diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ec54a7cb3d..e886c80c17 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -171,8 +171,15 @@ jobs: image: stalwartlabs/stalwart:v0.15.5 env: STALWART_ADMIN_PASSWORD: ${{ env.STALWART_PWD }} + # Pin a stable container hostname. Stalwart derives the authority of + # its published JMAP discovery URLs (apiUrl/downloadUrl/uploadUrl) from + # the container hostname + internal port, which is otherwise a random + # container id the runner cannot resolve. With a known hostname we can + # map it to loopback via /etc/hosts and publish the internal port 1:1 + # so the advertised http://stalwart:8080/jmap/ is reachable. + options: --hostname stalwart ports: - - 10080:8080 + - 8080:8080 - 10025:25 - 10143:143 - 10993:993 @@ -213,13 +220,15 @@ jobs: ports: - 6379:6379 steps: + - name: Map Stalwart hostname to loopback + run: echo "127.0.0.1 stalwart" | sudo tee -a /etc/hosts - name: Create domain and account in Stalwart run: | - curl -sf -X POST http://localhost:10080/api/principal \ + curl -sf -X POST http://stalwart:8080/api/principal \ -u "admin:${{ env.STALWART_PWD }}" \ -H 'Content-Type: application/json' \ -d '{"type":"domain","name":"example.com"}' - curl -sf -X POST http://localhost:10080/api/principal \ + curl -sf -X POST http://stalwart:8080/api/principal \ -u "admin:${{ env.STALWART_PWD }}" \ -H 'Content-Type: application/json' \ -d '{"type":"individual","name":"user@example.com","secrets":["mypassword"],"emails":["user@example.com"],"roles":["user"]}' diff --git a/composer.json b/composer.json index 54f72197db..df6dabe78d 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,7 @@ "phpmailer/dkimvalidator": "^0.3.1", "rubix/ml": "2.5.5", "sabberworm/php-css-parser": "^9.4.0", - "sebastiankrupinski/jmap-client-php": "^2.0.0", + "sebastiankrupinski/jmap-client-php": "^2.1.0", "wamania/php-stemmer": "4.0 as 3.0", "youthweb/urllinker": "^2.1.0" }, diff --git a/composer.lock b/composer.lock index ddfc6823d8..de8926ff86 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b5da21087a50ee288a4a2337ec4fdf11", + "content-hash": "09b1b7f1bc1fa81ff19f79ec444faf95", "packages": [ { "name": "amphp/amp", @@ -3064,16 +3064,16 @@ }, { "name": "sebastiankrupinski/jmap-client-php", - "version": "v2.0.0", + "version": "v2.1.0", "source": { "type": "git", "url": "https://github.com/SebastianKrupinski/jmap-client-php.git", - "reference": "720ba6ee0579202e5fedab39847680cc5c7ebf58" + "reference": "a681cf6f0d79d236e02a3d065b820523000f4577" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SebastianKrupinski/jmap-client-php/zipball/720ba6ee0579202e5fedab39847680cc5c7ebf58", - "reference": "720ba6ee0579202e5fedab39847680cc5c7ebf58", + "url": "https://api.github.com/repos/SebastianKrupinski/jmap-client-php/zipball/a681cf6f0d79d236e02a3d065b820523000f4577", + "reference": "a681cf6f0d79d236e02a3d065b820523000f4577", "shasum": "" }, "require": { @@ -3123,7 +3123,7 @@ "issues": "https://github.com/SebastianKrupinski/jmap-client-php/issues", "source": "https://github.com/SebastianKrupinski/jmap-client-php" }, - "time": "2026-06-27T23:55:59+00:00" + "time": "2026-08-10T21:06:57+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4373,5 +4373,5 @@ "platform-overrides": { "php": "8.1" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 6af8cf630d..4f7c58ce61 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -17,7 +17,6 @@ use OCA\Mail\Contracts\IAvatarService; use OCA\Mail\Contracts\IDkimService; use OCA\Mail\Contracts\IDkimValidator; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Contracts\ITrustedSenderService; @@ -63,7 +62,6 @@ use OCA\Mail\Service\AvatarService; use OCA\Mail\Service\DkimService; use OCA\Mail\Service\DkimValidator; -use OCA\Mail\Service\MailManager; use OCA\Mail\Service\MailTransmission; use OCA\Mail\Service\Search\MailSearch; use OCA\Mail\Service\TrustedSenderService; @@ -121,7 +119,6 @@ public function register(IRegistrationContext $context): void { $context->registerServiceAlias(IAvatarService::class, AvatarService::class); $context->registerServiceAlias(IAttachmentService::class, AttachmentService::class); - $context->registerServiceAlias(IMailManager::class, MailManager::class); $context->registerServiceAlias(IMailSearch::class, MailSearch::class); $context->registerServiceAlias(IMailTransmission::class, MailTransmission::class); $context->registerServiceAlias(ITrustedSenderService::class, TrustedSenderService::class); diff --git a/lib/BackgroundJob/ContextChat/SubmitContentJob.php b/lib/BackgroundJob/ContextChat/SubmitContentJob.php index 11619b92fe..698881800a 100644 --- a/lib/BackgroundJob/ContextChat/SubmitContentJob.php +++ b/lib/BackgroundJob/ContextChat/SubmitContentJob.php @@ -13,9 +13,9 @@ use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; use OCA\Mail\Db\MessageMapper; +use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Exception\SmimeDecryptException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\ContextChat\TaskService; use OCA\Mail\Service\MailManager; @@ -36,7 +36,6 @@ public function __construct( private AccountService $accountService, private MailManager $mailManager, private MessageMapper $messageMapper, - private IMAPClientFactory $clientFactory, private ContextChatProvider $contextChatProvider, private IContentManager $contentManager, private LoggerInterface $logger, @@ -108,50 +107,39 @@ protected function run($argument): void { return; } - $client = $this->clientFactory->getClient($account); $items = []; - try { - $startTime = $this->time->getTime(); - foreach ($messages as $message) { - if ($this->time->getTime() - $startTime > ContextChatProvider::CONTEXT_CHAT_JOB_INTERVAL) { - break; - } - try { - $imapMessage = $this->mailManager->getImapMessage($client, $account, $mailbox, $message->getUid(), true); - } catch (ServiceException $e) { - // couldn't load message, let's skip it. Retrying would be too costly - continue; - } catch (SmimeDecryptException $e) { - // encryption problem, skip this message - continue; - } - - // Skip encrypted messages - if ($imapMessage->isEncrypted()) { - continue; - } - - $fullMessage = $imapMessage->getFullMessage($imapMessage->getUid(), true); - - $items[] = new ContentItem( - "{$mailbox->getId()}:{$message->getId()}", - $this->contextChatProvider->getId(), - $imapMessage->getSubject(), - $fullMessage['body'] ?? '', - 'E-Mail', - $imapMessage->getSentDate(), - [$account->getUserId()], - ); + $startTime = $this->time->getTime(); + foreach ($messages as $message) { + if ($this->time->getTime() - $startTime > ContextChatProvider::CONTEXT_CHAT_JOB_INTERVAL) { + break; } - } catch (\Throwable $e) { - $this->logger->warning('Exception occurred when trying to fetch messages for context chat', ['exception' => $e]); - } finally { try { - $client->close(); - } catch (\Horde_Imap_Client_Exception $e) { - $this->logger->debug('Failed to close IMAP client', ['exception' => $e]); + $imapMessage = $this->mailManager->getImapMessage($account, $mailbox, $message, true); + } catch (ServiceException|ClientException $e) { + // couldn't load message, let's skip it. Retrying would be too costly + continue; + } catch (SmimeDecryptException $e) { + // encryption problem, skip this message + continue; + } + + // Skip encrypted messages + if ($imapMessage->isEncrypted()) { + continue; } + + $fullMessage = $imapMessage->getFullMessage($imapMessage->getUid(), true); + + $items[] = new ContentItem( + "{$mailbox->getId()}:{$message->getId()}", + $this->contextChatProvider->getId(), + $imapMessage->getSubject(), + $fullMessage['body'] ?? '', + 'E-Mail', + $imapMessage->getSentDate(), + [$account->getUserId()], + ); } if (count($items) > 0) { diff --git a/lib/BackgroundJob/FollowUpClassifierJob.php b/lib/BackgroundJob/FollowUpClassifierJob.php index 0169aaf8f9..97fb5086c9 100644 --- a/lib/BackgroundJob/FollowUpClassifierJob.php +++ b/lib/BackgroundJob/FollowUpClassifierJob.php @@ -9,13 +9,13 @@ namespace OCA\Mail\BackgroundJob; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Message; use OCA\Mail\Db\ThreadMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\QueuedJob; use OCP\DB\Exception; @@ -31,7 +31,7 @@ public function __construct( ITimeFactory $time, private LoggerInterface $logger, private AccountService $accountService, - private IMailManager $mailManager, + private MailManager $mailManager, private AiIntegrationsService $aiService, private ThreadMapper $threadMapper, ) { @@ -104,12 +104,12 @@ public function run($argument): void { $this->logger->debug("Message requires follow-up: {$message->getId()}"); $tag = $this->mailManager->createTag('Follow up', '#d77000', $userId); - $this->mailManager->tagMessage( + $this->mailManager->tagMessages( $account, - $mailbox->getName(), - $message, + $mailbox, $tag, true, + $message, ); } } diff --git a/lib/BackgroundJob/MigrateImportantJob.php b/lib/BackgroundJob/MigrateImportantJob.php index c2cfaa2c71..59a86aafec 100644 --- a/lib/BackgroundJob/MigrateImportantJob.php +++ b/lib/BackgroundJob/MigrateImportantJob.php @@ -13,8 +13,8 @@ use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Migration\MigrateImportantFromImapAndDb; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; @@ -29,7 +29,7 @@ public function __construct( private MigrateImportantFromImapAndDb $migration, private LoggerInterface $logger, ITimeFactory $timeFactory, - private IMAPClientFactory $imapClientFactory, + private ProtocolFactory $protocolFactory, ) { parent::__construct($timeFactory); } @@ -58,10 +58,10 @@ public function run($argument) { } $account = new Account($mailAccount); - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { - if ($this->mailManager->isPermflagsEnabled($client, $account, $mailbox->getName()) === false) { + if ($this->mailManager->isPermflagsEnabled($account, $mailbox) === false) { $this->logger->debug("Permflags not enabled for <{$accountId}>"); return; } diff --git a/lib/BackgroundJob/QuotaJob.php b/lib/BackgroundJob/QuotaJob.php index 7b03ee91f9..69ed0dd6dd 100644 --- a/lib/BackgroundJob/QuotaJob.php +++ b/lib/BackgroundJob/QuotaJob.php @@ -8,8 +8,8 @@ namespace OCA\Mail\BackgroundJob; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; @@ -28,7 +28,7 @@ public function __construct( ITimeFactory $time, IUserManager $userManager, private AccountService $accountService, - private IMailManager $mailManager, + private MailManager $mailManager, IManager $notificationManager, private LoggerInterface $logger, IJobList $jobList, diff --git a/lib/BackgroundJob/RepairSyncJob.php b/lib/BackgroundJob/RepairSyncJob.php index 4d843af8d3..c7c614d05d 100644 --- a/lib/BackgroundJob/RepairSyncJob.php +++ b/lib/BackgroundJob/RepairSyncJob.php @@ -9,8 +9,10 @@ namespace OCA\Mail\BackgroundJob; +use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Events\SynchronizationEvent; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\Sync\SyncService; use OCP\AppFramework\Db\DoesNotExistException; @@ -25,6 +27,7 @@ class RepairSyncJob extends TimedJob { public function __construct( ITimeFactory $time, private SyncService $syncService, + private ProtocolFactory $protocolFactory, private AccountService $accountService, private IUserManager $userManager, private MailboxMapper $mailboxMapper, @@ -55,6 +58,15 @@ protected function run($argument): void { return; } + if ($account->getMailAccount()->getProtocol() !== MailAccount::PROTOCOL_IMAP) { + $this->logger->debug(sprintf( + 'Account %d uses %s, skipping IMAP repair sync after mailbox refresh', + $account->getId(), + $account->getMailAccount()->getProtocol(), + )); + return; + } + $user = $this->userManager->get($account->getUserId()); if ($user === null || !$user->isEnabled()) { $this->logger->debug(sprintf( @@ -65,6 +77,10 @@ protected function run($argument): void { return; } + $this->protocolFactory + ->mailboxConnector($account) + ->syncAll($account, true); + $rebuildThreads = false; $trashMailboxId = $account->getMailAccount()->getTrashMailboxId(); $snoozeMailboxId = $account->getMailAccount()->getSnoozeMailboxId(); diff --git a/lib/BackgroundJob/SyncJob.php b/lib/BackgroundJob/SyncJob.php index 52af3f345f..beeb6624a6 100644 --- a/lib/BackgroundJob/SyncJob.php +++ b/lib/BackgroundJob/SyncJob.php @@ -10,11 +10,11 @@ use Horde_Imap_Client_Exception; use OCA\Mail\AppInfo\Application; +use OCA\Mail\Db\MailAccount; use OCA\Mail\Exception\IncompleteSyncException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\MailboxSync; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\Sync\ImapToDbSynchronizer; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; @@ -38,8 +38,7 @@ public function __construct( ITimeFactory $time, IUserManager $userManager, private AccountService $accountService, - private MailboxSync $mailboxSync, - private ImapToDbSynchronizer $syncService, + private ProtocolFactory $protocolFactory, private LoggerInterface $logger, IJobList $jobList, private readonly IConfig $config, @@ -87,7 +86,8 @@ protected function run($argument) { return; } - if (!$account->getMailAccount()->canAuthenticateImap()) { + if ($account->getMailAccount()->getProtocol() === MailAccount::PROTOCOL_IMAP + && !$account->getMailAccount()->canAuthenticateImap()) { $this->logger->debug('No authentication on IMAP possible, skipping background sync job'); return; } @@ -128,8 +128,12 @@ protected function run($argument) { } try { - $this->mailboxSync->sync($account, $this->logger, true); - $this->syncService->syncAccount($account, $this->logger); + $this->protocolFactory + ->mailboxConnector($account) + ->syncAll($account, true); + $this->protocolFactory + ->messageConnector($account) + ->syncAll($account, false); } catch (IncompleteSyncException $e) { $this->logger->warning($e->getMessage(), [ 'exception' => $e, diff --git a/lib/BackgroundJob/TrashRetentionJob.php b/lib/BackgroundJob/TrashRetentionJob.php index e3e1bd3e0f..0d5fe6abf1 100644 --- a/lib/BackgroundJob/TrashRetentionJob.php +++ b/lib/BackgroundJob/TrashRetentionJob.php @@ -10,14 +10,13 @@ namespace OCA\Mail\BackgroundJob; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\MessageMapper; use OCA\Mail\Db\MessageRetentionMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; @@ -27,12 +26,11 @@ class TrashRetentionJob extends TimedJob { public function __construct( ITimeFactory $time, private LoggerInterface $logger, - private IMAPClientFactory $clientFactory, private MessageMapper $messageMapper, private MessageRetentionMapper $messageRetentionMapper, private MailAccountMapper $accountMapper, private MailboxMapper $mailboxMapper, - private IMailManager $mailManager, + private MailManager $mailManager, ) { parent::__construct($time); @@ -96,22 +94,16 @@ private function cleanTrash(Account $account, int $retentionSeconds): void { return; } - $client = $this->clientFactory->getClient($account); - try { - foreach ($messages as $message) { - $this->mailManager->deleteMessageWithClient( - $account, - $trashMailbox, - $message->getUid(), - $client, - ); - $this->messageRetentionMapper->deleteByMailboxIdAndUid( - $message->getMailboxId(), - $message->getUid(), - ); - } - } finally { - $client->logout(); + foreach ($messages as $message) { + $this->mailManager->deleteMessage( + $account, + $trashMailbox, + $message, + ); + $this->messageRetentionMapper->deleteByMailboxIdAndUid( + $message->getMailboxId(), + $message->getUid(), + ); } } } diff --git a/lib/Command/SyncAccount.php b/lib/Command/SyncAccount.php index cb91217cb9..7246b6a17e 100644 --- a/lib/Command/SyncAccount.php +++ b/lib/Command/SyncAccount.php @@ -12,10 +12,8 @@ use OCA\Mail\Account; use OCA\Mail\Exception\IncompleteSyncException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\IMAP\MailboxSync; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\Sync\ImapToDbSynchronizer; use OCA\Mail\Support\ConsoleLoggerDecorator; use OCP\AppFramework\Db\DoesNotExistException; use Psr\Log\LoggerInterface; @@ -33,10 +31,8 @@ final class SyncAccount extends Command { public function __construct( private AccountService $accountService, - private MailboxSync $mailboxSync, - private ImapToDbSynchronizer $syncService, + private ProtocolFactory $protocolFactory, private LoggerInterface $logger, - private IMAPClientFactory $clientFactory, ) { parent::__construct(); } @@ -46,7 +42,7 @@ public function __construct( */ protected function configure() { $this->setName('mail:account:sync'); - $this->setDescription('Synchronize an IMAP account'); + $this->setDescription('Synchronize a mail account'); $this->addArgument(self::ARGUMENT_ACCOUNT_ID, InputArgument::REQUIRED); $this->addOption(self::OPTION_FORCE, 'f', InputOption::VALUE_NONE); } @@ -78,8 +74,8 @@ private function sync(Account $account, bool $force, OutputInterface $output): v ); try { - $this->mailboxSync->sync($account, $consoleLogger, $force); - $this->syncService->syncAccount($account, $consoleLogger, $force); + $this->protocolFactory->mailboxConnector($account)->syncAll($account, $force); + $this->protocolFactory->messageConnector($account)->syncAll($account, $force); } catch (ServiceException $e) { if (!($e instanceof IncompleteSyncException)) { throw $e; @@ -89,9 +85,5 @@ private function sync(Account $account, bool $force, OutputInterface $output): v $output->writeln("Batch of new messages sync'ed. " . $mbs . 'MB of memory in use'); $this->sync($account, $force, $output); } - - foreach ($this->clientFactory->getLoginStats() as $host => $count) { - $consoleLogger->debug(sprintf('%d IMAP connection(s) to %s', $count, $host)); - } } } diff --git a/lib/Command/TestAccount.php b/lib/Command/TestAccount.php index 32abbba9dd..8f11e7f309 100644 --- a/lib/Command/TestAccount.php +++ b/lib/Command/TestAccount.php @@ -9,23 +9,56 @@ namespace OCA\Mail\Command; +use Horde_Imap_Client; use Horde_Imap_Client_Exception; +use Horde_Imap_Client_Ids; +use OCA\Mail\Account; +use OCA\Mail\AddressList; use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\Message; +use OCA\Mail\IMAP\FolderMapper; +use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; +use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\JMAP\JmapOperationsService; use OCP\AppFramework\Db\DoesNotExistException; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use function array_keys; +use function array_slice; +use function array_values; +use function count; +use function date; +use function in_array; +use function json_decode; +use function max; +use function mb_strimwidth; +use function microtime; +use function round; +use function sort; +use function strtolower; +use function usort; final class TestAccount extends Command { private const ARGUMENT_ACCOUNT_ID = 'account-id'; + private const OPTION_MAILBOX_LIMIT = 'mailboxes'; + private const OPTION_MESSAGE_LIMIT = 'messages'; + private const DEFAULT_MAILBOX_LIMIT = 10; + private const DEFAULT_MESSAGE_LIMIT = 5; public function __construct( private AccountService $accountService, private ProtocolFactory $protocolFactory, + private FolderMapper $folderMapper, + private ImapMessageMapper $imapMessageMapper, + private JmapOperationsService $jmapOperationsService, private LoggerInterface $logger, ) { parent::__construct(); @@ -36,118 +69,414 @@ protected function configure(): void { $this->setAliases(['mail:account:diagnose']); $this->setDescription('Test the connection for a mail account (IMAP or JMAP)'); $this->addArgument(self::ARGUMENT_ACCOUNT_ID, InputArgument::REQUIRED, 'The ID of the mail account'); + $this->addOption(self::OPTION_MAILBOX_LIMIT, null, InputOption::VALUE_OPTIONAL, 'Number of mailboxes to list', (string)self::DEFAULT_MAILBOX_LIMIT); + $this->addOption(self::OPTION_MESSAGE_LIMIT, null, InputOption::VALUE_OPTIONAL, 'Number of recent inbox messages to list', (string)self::DEFAULT_MESSAGE_LIMIT); + $this->setHelp(<<<'HELP' + The mail:account:test command checks connectivity for a stored + mail account and prints protocol-specific diagnostics, mailbox listings, + and a short inbox preview. + + Examples: + + Test an account by ID: + php occ mail:account:test 42 + + Limit the mailbox and inbox preview output: + php occ mail:account:test 42 --mailboxes=5 --messages=3 + + Use the legacy alias: + php occ mail:account:diagnose 42 + + The command detects whether the account uses IMAP or JMAP and prints the + corresponding endpoint, authentication context, latency, capabilities, + mailboxes, and recent inbox messages. + HELP); } protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); $accountId = (int)$input->getArgument(self::ARGUMENT_ACCOUNT_ID); + $mailboxLimit = max(1, (int)$input->getOption(self::OPTION_MAILBOX_LIMIT)); + $messageLimit = max(1, (int)$input->getOption(self::OPTION_MESSAGE_LIMIT)); try { $account = $this->accountService->findById($accountId); } catch (DoesNotExistException $e) { - $output->writeln("Account $accountId does not exist"); - return 1; + $io->error("Account $accountId does not exist"); + return self::FAILURE; } $protocol = $account->getMailAccount()->getProtocol(); - $output->writeln("Account $accountId uses protocol: $protocol"); + $this->renderAccountSummary($account, $io); return match ($protocol) { - MailAccount::PROTOCOL_IMAP => $this->testImap($account, $output), - MailAccount::PROTOCOL_JMAP => $this->testJmap($account, $output), - default => $this->unsupportedProtocol($protocol, $output), + MailAccount::PROTOCOL_IMAP => $this->testImap($account, $io, $mailboxLimit, $messageLimit), + MailAccount::PROTOCOL_JMAP => $this->testJmap($account, $io, $mailboxLimit, $messageLimit), + default => $this->unsupportedProtocol($protocol, $io), }; } - private function testImap(\OCA\Mail\Account $account, OutputInterface $output): int { - $output->writeln('Testing IMAP connection...'); + private function renderAccountSummary(Account $account, SymfonyStyle $io): void { + $mailAccount = $account->getMailAccount(); + + $io->title('Mail Account Connection Test'); + $io->definitionList( + ['Account ID' => (string)$account->getId()], + ['Email' => $account->getEmail()], + ['Name' => $account->getName()], + ['Protocol' => $mailAccount->getProtocol()], + ['Authentication' => $mailAccount->getAuthMethod()], + ); + } + + private function testImap(Account $account, SymfonyStyle $io, int $mailboxLimit, int $messageLimit): int { + $io->section('IMAP Test'); $mailAccount = $account->getMailAccount(); $sslMode = $mailAccount->getInboundSslMode(); $scheme = ($sslMode === 'none') ? 'imap' : 'imaps'; $host = $mailAccount->getInboundHost() ?? '(not set)'; $port = $mailAccount->getInboundPort(); - $output->writeln('Server: ' . $scheme . '://' . $host . ':' . $port . ''); + $io->definitionList( + ['Server' => $scheme . '://' . $host . ':' . $port], + ['Username' => $mailAccount->getInboundUser()], + ['Security' => $sslMode], + ); if ($account->getMailAccount()->getInboundPassword() === null) { - $output->writeln('No IMAP password set. The user may need to log in to set it.'); - return 1; + $io->error('No IMAP password set. The user may need to log in to set it.'); + return self::FAILURE; } + $io->text('Opening IMAP connection...'); + try { $imapClient = $this->protocolFactory->imapClient($account); } catch (\Exception $e) { - $output->writeln('Could not create IMAP client: ' . $e->getMessage() . ''); - return 2; + $io->error('Could not create IMAP client: ' . $e->getMessage()); + return self::FAILURE; } + $startTime = microtime(true); try { $imapClient->login(); - $output->writeln('Login successful'); + $latency = (int)round(max(0, microtime(true) - $startTime) * 1000); + $mailboxes = $this->folderMapper->getFolders($account, $imapClient); + $this->folderMapper->fetchFolderAcls($mailboxes, $imapClient); $capabilities = array_keys( - json_decode($imapClient->capability->serialize(), true) + json_decode($imapClient->capability->serialize(), true, 512, JSON_THROW_ON_ERROR) ); sort($capabilities); - $output->writeln('Capabilities: ' . implode(', ', $capabilities) . ''); - $output->writeln('IMAP connection test passed'); - return 0; + $io->success('IMAP connection test passed.'); + $io->definitionList( + ['Login' => 'Successful'], + ['Latency' => $latency . ' ms'], + ['Capabilities' => (string)count($capabilities)], + ); + + if ($capabilities === []) { + $io->note('The server returned no CAPABILITY entries.'); + } else { + $io->listing($capabilities); + } + + $this->renderImapMailboxPreview($account, $imapClient, $mailboxes, $io, $mailboxLimit, $messageLimit); + + return self::SUCCESS; } catch (Horde_Imap_Client_Exception $e) { $this->logger->error('IMAP connection test failed for account ' . $account->getId() . ': ' . $e->getMessage(), [ 'exception' => $e, ]); - $output->writeln('IMAP connection test failed: ' . $e->getMessage() . ''); - return 2; + $io->error('IMAP connection test failed: ' . $e->getMessage()); + return self::FAILURE; } finally { $imapClient->logout(); } } - private function testJmap(\OCA\Mail\Account $account, OutputInterface $output): int { - $output->writeln('Testing JMAP connection...'); + private function testJmap(Account $account, SymfonyStyle $io, int $mailboxLimit, int $messageLimit): int { + $io->section('JMAP Test'); $mailAccount = $account->getMailAccount(); $sslMode = $mailAccount->getInboundSslMode(); - $scheme = ($sslMode === 'none') ? 'http' : 'https'; + $scheme = ($sslMode === 'yes') ? 'https' : 'http'; $host = $mailAccount->getInboundHost() ?? '(not set)'; $port = $mailAccount->getInboundPort(); $path = $mailAccount->getPath() ?? '/.well-known/jmap'; - $output->writeln('Server: ' . $scheme . '://' . $host . ':' . $port . $path . ''); + $io->definitionList( + ['Server' => $scheme . '://' . $host . ':' . $port . $path], + ['Username' => $mailAccount->getInboundUser()], + ['Security' => $sslMode], + ); + + if ($mailAccount->getInboundPassword() === null) { + $io->error('No JMAP password set. The user may need to log in to set it.'); + return self::FAILURE; + } + + $io->text('Opening JMAP session...'); + $startTime = microtime(true); try { $client = $this->protocolFactory->jmapClient($account); $session = $client->connect(); + $this->jmapOperationsService->connect($account); } catch (\Exception $e) { $this->logger->error('JMAP connection test failed for account ' . $account->getId() . ': ' . $e->getMessage(), [ 'exception' => $e, ]); - $output->writeln('JMAP connection test failed: ' . $e->getMessage() . ''); - return 2; + $io->error('JMAP connection test failed: ' . $e->getMessage()); + return self::FAILURE; } if (!$client->sessionStatus()) { - $output->writeln('JMAP session discovery failed. Check the server and credentials.'); - return 2; + $io->error('JMAP session discovery failed. Check the server and credentials.'); + return self::FAILURE; } - $output->writeln('JMAP session established'); - $output->writeln('Username: ' . $session->username() . ''); - $output->writeln('API URL: ' . $session->commandUrl() . ''); - $output->writeln('State: ' . $session->state() . ''); + $latency = (int)round(max(0, microtime(true) - $startTime) * 1000); + + $io->success('JMAP connection test passed.'); + $io->definitionList( + ['Session' => 'Established'], + ['Username' => $session->username()], + ['API URL' => $session->commandUrl()], + ['State' => $session->state()], + ['Latency' => $latency . ' ms'], + ); $capabilities = []; foreach ($session->capabilities() as $capability) { $capabilities[] = $capability->id(); } sort($capabilities); - $output->writeln('Capabilities: ' . implode(', ', $capabilities) . ''); - $output->writeln('JMAP connection test passed'); - return 0; + if ($capabilities === []) { + $io->note('The server returned no JMAP capabilities.'); + } else { + $io->listing($capabilities); + } + + $this->renderJmapMailboxPreview($io, $mailboxLimit, $messageLimit); + + return self::SUCCESS; + } + + /** + * @param list<\OCA\Mail\Folder> $folders + */ + private function renderImapMailboxPreview(Account $account, $imapClient, array $folders, SymfonyStyle $io, int $mailboxLimit, int $messageLimit): void { + $io->section('Mailboxes'); + + usort($folders, static fn ($left, $right) => strcmp($left->getMailbox(), $right->getMailbox())); + $rows = []; + foreach (array_slice($folders, 0, $mailboxLimit) as $folder) { + $status = $this->folderMapper->getFolderStatus($imapClient, $folder->getMailbox()); + $attributes = array_map(static fn (string $attribute) => strtolower($attribute), $folder->getAttributes()); + $rows[] = [ + $folder->getMailbox(), + $folder->getDelimiter() ?? 'NIL', + in_array('\\noselect', $attributes, true) ? 'no' : 'yes', + $status !== null ? (string)$status->getTotal() : 'N/A', + $status !== null ? (string)$status->getUnread() : 'N/A', + ]; + } + + if ($rows === []) { + $io->note('No mailboxes returned by the IMAP server.'); + } else { + $io->table(['Mailbox', 'Delimiter', 'Selectable', 'Messages', 'Unseen'], $rows); + if (count($folders) > $mailboxLimit) { + $io->note('Showing the first ' . $mailboxLimit . ' mailboxes. Increase --mailboxes to see more.'); + } + } + + $io->section('Inbox Preview'); + $inbox = array_values(array_filter($folders, static fn ($folder) => strtolower($folder->getMailbox()) === 'inbox'))[0] ?? null; + if ($inbox === null) { + $io->note('No INBOX mailbox returned by the IMAP server.'); + return; + } + + try { + $messages = $this->loadRecentImapInboxMessages($account, $imapClient, $inbox->getMailbox(), $messageLimit); + } catch (\Throwable $e) { + $this->logger->warning('Could not load IMAP inbox preview for account ' . $account->getId() . ': ' . $e->getMessage(), [ + 'exception' => $e, + ]); + $io->warning('Connected successfully, but could not load recent inbox messages: ' . $e->getMessage()); + return; + } + + $this->renderMessageTable($io, $this->buildImapMessageRows($messages)); + } + + /** + * @return list + */ + private function loadRecentImapInboxMessages(Account $account, $imapClient, string $mailbox, int $messageLimit): array { + $metaResults = $imapClient->search( + $mailbox, + null, + [ + 'results' => [ + Horde_Imap_Client::SEARCH_RESULTS_MIN, + Horde_Imap_Client::SEARCH_RESULTS_MAX, + Horde_Imap_Client::SEARCH_RESULTS_COUNT, + ], + ] + ); + + $total = (int)($metaResults['count'] ?? 0); + if ($total === 0) { + return []; + } + + $maxUid = $metaResults['max']; + if ($maxUid === null) { + $status = $imapClient->status($mailbox); + $maxUid = ((int)($status['uidnext'] ?? 1)) - 1; + } + + $lower = max(1, (int)$maxUid - max(50, $messageLimit * 20)); + $uids = new Horde_Imap_Client_Ids($lower . ':' . (int)$maxUid); + $messages = $this->imapMessageMapper->findByIds($imapClient, $mailbox, $uids, $account->getUserId(), false); + + usort($messages, static fn (IMAPMessage $left, IMAPMessage $right) => $right->getSentDate()->getTimestamp() <=> $left->getSentDate()->getTimestamp()); + + return array_slice($messages, 0, $messageLimit); + } + + private function renderJmapMailboxPreview(SymfonyStyle $io, int $mailboxLimit, int $messageLimit): void { + $io->section('Mailboxes'); + $mailboxes = $this->jmapOperationsService->collectionList(null, [], [ + ['attribute' => 'order', 'direction' => true], + ['attribute' => 'name', 'direction' => true], + ]); + + $rows = []; + foreach (array_slice($mailboxes, 0, $mailboxLimit) as $mailbox) { + $rows[] = [ + $mailbox->getName(), + $mailbox->getDelimiter() ?? 'NIL', + $mailbox->getSelectable() ? 'yes' : 'no', + (string)$mailbox->getMessages(), + (string)$mailbox->getUnseen(), + ]; + } + + if ($rows === []) { + $io->note('No mailboxes returned by the JMAP server.'); + } else { + $io->table(['Mailbox', 'Delimiter', 'Selectable', 'Messages', 'Unseen'], $rows); + if (count($mailboxes) > $mailboxLimit) { + $io->note('Showing the first ' . $mailboxLimit . ' mailboxes. Increase --mailboxes to see more.'); + } + } + + $io->section('Inbox Preview'); + $inbox = $this->findInboxMailbox($mailboxes); + if ($inbox === null || $inbox->getRemoteId() === null) { + $io->note('No INBOX mailbox returned by the JMAP server.'); + return; + } + + $messages = $this->jmapOperationsService->entityList( + $inbox->getRemoteId(), + [], + [['attribute' => 'received', 'direction' => true]], + ['anchor' => 'absolute', 'position' => 0, 'tally' => $messageLimit] + ); + + /** @var list $messageList */ + $messageList = $messages['list'] ?? []; + $this->renderMessageTable($io, $this->buildJmapMessageRows($messageList)); + } + + /** + * @param Mailbox[] $mailboxes + */ + private function findInboxMailbox(array $mailboxes): ?Mailbox { + foreach ($mailboxes as $mailbox) { + if ($mailbox->isSpecialUse('inbox') || $mailbox->isInbox()) { + return $mailbox; + } + } + + return null; + } + + /** + * @param list $messages + * @return list> + */ + private function buildImapMessageRows(array $messages): array { + $rows = []; + foreach ($messages as $message) { + $rows[] = [ + (string)$message->getUid(), + date('Y-m-d H:i', $message->getSentDate()->getTimestamp()), + $this->formatAddressList($message->getFrom()), + $this->truncate($message->getSubject()), + '', + ]; + } + + return $rows; + } + + /** + * @param list $messages + * @return list> + */ + private function buildJmapMessageRows(array $messages): array { + $rows = []; + foreach ($messages as $message) { + $rows[] = [ + (string)($message->getRemoteId() ?? $message->getUid()), + date('Y-m-d H:i', $message->getSentAt()), + $this->formatAddressList($message->getFrom()), + $this->truncate($message->getSubject()), + $this->truncate($message->getPreviewText() ?? ''), + ]; + } + + return $rows; + } + + /** + * @param list> $rows + */ + private function renderMessageTable(SymfonyStyle $io, array $rows): void { + if ($rows === []) { + $io->note('No recent messages found in INBOX.'); + return; + } + + $io->table(['UID', 'Date', 'From', 'Subject', 'Preview'], $rows); + } + + private function formatAddressList(AddressList $addresses): string { + $first = $addresses->first(); + if ($first === null) { + return 'NIL'; + } + + return $first->getLabel() ?? $first->getEmail() ?? 'NIL'; + } + + private function truncate(string $value, int $length = 60): string { + if ($value === '') { + return ''; + } + + return mb_strimwidth($value, 0, $length, '...'); } - private function unsupportedProtocol(string $protocol, OutputInterface $output): int { - $output->writeln("Unsupported protocol: $protocol"); - return 1; + private function unsupportedProtocol(string $protocol, SymfonyStyle $io): int { + $io->error("Unsupported protocol: $protocol"); + return self::FAILURE; } } diff --git a/lib/Contracts/IDkimService.php b/lib/Contracts/IDkimService.php index 024ad1a954..b21ed14fdb 100644 --- a/lib/Contracts/IDkimService.php +++ b/lib/Contracts/IDkimService.php @@ -11,8 +11,9 @@ use OCA\Mail\Account; use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\Message; interface IDkimService { - public function validate(Account $account, Mailbox $mailbox, int $id): bool; + public function validate(Account $account, Mailbox $mailbox, Message $message): bool; public function getCached(Account $account, Mailbox $mailbox, int $id): ?bool; } diff --git a/lib/Contracts/IMailManager.php b/lib/Contracts/IMailManager.php deleted file mode 100644 index c18bf8f4a1..0000000000 --- a/lib/Contracts/IMailManager.php +++ /dev/null @@ -1,349 +0,0 @@ -clientFactory->getClient($account); try { $imapMessage = $this->mailManager->getImapMessage( - $client, $account, $mailbox, - $message->getUid(), + $message, true ); $unsubscribeUrl = $imapMessage->getUnsubscribeUrl(); @@ -86,8 +82,6 @@ public function unsubscribe(int $id): JsonResponse { 'exception' => $e, ]); return JsonResponse::error('Unknown error'); - } finally { - $client->logout(); } $this->delegationService->logDelegatedAction($this->userId, $effectiveUserId, "$this->userId unsubscribed from mailing list: $id on behalf of $effectiveUserId"); diff --git a/lib/Controller/MailboxesApiController.php b/lib/Controller/MailboxesApiController.php index cbb14b558f..2b7d3bbb5d 100644 --- a/lib/Controller/MailboxesApiController.php +++ b/lib/Controller/MailboxesApiController.php @@ -9,12 +9,12 @@ namespace OCA\Mail\Controller; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Exception\ClientException; use OCA\Mail\ResponseDefinitions; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\DelegationService; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; @@ -32,7 +32,7 @@ public function __construct( string $appName, IRequest $request, private readonly ?string $userId, - private IMailManager $mailManager, + private MailManager $mailManager, private readonly AccountService $accountService, private IMailSearch $mailSearch, private DelegationService $delegationService, diff --git a/lib/Controller/MailboxesController.php b/lib/Controller/MailboxesController.php index b7dad5a191..a36a3a1b4f 100644 --- a/lib/Controller/MailboxesController.php +++ b/lib/Controller/MailboxesController.php @@ -12,8 +12,8 @@ use Horde_Imap_Client; use OCA\Mail\AppInfo\Application; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; +use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\IncompleteSyncException; use OCA\Mail\Exception\MailboxNotCachedException; @@ -22,6 +22,7 @@ use OCA\Mail\Http\TrapError; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\DelegationService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\Sync\SyncService; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; @@ -41,11 +42,12 @@ public function __construct( IRequest $request, private AccountService $accountService, private ?string $userId, - private IMailManager $mailManager, + private MailManager $mailManager, private SyncService $syncService, private readonly IConfig $config, private readonly ITimeFactory $timeFactory, private DelegationService $delegationService, + private MailboxMapper $mailboxMapper, ) { parent::__construct($appName, $request); } @@ -127,10 +129,8 @@ public function patch(int $id, } if ($syncInBackground !== null) { - $mailbox = $this->mailManager->enableMailboxBackgroundSync( - $mailbox, - $syncInBackground - ); + $mailbox->setSyncInBackground($syncInBackground); + $this->mailboxMapper->update($mailbox); $syncVerb = $syncInBackground ? 'enabled' : 'disabled'; $this->delegationService->logDelegatedAction($this->userId, $effectiveUserId, "$this->userId $syncVerb background sync for mailbox: $id on behalf of $effectiveUserId"); } diff --git a/lib/Controller/MessageApiController.php b/lib/Controller/MessageApiController.php index 3306de70cd..0b4f786cf1 100644 --- a/lib/Controller/MessageApiController.php +++ b/lib/Controller/MessageApiController.php @@ -15,7 +15,6 @@ use OCA\Mail\Exception\SmimeDecryptException; use OCA\Mail\Exception\UploadException; use OCA\Mail\Http\TrapError; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Model\SmimeData; use OCA\Mail\ResponseDefinitions; use OCA\Mail\Service\AccountService; @@ -59,7 +58,6 @@ public function __construct( private AttachmentService $attachmentService, private OutboxService $outboxService, private MailManager $mailManager, - private IMAPClientFactory $clientFactory, private LoggerInterface $logger, private ITimeFactory $time, private IURLGenerator $urlGenerator, @@ -252,13 +250,11 @@ public function get(int $id): DataResponse { } $loadBody = true; - $client = $this->clientFactory->getClient($account); try { $imapMessage = $this->mailManager->getImapMessage( - $client, $account, $mailbox, - $message->getUid(), + $message, true ); } catch (ServiceException $e) { @@ -268,13 +264,10 @@ public function get(int $id): DataResponse { $this->logger->warning('Message could not be decrypted', ['exception' => $e->getMessage()]); $loadBody = false; $imapMessage = $this->mailManager->getImapMessage( - $client, $account, $mailbox, - $message->getUid() + $message ); - } finally { - $client->logout(); } $json = $imapMessage->getFullMessage($id, $loadBody); @@ -344,19 +337,15 @@ public function getRaw(int $id): DataResponse { return new DataResponse('Message, Account or Mailbox not found', Http::STATUS_NOT_FOUND); } - $client = $this->clientFactory->getClient($account); try { $source = $this->mailManager->getSource( - $client, $account, - $mailbox->getName(), - $message->getUid() + $mailbox, + $message ); } catch (ServiceException $e) { $this->logger->error('Message not found on IMAP, or mail server went away', ['exception' => $e->getMessage()]); return new DataResponse('Message not found', Http::STATUS_NOT_FOUND); - } finally { - $client->logout(); } return new DataResponse($source, Http::STATUS_OK); diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php index 098757c908..c508e26569 100755 --- a/lib/Controller/MessagesController.php +++ b/lib/Controller/MessagesController.php @@ -14,7 +14,6 @@ use OC\Security\CSP\ContentSecurityPolicyNonceManager; use OCA\Mail\Attachment; use OCA\Mail\Contracts\IDkimService; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Contracts\ITrustedSenderService; @@ -25,12 +24,12 @@ use OCA\Mail\Http\AttachmentDownloadResponse; use OCA\Mail\Http\HtmlResponse; use OCA\Mail\Http\TrapError; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Model\SmimeData; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\DelegationService; use OCA\Mail\Service\ItineraryService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\SmimeService; use OCA\Mail\Service\SnoozeService; use OCP\AppFramework\Controller; @@ -67,7 +66,7 @@ public function __construct( string $appName, IRequest $request, private AccountService $accountService, - private IMailManager $mailManager, + private MailManager $mailManager, private IMailSearch $mailSearch, private ItineraryService $itineraryService, private ?string $userId, @@ -81,7 +80,6 @@ public function __construct( private ITrustedSenderService $trustedSenderService, private IMailTransmission $mailTransmission, private SmimeService $smimeService, - private IMAPClientFactory $clientFactory, private IDkimService $dkimService, private IUserPreferences $preferences, private SnoozeService $snoozeService, @@ -214,25 +212,20 @@ public function getBody(int $id): JSONResponse { $cacheInstance = $this->getCacheForAccount($account->getId()); $imapMessageCacheKey = "message_$id"; - $client = $this->clientFactory->getClient($account); - try { - $imapMessage = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), true - ); - - if ($imapMessage->hasHtmlMessage()) { - $cacheInstance->set($imapMessageCacheKey, $imapMessage->getHtmlBody($id), 600); - } + $imapMessage = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message, + true + ); - $json = $imapMessage->getFullMessage($id); - } finally { - $client->logout(); + if ($imapMessage->hasHtmlMessage()) { + $cacheInstance->set($imapMessageCacheKey, $imapMessage->getHtmlBody($id), 600); } - $itineraries = $this->itineraryService->getCached($account, $mailbox, $message->getUid()); + $json = $imapMessage->getFullMessage($id); + + $itineraries = $this->itineraryService->getCached($account, $mailbox, $message->getId()); if ($itineraries) { $json['itineraries'] = $itineraries; } @@ -251,7 +244,7 @@ public function getBody(int $id): JSONResponse { } $json['smime'] = $smimeData; - $dkimResult = $this->dkimService->getCached($account, $mailbox, $message->getUid()); + $dkimResult = $this->dkimService->getCached($account, $mailbox, $message->getId()); if (is_bool($dkimResult)) { $json['dkimValid'] = $dkimResult; } @@ -287,7 +280,7 @@ public function getItineraries(int $id): JSONResponse { return new JSONResponse([], Http::STATUS_FORBIDDEN); } - $response = new JsonResponse($this->itineraryService->extract($account, $mailbox, $message->getUid())); + $response = new JsonResponse($this->itineraryService->extract($account, $mailbox, $message)); $response->cacheFor(24 * 60 * 60, false, true); return $response; } @@ -310,7 +303,7 @@ public function getDkim(int $id): JSONResponse { return new JSONResponse([], Http::STATUS_FORBIDDEN); } - $response = new JSONResponse(['valid' => $this->dkimService->validate($account, $mailbox, $message->getUid())]); + $response = new JSONResponse(['valid' => $this->dkimService->validate($account, $mailbox, $message)]); $response->cacheFor(24 * 60 * 60, false, true); return $response; } @@ -395,10 +388,10 @@ public function move(int $id, int $destFolderId): JSONResponse { $this->mailManager->moveMessage( $srcAccount, - $srcMailbox->getName(), - $message->getUid(), + $srcMailbox, + $message, $dstAccount, - $dstMailbox->getName() + $dstMailbox ); $this->delegationService->logDelegatedAction($this->userId, $effectiveUserId, "$this->userId moved message <$id> to mailbox <$destFolderId> on behalf of $effectiveUserId"); @@ -498,7 +491,7 @@ public function mdn(int $id): JSONResponse { try { $this->mailTransmission->sendMdn($account, $mailbox, $message); - $this->mailManager->flagMessage($account, $mailbox->getName(), $message->getUid(), '$mdnsent', true); + $this->mailManager->flagMessages($account, $mailbox, '$mdnsent', true, $message); } catch (ServiceException $ex) { $this->logger->error('Sending mdn failed: ' . $ex->getMessage()); throw $ex; @@ -527,19 +520,13 @@ public function getSource(int $id): JSONResponse { return new JSONResponse([], Http::STATUS_FORBIDDEN); } - $client = $this->clientFactory->getClient($account); - try { - $response = new JSONResponse([ - 'source' => $this->mailManager->getSource( - $client, - $account, - $mailbox->getName(), - $message->getUid() - ) - ]); - } finally { - $client->logout(); - } + $response = new JSONResponse([ + 'source' => $this->mailManager->getSource( + $account, + $mailbox, + $message + ) + ]); // Enable caching $response->cacheFor(60 * 60, false, true); @@ -572,17 +559,11 @@ public function export(int $id): Response { return new JSONResponse([], Http::STATUS_FORBIDDEN); } - $client = $this->clientFactory->getClient($account); - try { - $source = $this->mailManager->getSource( - $client, - $account, - $mailbox->getName(), - $message->getUid() - ); - } finally { - $client->logout(); - } + $source = $this->mailManager->getSource( + $account, + $mailbox, + $message + ); return new AttachmentDownloadResponse( $source ?? '', @@ -630,17 +611,11 @@ public function saveFile(int $id, string $targetPath): Response { return new JSONResponse([], Http::STATUS_FORBIDDEN); } - $client = $this->clientFactory->getClient($account); - try { - $source = $this->mailManager->getSource( - $client, - $account, - $mailbox->getName(), - $message->getUid() - ); - } finally { - $client->logout(); - } + $source = $this->mailManager->getSource( + $account, + $mailbox, + $message + ); if ($source === null) { return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); @@ -704,18 +679,12 @@ public function getHtmlBody(int $id, bool $plain = false): Response { $html = $cacheInstance->get($imapMessageCacheKey); if ($html === null) { - $client = $this->clientFactory->getClient($account); - try { - $html = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), - true - )->getHtmlBody($id); - } finally { - $client->logout(); - } + $html = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message, + true + )->getHtmlBody($id); } $htmlResponse = $plain @@ -949,7 +918,7 @@ public function setFlags(int $id, array $flags): JSONResponse { $flagChanges = []; foreach ($flags as $flag => $value) { $value = filter_var($value, FILTER_VALIDATE_BOOLEAN); - $this->mailManager->flagMessage($account, $mailbox->getName(), $message->getUid(), $flag, $value); + $this->mailManager->flagMessages($account, $mailbox, $flag, $value, $message); $flagChanges[] = "$flag=" . ($value ? 'true' : 'false'); } $flagsSummary = implode(', ', $flagChanges); @@ -983,12 +952,12 @@ public function setTag(int $id, string $imapLabel): JSONResponse { } try { - $tag = $this->mailManager->getTagByImapLabel($imapLabel, $this->userId); + $tag = $this->mailManager->getTagByLabel($imapLabel, $this->userId); } catch (ClientException $e) { return new JSONResponse([], Http::STATUS_FORBIDDEN); } - $this->mailManager->tagMessage($account, $mailbox->getName(), $message, $tag, true); + $this->mailManager->tagMessages($account, $mailbox, $tag, true, $message); $this->delegationService->logDelegatedAction($this->userId, $effectiveUserId, "$this->userId added tag <$imapLabel> on message <$id> on behalf of $effectiveUserId"); return new JSONResponse($tag); } @@ -1019,12 +988,12 @@ public function removeTag(int $id, string $imapLabel): JSONResponse { } try { - $tag = $this->mailManager->getTagByImapLabel($imapLabel, $this->userId); + $tag = $this->mailManager->getTagByLabel($imapLabel, $this->userId); } catch (ClientException $e) { return new JSONResponse([], Http::STATUS_FORBIDDEN); } - $this->mailManager->tagMessage($account, $mailbox->getName(), $message, $tag, false); + $this->mailManager->tagMessages($account, $mailbox, $tag, false, $message); $this->delegationService->logDelegatedAction($this->userId, $effectiveUserId, "$this->userId removed tag <$imapLabel> on message <$id> on behalf of $effectiveUserId"); return new JSONResponse($tag); } @@ -1055,8 +1024,8 @@ public function destroy(int $id): JSONResponse { $this->mailManager->deleteMessage( $account, - $mailbox->getName(), - $message->getUid() + $mailbox, + $message ); $this->delegationService->logDelegatedAction($this->userId, $effectiveUserId, "$this->userId deleted message <$id> on behalf of $effectiveUserId"); return new JSONResponse(); diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 00360f58db..4b691b3d64 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -13,7 +13,6 @@ use OCA\Contacts\Event\LoadContactsOcaApiEvent; use OCA\Mail\AppInfo\Application; use OCA\Mail\ConfigLexicon; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IUserPreferences; use OCA\Mail\Db\SmimeCertificate; use OCA\Mail\Db\TagMapper; @@ -23,6 +22,7 @@ use OCA\Mail\Service\Classification\ClassificationSettingsService; use OCA\Mail\Service\ContextChat\ContextChatSettingsService; use OCA\Mail\Service\InternalAddressService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\QuickActionsService; use OCA\Mail\Service\SmimeService; @@ -75,7 +75,7 @@ public function __construct( private ?string $userId, IUserSession $userSession, private IUserPreferences $preferences, - private IMailManager $mailManager, + private MailManager $mailManager, private TagMapper $tagMapper, IInitialState $initialStateService, private LoggerInterface $logger, diff --git a/lib/Controller/TagsController.php b/lib/Controller/TagsController.php index 52774eb054..1bcf045e0a 100644 --- a/lib/Controller/TagsController.php +++ b/lib/Controller/TagsController.php @@ -10,10 +10,10 @@ namespace OCA\Mail\Controller; use OCA\Mail\AppInfo\Application; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Exception\ClientException; use OCA\Mail\Http\TrapError; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -26,7 +26,7 @@ class TagsController extends Controller { public function __construct( IRequest $request, private string $userId, - private IMailManager $mailManager, + private MailManager $mailManager, private AccountService $accountService, ) { parent::__construct(Application::APP_ID, $request); diff --git a/lib/Controller/ThreadController.php b/lib/Controller/ThreadController.php index c4f0e15718..af3f3ba883 100755 --- a/lib/Controller/ThreadController.php +++ b/lib/Controller/ThreadController.php @@ -9,13 +9,13 @@ namespace OCA\Mail\Controller; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Http\TrapError; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\DelegationService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\SnoozeService; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; @@ -32,7 +32,7 @@ public function __construct( IRequest $request, private string $userId, private AccountService $accountService, - private IMailManager $mailManager, + private MailManager $mailManager, private SnoozeService $snoozeService, private AiIntegrationsService $aiIntergrationsService, private LoggerInterface $logger, diff --git a/lib/Db/Mailbox.php b/lib/Db/Mailbox.php index 5408bf3fb1..2ca2654e69 100644 --- a/lib/Db/Mailbox.php +++ b/lib/Db/Mailbox.php @@ -125,9 +125,18 @@ public function isSpecialUse(string $specialUse): bool { } public function isCached(): bool { - return $this->getSyncNewToken() !== null + if ($this->getSyncNewToken() !== null && $this->getSyncChangedToken() !== null - && $this->getSyncVanishedToken() !== null; + && $this->getSyncVanishedToken() !== null + ) { + return true; + } + + if ($this->getState() !== null) { + return true; + } + + return false; } private function isLockActive(?int $lock, int $now): bool { @@ -160,6 +169,14 @@ public function getStats(): MailboxStats { } public function getCacheBuster(): string { + $state = $this->getState(); + if ($state !== null) { + return hash('md5', implode('|', [ + (string)$this->getId(), + $state, + ])); + } + return hash('md5', implode('|', [ (string)$this->getId(), $this->getSyncNewToken() ?? 'null', diff --git a/lib/Db/MailboxMapper.php b/lib/Db/MailboxMapper.php index 7ece9a8a1f..11a8f15232 100644 --- a/lib/Db/MailboxMapper.php +++ b/lib/Db/MailboxMapper.php @@ -169,6 +169,16 @@ public function findAccountIdForMailbox(int $mailboxId): int { return (int)$row['account_id']; } + public function findSpecialUseMailbox(Account $account, string $specialUse): ?Mailbox { + foreach ($this->findAll($account) as $mailbox) { + if ($mailbox->isSpecialUse($specialUse) || ($specialUse === 'inbox' && $mailbox->isInbox())) { + return $mailbox; + } + } + + return null; + } + /** * @throws MailboxLockedException */ diff --git a/lib/Db/Message.php b/lib/Db/Message.php index 35d4aa1af3..0241cce66d 100644 --- a/lib/Db/Message.php +++ b/lib/Db/Message.php @@ -23,7 +23,7 @@ * @method void setUid(int $uid) * @method int getUid() * @method string|null getMessageId() - * @method void setReferences(string $references) + * @method void setReferences(string|null $references) * @method string|null getReferences() * @method string|null getInReplyTo() * @method string|null getThreadRootId() diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index 41bb6dc34f..9b1090f4be 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -270,6 +270,7 @@ public function insertBulk(Account $account, Message ...$messages): void { $qb1 = $this->db->getQueryBuilder(); $qb1->insert($this->getTableName()); $qb1->setValue('uid', $qb1->createParameter('uid')); + $qb1->setValue('remote_id', $qb1->createParameter('remote_id')); $qb1->setValue('message_id', $qb1->createParameter('message_id')); $qb1->setValue('references', $qb1->createParameter('references')); $qb1->setValue('in_reply_to', $qb1->createParameter('in_reply_to')); @@ -297,6 +298,7 @@ public function insertBulk(Account $account, Message ...$messages): void { foreach ($messages as $message) { $qb1->setParameter('uid', $message->getUid(), IQueryBuilder::PARAM_INT); + $qb1->setParameter('remote_id', $message->getRemoteId(), $message->getRemoteId() === null ? IQueryBuilder::PARAM_NULL : IQueryBuilder::PARAM_STR); $qb1->setParameter('message_id', $message->getMessageId(), IQueryBuilder::PARAM_STR); $inReplyTo = self::filterMessageIdLength($message->getInReplyTo()); $qb1->setParameter('in_reply_to', $inReplyTo, $inReplyTo === null ? IQueryBuilder::PARAM_NULL : IQueryBuilder::PARAM_STR); @@ -751,6 +753,74 @@ public function deleteByUid(Mailbox $mailbox, int ...$uids): void { } } + /** + * @param Mailbox $mailbox + * @param string[] $rids + * + * @return Message[] + */ + public function findByRemoteIds(Mailbox $mailbox, array $rids): array { + if ($rids === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + + $select = $qb + ->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('mailbox_id', $qb->createNamedParameter($mailbox->getId()), IQueryBuilder::PARAM_INT), + $qb->expr()->in('remote_id', $qb->createNamedParameter($rids, IQueryBuilder::PARAM_STR_ARRAY)) + ) + ->orderBy('sent_at', 'desc'); + + return $this->findRecipients($this->findEntities($select)); + } + + /** + * @param Mailbox $mailbox + * @param string[] $rids + */ + public function deleteByRemoteIds(Mailbox $mailbox, string ...$rids): void { + $selectMessageIdsQuery = $this->db->getQueryBuilder(); + $deleteRecipientsQuery = $this->db->getQueryBuilder(); + $deleteMessagesQuery = $this->db->getQueryBuilder(); + + $selectMessageIdsQuery->select('id') + ->from($this->getTableName()) + ->where( + $selectMessageIdsQuery->expr()->eq('mailbox_id', $selectMessageIdsQuery->createNamedParameter($mailbox->getId())), + $selectMessageIdsQuery->expr()->in('remote_id', $deleteMessagesQuery->createParameter('remote_ids')), + ); + $deleteRecipientsQuery->delete('mail_recipients') + ->where( + $deleteRecipientsQuery->expr()->in('message_id', $deleteRecipientsQuery->createParameter('ids')), + ); + $deleteMessagesQuery->delete('mail_messages') + ->where( + $deleteMessagesQuery->expr()->in('id', $deleteMessagesQuery->createParameter('ids')), + ); + + foreach (array_chunk($rids, 1000) as $chunk) { + $this->atomic(function () use ($selectMessageIdsQuery, $deleteRecipientsQuery, $deleteMessagesQuery, $chunk) { + $selectMessageIdsQuery->setParameter('remote_ids', $chunk, IQueryBuilder::PARAM_STR_ARRAY); + $selectResult = $selectMessageIdsQuery->executeQuery(); + $ids = array_map('intval', $selectResult->fetchAll(\PDO::FETCH_COLUMN)); + $selectResult->closeCursor(); + if (empty($ids)) { + return; + } + + $deleteRecipientsQuery->setParameter('ids', $ids, IQueryBuilder::PARAM_INT_ARRAY); + $deleteRecipientsQuery->executeStatement(); + + $deleteMessagesQuery->setParameter('ids', $ids, IQueryBuilder::PARAM_INT_ARRAY); + $deleteMessagesQuery->executeStatement(); + }, $this->db); + } + } + /** * @param Account $account * @param string $threadRootId diff --git a/lib/IMAP/ImapMailboxConnector.php b/lib/IMAP/ImapMailboxConnector.php new file mode 100644 index 0000000000..7be26dffc6 --- /dev/null +++ b/lib/IMAP/ImapMailboxConnector.php @@ -0,0 +1,121 @@ +mailboxSync->sync($account, $this->logger, $force); + } + + #[\Override] + public function syncOne(Account $account, Mailbox $mailbox): void { + $client = $this->protocolFactory->imapClient($account); + try { + $this->mailboxSync->syncStats($client, $mailbox); + } finally { + $client->logout(); + } + } + + #[\Override] + public function create(Account $account, string $name, array $specialUse = []): Mailbox { + $client = $this->protocolFactory->imapClient($account); + try { + $folder = $this->folderMapper->createFolder($client, $name, $specialUse); + $this->folderMapper->fetchFolderAcls([$folder], $client); + $this->folderMapper->detectFolderSpecialUse([$folder]); + $this->mailboxSync->sync($account, $this->logger, true, $client); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException( + 'Could not get mailbox status: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } finally { + $client->logout(); + } + + return $this->mailboxMapper->find($account, $name); + } + + #[\Override] + public function rename(Account $account, Mailbox $mailbox, string $newName): Mailbox { + $client = $this->protocolFactory->imapClient($account); + try { + $this->folderMapper->renameFolder($client, $mailbox->getName(), $newName); + $this->mailboxSync->sync($account, $this->logger, true, $client); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException( + 'Could not rename mailbox ' . $mailbox->getId() . ' on IMAP: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } finally { + $client->logout(); + } + + try { + return $this->mailboxMapper->find($account, $newName); + } catch (DoesNotExistException $e) { + throw new ServiceException("The renamed mailbox $newName does not exist", 0, $e); + } + } + + #[\Override] + public function delete(Account $account, Mailbox $mailbox): void { + $client = $this->protocolFactory->imapClient($account); + try { + $this->folderMapper->delete($client, $mailbox->getName()); + } finally { + $client->logout(); + } + + $this->mailboxMapper->delete($mailbox); + } + + #[\Override] + public function subscribe(Account $account, Mailbox $mailbox, bool $subscribed): Mailbox { + $client = $this->protocolFactory->imapClient($account); + try { + $client->subscribeMailbox($mailbox->getName(), $subscribed); + $this->mailboxSync->sync($account, $this->logger, true, $client); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException( + 'Could not set subscription status for mailbox ' . $mailbox->getId() . ' on IMAP: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } finally { + $client->logout(); + } + + return $this->mailboxMapper->find($account, $mailbox->getName()); + } +} diff --git a/lib/IMAP/ImapMessageConnector.php b/lib/IMAP/ImapMessageConnector.php new file mode 100644 index 0000000000..27380a2ddb --- /dev/null +++ b/lib/IMAP/ImapMessageConnector.php @@ -0,0 +1,464 @@ +synchronizer->syncAccount($account, $this->logger, $force); + } + + #[\Override] + public function syncMailbox(Account $account, Mailbox $mailbox, LoggerInterface $logger, int $criteria, ?array $knownUids = null, bool $force = false): SyncResult { + $client = $this->protocolFactory->imapClient($account); + try { + $rebuildThreads = $this->synchronizer->sync( + $account, + $client, + $mailbox, + $logger, + $criteria, + $knownUids, + $force, + ); + } finally { + $client->logout(); + } + + return new SyncResult( + state: $mailbox->getSyncChangedToken(), + stats: [ + 'rebuildThreads' => $rebuildThreads, + ], + ); + } + + /** + * @throws ServiceException + * @throws SmimeDecryptException + */ + #[\Override] + public function fetchMessages(Account $account, Mailbox $mailbox, bool $loadBody = false, Message ...$messages): array { + $client = $this->protocolFactory->imapClient($account); + $uids = array_map(static fn ($message) => $message->getUid(), $messages); + try { + return $this->imapMessageMapper->findByIds( + $client, + $mailbox->getName(), + $uids, + $account->getUserId(), + true + ); + } catch (DoesNotExistException|Horde_Mime_Exception|Horde_Imap_Client_Exception $e) { + throw new ServiceException('Could not load messages: ' . $e->getMessage(), $e->getCode(), $e); + } finally { + $client->logout(); + } + } + + #[\Override] + public function findMessages(Account $account, Mailbox $mailbox, SearchQuery $searchQuery): array { + $client = $this->protocolFactory->imapClient($account); + try { + $fetchResult = $client->search( + $mailbox->getName(), + $this->convertMailQueryToHordeQuery($searchQuery), + ); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException('Could not get message IDs: ' . $e->getMessage(), 0, $e); + } finally { + $client->logout(); + } + + return $fetchResult['match']->ids; + } + + #[\Override] + public function fetchMessageRaw(Account $account, Mailbox $mailbox, Message $message): ?string { + $client = $this->protocolFactory->imapClient($account); + try { + return $this->imapMessageMapper->getFullText( + $client, + $mailbox->getName(), + $message->getUid(), + $account->getUserId(), + false, + ); + } finally { + $client->logout(); + } + } + + /** + * @return Attachment[] + * @throws DoesNotExistException + * @throws ServiceException + */ + #[\Override] + public function fetchAttachments(Account $account, Mailbox $mailbox, Message $message): array { + $client = $this->protocolFactory->imapClient($account); + try { + return $this->imapMessageMapper->getAttachments( + $client, + $mailbox->getName(), + $message->getUid(), + $account->getUserId(), + ); + } catch (Horde_Imap_Client_Exception_NoSupportExtension|Horde_Imap_Client_Exception|Horde_Mime_Exception $e) { + throw new ServiceException('Could not load attachments from IMAP: ' . $e->getMessage(), $e->getCode(), $e); + } finally { + $client->logout(); + } + } + + /** + * @throws DoesNotExistException + * @throws ServiceException + */ + #[\Override] + public function fetchAttachment(Account $account, Mailbox $mailbox, Message $message, string $attachmentId): Attachment { + $client = $this->protocolFactory->imapClient($account); + try { + return $this->imapMessageMapper->getAttachment( + $client, + $mailbox->getName(), + $message->getUid(), + $attachmentId, + $account->getUserId(), + ); + } catch (Horde_Imap_Client_Exception|Horde_Mime_Exception $e) { + throw new ServiceException('Could not load attachment from IMAP: ' . $e->getMessage(), $e->getCode(), $e); + } finally { + $client->logout(); + } + } + + #[\Override] + public function moveMessages(Account $account, Mailbox $targetMailbox, Mailbox $sourceMailbox, Message ...$messages): array { + if ($messages === []) { + return []; + } + $client = $this->protocolFactory->imapClient($account); + + $mutatedMessages = []; + try { + foreach ($messages as $message) { + try { + $newUid = $this->imapMessageMapper->move($client, $sourceMailbox->getName(), $message->getUid(), $targetMailbox->getName()); + if ($newUid === null) { + // The IMAP server does not support UIDPLUS and the message has no Message-ID + // header, so the new UID is unknown. It will be reconciled on the next sync. + $this->logger->debug('Moved message but could not determine its new UID', [ + 'userId' => $account->getUserId(), + 'accountId' => $account->getId(), + 'sourceMailboxId' => $sourceMailbox->getId(), + 'targetMailboxId' => $targetMailbox->getId(), + 'messageUid' => $message->getUid(), + ]); + continue; + } + $message->setUid($newUid); + $message->setMailboxId($targetMailbox->getId()); + $mutatedMessages[] = $message; + } catch (Horde_Imap_Client_Exception $e) { + $this->logger->error('Could not move message on remote IMAP server', [ + 'exception' => $e, + 'userId' => $account->getUserId(), + 'accountId' => $account->getId(), + 'sourceMailboxId' => $sourceMailbox->getId(), + 'targetMailboxId' => $targetMailbox->getId(), + 'messageUid' => $message->getUid(), + ]); + } + } + } finally { + $client->logout(); + } + + return $mutatedMessages; + } + + #[\Override] + public function deleteMessages(Account $account, Mailbox $mailbox, Message ...$messages): array { + if ($messages === []) { + return []; + } + $client = $this->protocolFactory->imapClient($account); + + $mutatedMessages = []; + try { + foreach ($messages as $message) { + try { + $this->imapMessageMapper->expunge($client, $mailbox->getName(), $message->getUid()); + $mutatedMessages[] = $message; + } catch (Horde_Imap_Client_Exception $e) { + $this->logger->error('Could not delete message on remote IMAP server', [ + 'exception' => $e, + 'userId' => $account->getUserId(), + 'accountId' => $account->getId(), + 'mailboxId' => $mailbox->getId(), + 'messageUid' => $message->getUid(), + ]); + } + } + } finally { + $client->logout(); + } + + return $mutatedMessages; + } + + #[\Override] + public function flagMessages(Account $account, Mailbox $mailbox, string $flag, bool $value, Message ...$messages): array { + if ($messages === []) { + return []; + } + $client = $this->protocolFactory->imapClient($account); + + $uids = array_map(static fn (Message $message): int => $message->getUid(), $messages); + try { + $imapFlags = $this->filterFlags($client, $flag, $mailbox->getName()); + foreach ($imapFlags as $imapFlag) { + if ($imapFlag === '') { + continue; + } + // modify remote messages + if ($value) { + $this->imapMessageMapper->addFlag($client, $mailbox, $uids, $imapFlag); + } else { + $this->imapMessageMapper->removeFlag($client, $mailbox, $uids, $imapFlag); + } + // update local messages + foreach ($messages as $message) { + $message->setFlag($flag, $value); + } + } + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException('Could not set message flag on remote IMAP server: ' . $e->getMessage(), $e->getCode(), $e); + } finally { + $client->logout(); + } + + return $messages; + } + + #[\Override] + public function tagMessages(Account $account, Mailbox $mailbox, Tag $tag, bool $value, Message ...$messages): array { + if ($messages === []) { + return []; + } + $client = $this->protocolFactory->imapClient($account); + + try { + if ($this->isPermflagsEnabledWithClient($client, $mailbox->getName()) === false) { + $this->logger->error('Cannot set message keyword, server does not support permanent flags', ['tag' => $tag->getDisplayName()]); + return []; + } + + $uids = array_map(static fn (Message $message) => $message->getUid(), $messages); + try { + if ($value) { + $this->imapMessageMapper->addFlag($client, $mailbox, $uids, $tag->getImapLabel()); + } else { + $this->imapMessageMapper->removeFlag($client, $mailbox, $uids, $tag->getImapLabel()); + } + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException('Could not set message keyword on remote IMAP server: ' . $e->getMessage(), $e->getCode(), $e); + } + } finally { + $client->logout(); + } + + foreach ($messages as $message) { + $this->applyTagValue($message, $tag, $value); + } + + return $messages; + } + + #[\Override] + public function getQuota(Account $account): ?Quota { + $client = $this->protocolFactory->imapClient($account); + try { + $quotas = array_map( + static fn (Folder $mailbox) => $client->getQuotaRoot($mailbox->getMailbox()), + $this->imapMailboxMapper->getFolders($account, $client), + ); + } catch (Horde_Imap_Client_Exception_NoSupportExtension) { + return null; + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException('Could not get quota from IMAP: ' . $e->getMessage(), $e->getCode(), $e); + } finally { + $client->logout(); + } + + $storageQuotas = array_map(static fn (array $root) => $root['storage'] ?? [ + 'usage' => 0, + 'limit' => 0, + ], array_merge(...array_values($quotas))); + + if ($storageQuotas === []) { + return null; + } + + $storage = array_merge(...array_values($storageQuotas)); + + return new Quota( + 1024 * (int)($storage['usage'] ?? 0), + 1024 * (int)($storage['limit'] ?? 0), + ); + } + + #[\Override] + public function clearCache(Account $account, Mailbox $mailbox): void { + $this->synchronizer->clearCache($account, $mailbox); + } + + #[\Override] + public function repairSync(Account $account, Mailbox $mailbox): void { + $this->synchronizer->repairSync($account, $mailbox, $this->logger); + } + + #[\Override] + public function isPermflagsEnabled(Account $account, Mailbox $mailbox): bool { + $client = $this->protocolFactory->imapClient($account); + try { + return $this->isPermflagsEnabledWithClient($client, $mailbox->getName()); + } finally { + $client->logout(); + } + } + + private function isPermflagsEnabledWithClient($client, string $mailbox): bool { + try { + $capabilities = $client->status($mailbox, Horde_Imap_Client::STATUS_PERMFLAGS); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException( + 'Could not get message flag options from IMAP: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } + + return is_array($capabilities) + && in_array('\\*', $capabilities['permflags'] ?? [], true); + } + + private function convertMailQueryToHordeQuery(SearchQuery $searchQuery): Horde_Imap_Client_Search_Query { + $query = new Horde_Imap_Client_Search_Query(); + foreach ($searchQuery->getBodies() as $textToken) { + $query->text($textToken, true); + } + + return $query; + } + + /** + * @return string[] + */ + private function filterFlags($client, string $flag, string $mailbox): array { + $systemFlags = [ + 'seen' => [Horde_Imap_Client::FLAG_SEEN], + 'answered' => [Horde_Imap_Client::FLAG_ANSWERED], + 'flagged' => [Horde_Imap_Client::FLAG_FLAGGED], + 'deleted' => [Horde_Imap_Client::FLAG_DELETED], + 'draft' => [Horde_Imap_Client::FLAG_DRAFT], + 'recent' => [Horde_Imap_Client::FLAG_RECENT], + ]; + + if (isset($systemFlags[$flag])) { + return $systemFlags[$flag]; + } + + try { + $capabilities = $client->status($mailbox, Horde_Imap_Client::STATUS_PERMFLAGS); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException( + 'Could not get message flag options from IMAP: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } + + if (!isset($capabilities['permflags'])) { + return []; + } + + if (in_array('\\*', $capabilities['permflags'], true) || in_array($flag, $capabilities['permflags'], true)) { + return [$flag]; + } + + return []; + } + + private function applyTagValue(Message $message, Tag $tag, bool $value): void { + $tags = $message->getTags(); + + if ($value) { + foreach ($tags as $existingTag) { + if ($existingTag->getImapLabel() === $tag->getImapLabel()) { + return; + } + } + + $new = new Tag(); + $new->setImapLabel($tag->getImapLabel()); + $new->setDisplayName($tag->getDisplayName()); + $new->setColor($tag->getColor()); + $new->setIsDefaultTag($tag->getIsDefaultTag() ?? false); + $tags[] = $new; + } else { + $tags = array_values(array_filter( + $tags, + static fn (Tag $existingTag): bool => $existingTag->getImapLabel() !== $tag->getImapLabel(), + )); + } + + $message->setTags($tags); + } +} diff --git a/lib/IMAP/MailboxSync.php b/lib/IMAP/MailboxSync.php index c47d04cfcf..90ed10398d 100644 --- a/lib/IMAP/MailboxSync.php +++ b/lib/IMAP/MailboxSync.php @@ -21,6 +21,7 @@ use OCA\Mail\Events\MailboxesSynchronizedEvent; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Folder; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\AppFramework\Db\TTransactional; use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\IEventDispatcher; @@ -55,7 +56,7 @@ public function __construct( private MailboxMapper $mailboxMapper, private FolderMapper $folderMapper, private MailAccountMapper $mailAccountMapper, - private IMAPClientFactory $imapClientFactory, + private readonly ProtocolFactory $protocolFactory, ITimeFactory $timeFactory, IEventDispatcher $dispatcher, IDBConnection $dbConnection, @@ -72,13 +73,13 @@ public function sync(Account $account, LoggerInterface $logger, bool $force = false, ?Horde_Imap_Client_Socket $client = null): void { - if (!$force && $account->getMailAccount()->getLastMailboxSync() >= ($this->timeFactory->getTime() - 7200)) { + if (!$force && $account->getMailAccount()->getLastMailboxSync() >= ($this->timeFactory->getTime() - 900)) { $logger->debug('account is up to date, skipping mailbox sync'); return; } if ($client === null) { - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); $ownClient = true; } else { $ownClient = false; diff --git a/lib/IMAP/PreviewEnhancer.php b/lib/IMAP/PreviewEnhancer.php index 3f67545379..929a85a946 100644 --- a/lib/IMAP/PreviewEnhancer.php +++ b/lib/IMAP/PreviewEnhancer.php @@ -11,10 +11,12 @@ use Horde_Imap_Client_Exception; use OCA\Mail\Account; +use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; use OCA\Mail\Db\MessageMapper as DbMapper; use OCA\Mail\IMAP\MessageMapper as ImapMapper; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\Avatar\Avatar; use OCA\Mail\Service\AvatarService; @@ -26,7 +28,7 @@ class PreviewEnhancer { public function __construct( - private IMAPClientFactory $clientFactory, + private readonly ProtocolFactory $protocolFactory, private ImapMapper $imapMapper, private DbMapper $mapper, private LoggerInterface $logger, @@ -49,10 +51,9 @@ public function process(Account $account, Mailbox $mailbox, array $messages, boo return array_merge($carry, [$message->getUid()]); }, []); - $client = $this->clientFactory->getClient($account); foreach ($messages as $message) { - $attachments = $this->attachmentService->getAttachmentNames($account, $mailbox, $message, $client); + $attachments = $this->attachmentService->getAttachmentNames($account, $mailbox, $message); $message->setAttachments($attachments); } @@ -77,6 +78,19 @@ public function process(Account $account, Mailbox $mailbox, array $messages, boo return $messages; } + if ($account->getMailAccount()->getProtocol() === MailAccount::PROTOCOL_JMAP) { + foreach ($messages as $message) { + if ($message->getStructureAnalyzed()) { + continue; + } + + $message->setStructureAnalyzed(true); + } + + return $this->mapper->updatePreviewDataBulk(...$messages); + } + + $client = $this->protocolFactory->imapClient($account); try { $data = $this->imapMapper->getBodyStructureData( $client, diff --git a/lib/IMAP/Search/Provider.php b/lib/IMAP/Search/Provider.php deleted file mode 100644 index aecfa50d2e..0000000000 --- a/lib/IMAP/Search/Provider.php +++ /dev/null @@ -1,66 +0,0 @@ -clientFactory->getClient($account); - try { - $fetchResult = $client->search( - $mailbox->getName(), - $this->convertMailQueryToHordeQuery($searchQuery) - ); - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException('Could not get message IDs: ' . $e->getMessage(), 0, $e); - } finally { - $client->logout(); - } - - return $fetchResult['match']->ids; - } - - /** - * @param SearchQuery $searchQuery - * - * @todo possible optimization: filter flags here as well as it might speed up IMAP search - * - * @return Horde_Imap_Client_Search_Query - */ - private function convertMailQueryToHordeQuery(SearchQuery $searchQuery): Horde_Imap_Client_Search_Query { - return array_reduce( - $searchQuery->getBodies(), - static function (Horde_Imap_Client_Search_Query $query, string $textToken) { - $query->text($textToken, true); - return $query; - }, - new Horde_Imap_Client_Search_Query() - ); - } -} diff --git a/lib/JMAP/Exception/JmapUnknownMethod.php b/lib/JMAP/Exception/JmapUnknownMethod.php new file mode 100644 index 0000000000..a784f9f025 --- /dev/null +++ b/lib/JMAP/Exception/JmapUnknownMethod.php @@ -0,0 +1,18 @@ +configureTransportMode($secure ? 'https' : 'http'); - $client->setHost($host . ':' . (string)$port); + $client->configureTransportMode($secure ? JmapClient::TRANSPORT_MODE_SECURE : JmapClient::TRANSPORT_MODE_STANDARD); + $client->setHost($host . ':' . $port); if ($path !== '/.well-known/jmap') { $client->setDiscoveryPath($path); } diff --git a/lib/JMAP/JmapMailboxAdapter.php b/lib/JMAP/JmapMailboxAdapter.php new file mode 100644 index 0000000000..3883188f33 --- /dev/null +++ b/lib/JMAP/JmapMailboxAdapter.php @@ -0,0 +1,169 @@ +setName($response->label() ?? $response->id() ?? ''); + $mailbox->setNameHash(md5($response->id() ?? '')); + $mailbox->setRemoteParentId($response->in()); + $mailbox->setRemoteId($response->id()); + $mailbox->setState(null); + $mailbox->setAttributes(json_encode($this->convertToAttributes($response), JSON_THROW_ON_ERROR)); + $mailbox->setDelimiter(self::DELIMITER); + $mailbox->setMessages($response->objectsTotal() ?? 0); + $mailbox->setUnseen($response->objectsUnseen() ?? 0); + $mailbox->setSelectable($response->rights()?->readItems() === true); + $mailbox->setSpecialUse(json_encode($this->convertToSpecialUse($response), JSON_THROW_ON_ERROR)); + $mailbox->setMyAcls($this->convertToAcl($response)); + $mailbox->setShared(false); + + return $mailbox; + } + + /** + * @throws JsonException + */ + public function convertFromMailbox(Mailbox $mailbox, array $patch = []): MailboxParametersRequest { + $properties = ['location', 'name', 'subscribed', 'role']; + if (!empty($patch)) { + $properties = array_intersect($properties, $patch); + } + + $request = new MailboxParametersRequest(); + + if (in_array('location', $properties, true)) { + $request->in($mailbox->getRemoteParentId()); + } + if (in_array('name', $properties, true)) { + $request->label($mailbox->getName()); + } + if (in_array('subscribed', $properties, true)) { + $request->subscribed(str_contains($mailbox->getAttributes() ?? '', '\\subscribed')); + } + if (in_array('role', $properties, true)) { + $specialUse = json_decode($mailbox->getSpecialUse() ?? '[]', true) ?? []; + $role = $this->convertFromSpecialUse($specialUse); + $request->role($role); + } + + return $request; + } + + /** + * @return string[] + */ + private function convertToAttributes(MailboxParametersResponse $response): array { + $attributes = []; + + if ($response->subscribed() !== false) { + $attributes[] = '\\subscribed'; + } + + $role = $response->role(); + if ($role !== null && $role !== '') { + $attributes[] = '\\' . $this->normalizeSpecialUse($role); + } + + if ($response->rights()?->readItems() !== true) { + $attributes[] = '\\noselect'; + } + + return $attributes; + } + + /** + * @return string[] + */ + private function convertToSpecialUse(MailboxParametersResponse $response): array { + $role = $response->role(); + if ($role === null || $role === '') { + return []; + } + + return [$this->normalizeSpecialUse($role)]; + } + + /** + * @param string[] $specialUse + */ + private function convertFromSpecialUse(array $specialUse): ?string { + $role = $specialUse[0] ?? null; + if ($role === null) { + return null; + } + + $role = strtolower(trim($role, '\\')); + if ($role === 'flagged') { + return 'important'; + } + + $allowed = ['all', 'archive', 'drafts', 'important', 'inbox', 'junk', 'sent', 'trash']; + + return in_array($role, $allowed, true) ? $role : null; + } + + private function normalizeSpecialUse(string $role): string { + $role = strtolower($role); + + return $role === 'important' ? 'flagged' : $role; + } + + private function convertToAcl(MailboxParametersResponse $response): ?string { + $rights = $response->rights(); + if ($rights === null) { + return null; + } + + /** @var string $acls */ + $acls = ''; + if ($rights->readItems()) { + $acls .= 'lr'; + } + if ($rights->addItems()) { + $acls .= 'i'; + } + if ($rights->removeItems()) { + $acls .= 'te'; + } + if ($rights->setSeen()) { + $acls .= 's'; + } + if ($rights->setKeywords()) { + $acls .= 'w'; + } + if ($rights->createChild()) { + $acls .= 'k'; + } + if ($rights->rename() || $rights->delete()) { + $acls .= 'x'; + } + if ($rights->submit()) { + $acls .= 'p'; + } + if ($rights->createChild() && $rights->rename() && $rights->delete()) { + $acls .= 'a'; + } + + return $acls === '' ? null : $acls; + } + +} diff --git a/lib/JMAP/JmapMailboxConnector.php b/lib/JMAP/JmapMailboxConnector.php new file mode 100644 index 0000000000..ef6d4e5b12 --- /dev/null +++ b/lib/JMAP/JmapMailboxConnector.php @@ -0,0 +1,318 @@ +getMailAccount()->getLastMailboxSync() >= ($this->timeFactory->getTime() - self::MAILBOX_SYNC_TTL)) { + $this->logger->debug('account is up to date, skipping mailbox sync'); + return; + } + + $this->jmapOperationsService->connect($account); + $remoteMailboxes = $this->jmapOperationsService->collectionList(); + $localMailboxes = $this->mailboxMapper->findAll($account); + $remoteMailboxNames = $this->constructMailboxSyncNameLookup($remoteMailboxes); + + // create or update mailboxes locally that are present remotely + foreach ($remoteMailboxes as $remoteMailbox) { + $remoteId = $remoteMailbox->getRemoteId(); + $remoteMailboxName = ($remoteId !== null ? $remoteMailboxNames[$remoteId] ?? null : null) ?? $remoteMailbox->getName(); + $remoteMailbox->setName($remoteMailboxName); + $remoteMailbox->setNameHash(md5($remoteMailboxName)); + + $localMailboxIdx = null; + $localMailboxData = null; + foreach ($localMailboxes as $key => $candidate) { + if ($candidate->getRemoteId() === $remoteMailbox->getRemoteId()) { + $localMailboxIdx = $key; + $localMailboxData = $candidate; + break; + } + } + + if ($localMailboxData === null) { + $remoteMailbox->setAccountId($account->getId()); + $this->mailboxMapper->insert($remoteMailbox); + } elseif ($localMailboxIdx !== null) { + $localMailbox = $this->mergeMailbox($localMailboxData, $remoteMailbox); + $this->mailboxMapper->update($localMailbox); + unset($localMailboxes[$localMailboxIdx]); + } + } + // delete local mailboxes that are not present remotely + if (count($localMailboxes) > 0) { + foreach ($localMailboxes as $mailbox) { + $this->mailboxMapper->delete($mailbox); + } + } + + $this->dispatcher->dispatchTyped(new MailboxesSynchronizedEvent($account)); + } + + #[\Override] + public function syncOne(Account $account, Mailbox $mailbox): void { + $remoteId = $mailbox->getRemoteId(); + if ($remoteId === null) { + throw new ServiceException('JMAP mailbox is missing a remote id'); + } + + $this->jmapOperationsService->connect($account); + + $remoteMailbox = $this->jmapOperationsService->collectionFetch($remoteId); + if ($remoteMailbox === null) { + throw new ServiceException('JMAP mailbox could not be fetched from the remote'); + } + $this->mailboxMapper->update($this->mergeMailbox($mailbox, $remoteMailbox, ['name', 'nameHash'])); + } + + #[\Override] + public function create(Account $account, string $name, array $specialUse = []): Mailbox { + $this->jmapOperationsService->connect($account); + + // extract the mailbox name and parent name from the full path for remote operation + $pathParts = explode(self::DELIMITER, $name); + if (count($pathParts) === 1) { + $mailboxName = $name; + $parentName = null; + } else { + $mailboxName = array_pop($pathParts); + $parentName = implode(self::DELIMITER, $pathParts); + } + // find the parent mailbox to retrieve remote mailbox id for remote operation + if ($parentName !== null) { + try { + $location = $this->mailboxMapper->find($account, $parentName); + } catch (DoesNotExistException $e) { + throw new ServiceException('JMAP parent mailbox does not exist: ' . $parentName); + } + + if ($location->getRemoteId() === null) { + throw new ServiceException('JMAP parent mailbox is missing a remote id: ' . $parentName); + } + } else { + $location = null; + } + // construct the mailbox for the remote and local creation + $mailbox = new Mailbox(); + $mailbox->setAccountId($account->getId()); + $mailbox->setDelimiter(self::DELIMITER); + $mailbox->setMessages(0); + $mailbox->setUnseen(0); + $mailbox->setSelectable(true); + $mailbox->setAttributes(json_encode(['\\subscribed'], JSON_THROW_ON_ERROR)); + $mailbox->setSpecialUse(json_encode($specialUse, JSON_THROW_ON_ERROR)); + // create in remote store, using only the mailbox + $mailbox->setName($mailboxName); + $mailbox = $this->jmapOperationsService->collectionCreate($location, $mailbox); + if ($mailbox === null) { + throw new ServiceException('JMAP mailbox creation failed'); + } + // create in local store, using the full path name + $mailbox->setName($name); + $mailbox->setNameHash(md5($name)); + $mailbox = $this->mailboxMapper->insert($mailbox); + + return $mailbox; + } + + #[\Override] + public function rename(Account $account, Mailbox $mailbox, string $newName): Mailbox { + $remoteId = $mailbox->getRemoteId(); + if ($remoteId === null) { + throw new ServiceException('JMAP mailbox is missing a remote id'); + } + + $this->jmapOperationsService->connect($account); + + // extract the mailbox name from the full path for remote operation + $pathParts = explode(self::DELIMITER, $newName); + if (count($pathParts) === 1) { + $mailboxName = $newName; + } else { + $mailboxName = array_pop($pathParts); + } + // update remote store, using only the mailbox name + $mailbox->setName($mailboxName); + $mailbox = $this->jmapOperationsService->collectionModify($remoteId, $mailbox, ['name']); + if ($mailbox === null) { + throw new ServiceException('JMAP mailbox rename failed'); + } + // update local store, with the full path name + try { + $mailbox->setName($newName); + $mailbox->setNameHash(md5($newName)); + return $this->mailboxMapper->update($mailbox); + } catch (DoesNotExistException $e) { + throw new ServiceException("The renamed mailbox $newName does not exist", 0, $e); + } + } + + #[\Override] + public function delete(Account $account, Mailbox $mailbox): void { + $remoteId = $mailbox->getRemoteId(); + if ($remoteId === null) { + throw new ServiceException('JMAP mailbox is missing a remote id'); + } + + $this->jmapOperationsService->connect($account); + + // delete from remote store + $result = $this->jmapOperationsService->collectionDestroy($remoteId); + if ($result === null) { + throw new ServiceException('JMAP mailbox deletion failed'); + } + // delete from local store + $this->mailboxMapper->delete($mailbox); + } + + #[\Override] + public function subscribe(Account $account, Mailbox $mailbox, bool $subscribed): Mailbox { + $remoteId = $mailbox->getRemoteId(); + if ($remoteId === null) { + throw new ServiceException('JMAP mailbox is missing a remote id'); + } + + $this->jmapOperationsService->connect($account); + + // update subscription attribute + $attributes = json_decode($mailbox->getAttributes() ?? '[]', true); + if (!is_array($attributes)) { + $attributes = []; + } + if ($subscribed) { + $attributes[] = '\\subscribed'; + } else { + $attributes = array_filter($attributes, static function ($attribute) { + return $attribute !== '\\subscribed'; + }); + } + $mailbox->setAttributes(json_encode(array_values(array_unique($attributes)))); + // update remote store + $mailbox = $this->jmapOperationsService->collectionModify($remoteId, $mailbox, ['subscribed']); + if ($mailbox === null) { + throw new ServiceException('JMAP mailbox subscription update failed'); + } + // update local store + try { + return $this->mailboxMapper->update($mailbox); + } catch (DoesNotExistException $e) { + throw new ServiceException('The updated mailbox does not exist', 0, $e); + } + } + + /** + * @param Mailbox[] $remoteMailboxes + * @return array + */ + private function constructMailboxSyncNameLookup(array $remoteMailboxes): array { + $mailboxesByRid = []; + foreach ($remoteMailboxes as $remoteMailbox) { + $rid = $remoteMailbox->getRemoteId(); + if ($rid === null) { + continue; + } + + $mailboxesByRid[$rid] = $remoteMailbox; + } + + $lookup = []; + $visiting = []; + $resolveMailboxPath = function (string $rid) use (&$resolveMailboxPath, $mailboxesByRid, &$lookup, &$visiting): string { + if (isset($lookup[$rid])) { + return $lookup[$rid]; + } + + $mailbox = $mailboxesByRid[$rid]; + if (isset($visiting[$rid])) { + $this->logger->warning('Detected cyclic JMAP mailbox parent relationship', [ + 'rid' => $rid, + ]); + + return $mailbox->getName(); + } + + $visiting[$rid] = true; + $path = $mailbox->getName(); + $parentRid = $mailbox->getRemoteParentId(); + + if ($parentRid !== null) { + if (isset($mailboxesByRid[$parentRid])) { + $path = $resolveMailboxPath($parentRid) . self::DELIMITER . $path; + } else { + $this->logger->warning('JMAP mailbox parent missing from sync payload', [ + 'rid' => $rid, + 'parentRid' => $parentRid, + ]); + } + } + + unset($visiting[$rid]); + $lookup[$rid] = $path; + + return $path; + }; + + foreach (array_keys($mailboxesByRid) as $rid) { + $resolveMailboxPath($rid); + } + + return $lookup; + } + + private function mergeMailbox(Mailbox $target, Mailbox $source, array $omit = []): Mailbox { + if (!in_array('name', $omit, true)) { + $target->setName($source->getName()); + } + if (!in_array('nameHash', $omit, true)) { + $target->setNameHash($source->getNameHash()); + } + $target->setRemoteId($source->getRemoteId()); + $target->setAttributes($source->getAttributes()); + $target->setDelimiter($source->getDelimiter()); + $target->setMessages($source->getMessages()); + $target->setUnseen($source->getUnseen()); + $target->setSelectable($source->getSelectable() === true); + $target->setSpecialUse($source->getSpecialUse()); + $target->setMyAcls($source->getMyAcls()); + $target->setShared($source->isShared() === true); + + return $target; + } + +} diff --git a/lib/JMAP/JmapMessageAdapter.php b/lib/JMAP/JmapMessageAdapter.php new file mode 100644 index 0000000000..55f225c429 --- /dev/null +++ b/lib/JMAP/JmapMessageAdapter.php @@ -0,0 +1,379 @@ +keywords(); + $updatedAt = $source->parameter('updatedAt'); + + $message = new Message(); + $message->setRemoteId($source->id()); + $message->setMessageId($this->firstString($source->messageId())); + $message->setInReplyTo($this->firstString($source->inReplyTo())); + $message->setReferences($this->normalizeReferenceValue($source->references())); + $message->setThreadRootId($source->thread()); + $message->setSubject($source->subject() ?? ''); + $message->setSentAt($this->parseTimestamp($source->sent() ?? $source->received() ?? null)); + $message->setFlagAnswered($source->answered() ?? false); + $message->setFlagDeleted($source->keyword('$deleted') ?? false); + $message->setFlagDraft($source->draft() ?? false); + $message->setFlagFlagged($source->flagged() ?? false); + $message->setFlagSeen($source->seen() ?? false); + $message->setFlagForwarded($source->forwarded() ?? false); + $message->setFlagJunk($source->junk() ?? false); + $message->setFlagNotjunk($source->notjunk() ?? false); + $message->setFlagImportant(($source->keyword('$label1') ?? false) || ($source->keyword(Tag::LABEL_IMPORTANT) ?? false)); + $message->setFlagMdnsent($source->keyword('$mdnsent') ?? false); + $message->setPreviewText($source->bodyTextPreview()); + $message->setFlagAttachments($source->hasAttachment() ?? false); + $message->setStructureAnalyzed(true); + $message->setUpdatedAt($this->parseTimestamp($updatedAt ?? $source->sent() ?? null)); + + $message->setFrom($this->convertAddressList($source->from() ?? $source->sender())); + $message->setTo($this->convertAddressList($source->to() ?? [])); + $message->setCc($this->convertAddressList($source->cc() ?? [])); + $message->setBcc($this->convertAddressList($source->bcc() ?? [])); + $message->setTags($this->convertTags($keywords)); + + return $message; + } + + public function convertToModelMessage(MailParametersResponse $source, int $uid, bool $loadBody): IMAPMessage { + // extract body, attachments and other related properties from the structure + [ + 'plainBody' => $plainBody, + 'htmlBody' => $htmlBody, + 'attachments' => $attachments, + 'inlineAttachments' => $inlineAttachments, + 'isEncrypted' => $isEncrypted, + 'isSigned' => $isSigned, + 'isPgpMimeEncrypted' => $isPgpMimeEncrypted, + 'scheduling' => $scheduling, + ] = $this->extractStructureData($source, $uid, $loadBody); + /** @var list $inlineAttachments */ + $dispositionNotificationTo = $this->firstHeaderValue($source, 'Disposition-Notification-To') ?? ''; + $hasDkimSignature = $this->firstHeaderValue($source, 'DKIM-Signature') !== null; + [$unsubscribeUrl, $unsubscribeMailto] = $this->extractUnsubscribeTargets($source); + $isOneClickUnsubscribe = $unsubscribeUrl !== null + && str_contains(strtolower($this->firstHeaderValue($source, 'List-Unsubscribe-Post') ?? ''), 'one-click'); + $flags = array_keys($source->keywords()); + + return new IMAPMessage( + $uid, + $this->firstString($source->messageId()) ?? '', + $flags, + $this->convertAddressList($source->from() ?? $source->sender()), + $this->convertAddressList($source->to() ?? []), + $this->convertAddressList($source->cc() ?? []), + $this->convertAddressList($source->bcc() ?? []), + $this->convertAddressList($source->replyTo() ?? []), + $source->subject() ?? '', + $plainBody, + $htmlBody, + $htmlBody !== '', + $attachments, + $inlineAttachments, + $attachments !== [] || $inlineAttachments !== [], + $scheduling, + new Horde_Imap_Client_DateTime('@' . $this->parseTimestamp($source->received() ?? $source->sent() ?? null)), + $this->normalizeRawMessageIdList($source->references()), + $dispositionNotificationTo, + $hasDkimSignature, + [], + $unsubscribeUrl, + $isOneClickUnsubscribe, + $unsubscribeMailto, + $this->firstString($source->inReplyTo()) ?? '', + $isEncrypted, + $isSigned, + false, + $this->htmlService, + $isPgpMimeEncrypted, + ); + } + + /** + * @param array> $messages + */ + public function countUnreadMessages(array $messages): int { + $count = 0; + foreach ($messages as $message) { + if (($message['keywords']['$seen'] ?? false) !== true) { + $count++; + } + } + + return $count; + } + + private function convertAddressList(array $entries): AddressList { + $addresses = []; + foreach ($entries as $entry) { + $email = is_array($entry) ? ($entry['email'] ?? null) : null; + if (!is_string($email) || $email === '') { + continue; + } + $addresses[] = Address::fromRaw((string)($entry['name'] ?? $email), $email); + } + + return new AddressList($addresses); + } + + /** + * @param array $keywords + * @return Tag[] + */ + private function convertTags(array $keywords): array { + $tags = []; + foreach ($keywords as $keyword => $value) { + if (!is_string($keyword) || $keyword === '' || $value !== true || $this->isReservedKeyword($keyword)) { + continue; + } + + $tag = new Tag(); + $tag->setImapLabel($keyword); + $tag->setDisplayName($keyword); + $tag->setColor(''); + $tag->setIsDefaultTag(false); + $tags[] = $tag; + } + + return $tags; + } + + private function isReservedKeyword(string $keyword): bool { + return in_array($keyword, self::RESERVED_KEYWORDS, true); + } + + private function parseTimestamp(mixed $value): int { + if (is_string($value) && $value !== '') { + $timestamp = strtotime($value); + if ($timestamp !== false) { + return $timestamp; + } + } + + return time(); + } + + private function firstString(mixed $value): ?string { + if (is_string($value) && $value !== '') { + return $value; + } + if (is_array($value)) { + foreach ($value as $entry) { + if (is_string($entry) && $entry !== '') { + return $entry; + } + } + } + + return null; + } + + private function normalizeReferenceValue(mixed $references): ?string { + if ($references === null) { + return null; + } + if (is_string($references)) { + return $references; + } + if (is_array($references)) { + return json_encode(array_values(array_filter($references, static fn (mixed $value): bool => is_string($value) && $value !== ''))); + } + + return null; + } + + private function normalizeRawMessageIdList(mixed $references): string { + if (!is_array($references)) { + return ''; + } + + return implode(' ', array_filter($references, static fn (mixed $value): bool => is_string($value) && $value !== '')); + } + + private function firstHeaderValue(MailParametersResponse $source, string $name): ?string { + $value = $source->header($name, 'asText'); + return $this->firstString($value); + } + + /** + * @return array{0:?string,1:?string} + */ + private function extractUnsubscribeTargets(MailParametersResponse $source): array { + $headerValue = $this->firstHeaderValue($source, 'List-Unsubscribe'); + if ($headerValue === null) { + return [null, null]; + } + + $unsubscribeUrl = null; + $unsubscribeMailto = null; + foreach (preg_split('/\s*,\s*/', $headerValue) ?: [] as $entry) { + $target = trim($entry, " \t\n\r\0\x0B<>"); + if ($target === '') { + continue; + } + + $normalizedTarget = strtolower($target); + if ($unsubscribeMailto === null && str_starts_with($normalizedTarget, 'mailto:')) { + $unsubscribeMailto = $target; + continue; + } + if ($unsubscribeUrl === null && (str_starts_with($normalizedTarget, 'https://') || str_starts_with($normalizedTarget, 'http://'))) { + $unsubscribeUrl = $target; + } + } + + return [$unsubscribeUrl, $unsubscribeMailto]; + } + + /** + * @return array{ + * plainBody:string, + * htmlBody:string, + * attachments:array>, + * inlineAttachments:array>, + * isEncrypted:bool, + * isSigned:bool, + * isPgpMimeEncrypted:bool, + * scheduling:array> + * } + */ + private function extractStructureData(MailParametersResponse $source, int $uid, bool $loadBody): array { + $state = [ + 'plainBody' => '', + 'htmlBody' => '', + 'attachments' => [], + 'inlineAttachments' => [], + 'isEncrypted' => false, + 'isSigned' => false, + 'isPgpMimeEncrypted' => false, + 'scheduling' => [], + ]; + + $walk = function (MailPartResponse $part) use (&$walk, &$state, $source, $uid, $loadBody): void { + $partId = $part->id(); + $blobId = $part->blob(); + $type = strtolower($part->type() ?? ''); + $disposition = strtolower($part->disposition() ?? ''); + $content = is_string($partId) ? ($source->bodyPartValue($partId) ?? '') : ''; + + if ($type === 'multipart/encrypted' || $type === 'application/pkcs7-mime' || $type === 'application/x-pkcs7-mime') { + $state['isEncrypted'] = true; + } + if ($type === 'multipart/signed' || $type === 'application/pkcs7-signature' || $type === 'application/x-pkcs7-signature' || $type === 'application/pgp-signature') { + $state['isSigned'] = true; + } + if ($type === 'application/pgp-encrypted') { + $state['isEncrypted'] = true; + $state['isPgpMimeEncrypted'] = true; + } + if ($type === 'text/calendar') { + $state['scheduling'][] = [ + 'id' => $partId, + 'mime' => $type, + 'fileName' => $part->name(), + 'method' => $this->extractCalendarMethod($content), + ]; + } + + if ($loadBody && $type === 'text/plain' && $state['plainBody'] === '') { + $state['plainBody'] = $content; + } + if ($loadBody && $type === 'text/html' && $state['htmlBody'] === '') { + $state['htmlBody'] = $content; + } + + if ($loadBody && ($disposition === 'attachment' || $disposition === 'inline' || $type === 'text/calendar' || $type === 'application/ics')) { + $entry = [ + 'id' => $blobId, + 'messageId' => $uid, + 'fileName' => $part->name(), + 'mime' => $type !== '' ? $type : 'application/octet-stream', + 'size' => $part->size() ?? 0, + 'cid' => $part->cid(), + 'disposition' => $part->disposition() ?? '', + ]; + if ($disposition === 'inline') { + $state['inlineAttachments'][] = $entry; + } else { + $state['attachments'][] = $entry; + } + } + + foreach ($part->parts() ?? [] as $subPart) { + if ($subPart instanceof MailPartResponse) { + $walk($subPart); + } + } + }; + + $bodyStructure = $source->bodyPartStructure(); + if ($bodyStructure instanceof MailPartResponse) { + $walk($bodyStructure); + } + + $preview = $source->bodyTextPreview(); + if ($loadBody && $state['plainBody'] === '' && is_string($preview)) { + $state['plainBody'] = $preview; + } + + return $state; + } + + private function extractCalendarMethod(string $content): ?string { + if ($content === '') { + return null; + } + + if (preg_match('/^METHOD:([^\r\n;]+)/mi', $content, $matches) !== 1) { + return null; + } + + $method = trim($matches[1]); + + return $method !== '' ? $method : null; + } +} diff --git a/lib/JMAP/JmapMessageConnector.php b/lib/JMAP/JmapMessageConnector.php new file mode 100644 index 0000000000..d32c80b6d5 --- /dev/null +++ b/lib/JMAP/JmapMessageConnector.php @@ -0,0 +1,443 @@ +mailboxMapper->findAll($account) as $mailbox) { + $syncSent = $account->getMailAccount()->getSentMailboxId() === $mailbox->getId() || $mailbox->isSpecialUse('sent'); + if (!$mailbox->isInbox() && !$mailbox->getSyncInBackground() && !$syncSent) { + $this->logger->debug('Skipping mailbox sync for ' . $mailbox->getId()); + continue; + } + + $this->logger->debug('Syncing ' . $mailbox->getId()); + $this->syncMailbox($account, $mailbox, $this->logger, 0, null, $force); + $rebuildThreads = true; + } + + $this->eventDispatcher->dispatchTyped(new SynchronizationEvent($account, $this->logger, $rebuildThreads)); + } + + #[\Override] + public function syncMailbox(Account $account, Mailbox $mailbox, LoggerInterface $logger, int $criteria, ?array $knownUids = null, bool $force = false): SyncResult { + if ($mailbox->getRemoteId() === null || $mailbox->getSelectable() === false) { + return new SyncResult(state: $mailbox->getState()); + } + + // fetch delta from remote store + $this->jmapOperationsService->connect($account); + $delta = $this->jmapOperationsService->entityDelta($mailbox->getRemoteId(), $mailbox->getState() ?? ''); + if ($delta['state'] === $mailbox->getState()) { + return new SyncResult(state: $mailbox->getState()); + } + + $addedUids = []; + $addedMessages = []; + $modifiedUids = []; + $modifiedMessages = []; + $deletedUids = []; + + // update local store - deletions + if (isset($delta['deletions']) && $delta['deletions'] !== []) { + $deletedMessages = $this->dbMessageMapper->findByRemoteIds($mailbox, $delta['deletions']); + foreach ($deletedMessages as $key => $message) { + $deletedUids[] = $message->getUid(); + unset($deletedMessages[$key]); + } + $this->dbMessageMapper->deleteByRemoteIds($mailbox, ...$delta['deletions']); + } + + $deltaIds = array_values(array_unique(array_merge($delta['additions'] ?? [], $delta['modifications'] ?? []))); + $remoteMessages = $deltaIds === [] ? [] : $this->jmapOperationsService->entityFetchMessage(...$deltaIds); + $localMessages = $this->dbMessageMapper->findByRemoteIds($mailbox, $deltaIds); + $localMessages = $this->mapMessagesByRemoteId(...$localMessages); + + $nextUid = ($this->dbMessageMapper->findHighestUid($mailbox) ?? 0) + 1; + + foreach (array_keys($remoteMessages) as $remoteId) { + $remoteMessage = $remoteMessages[$remoteId]; + $localMessage = $localMessages[$remoteId] ?? null; + $uid = $localMessage?->getUid() ?? $nextUid++; + if ($localMessage !== null) { + $modifiedUids[] = $uid; + $modifiedMessages[] = $this->mergeMessage($localMessage, $remoteMessage); + } else { + $remoteMessage->setMailboxId($mailbox->getId()); + $remoteMessage->setUid($uid); + $addedUids[] = $uid; + $addedMessages[] = $remoteMessage; + } + unset($remoteMessages[$remoteId]); + unset($localMessages[$remoteId]); + } + + if ($addedMessages !== []) { + $this->dbMessageMapper->insertBulk($account, ...$addedMessages); + } + if ($modifiedMessages !== []) { + $this->dbMessageMapper->updateBulk($account, true, ...$modifiedMessages); + } + + $mailbox->setState($delta['state']); + $this->mailboxMapper->update($mailbox); + + return new SyncResult( + new: $addedUids, + modified: $modifiedUids, + deleted: $deletedUids, + state: $mailbox->getState(), + stats: ['rebuildThreads' => true], + ); + } + + #[\Override] + public function fetchMessages(Account $account, Mailbox $mailbox, bool $loadBody = false, Message ...$messages): array { + $messages = $this->mapMessagesByRemoteId(...$messages); + // retrieve message details from remote store + $this->jmapOperationsService->connect($account); + $remoteMessages = $this->jmapOperationsService->entityFetchNative(...array_keys($messages)); + // convert to model messages and preserve UIDs from local store + $modelMessages = []; + foreach ($remoteMessages as $remoteId => $remoteMessage) { + $modelMessages[$remoteId] = $this->jmapMessageAdapter->convertToModelMessage($remoteMessage, $messages[$remoteId]->getUid(), $loadBody); + } + return $modelMessages; + } + + #[\Override] + public function findMessages(Account $account, Mailbox $mailbox, SearchQuery $searchQuery): array { + if ($mailbox->getRemoteId() === null) { + return []; + } + + $this->jmapOperationsService->connect($account); + $results = $this->jmapOperationsService->entityList( + $mailbox->getRemoteId(), + $this->convertSearchQueryToFilters($searchQuery), + [], + null, + 'basic', + ); + $messages = $this->dbMessageMapper->findByRemoteIds($mailbox, array_keys($results['list'])); + + return array_map( + static fn (Message $message): int => $message->getUid(), + $messages, + ); + } + + #[\Override] + public function fetchMessageRaw(Account $account, Mailbox $mailbox, Message $message): ?string { + $remoteId = $message->getRemoteId(); + if ($remoteId === null) { + throw new ServiceException("Message {$message->getId()} does not have a remote id"); + } + // retrieve from remote store + $this->jmapOperationsService->connect($account); + return $this->jmapOperationsService->entityFetchRaw($remoteId); + } + + /** + * @return Attachment[] + */ + #[\Override] + public function fetchAttachments(Account $account, Mailbox $mailbox, Message $message): array { + $remoteId = $message->getRemoteId(); + if ($remoteId === null) { + throw new ServiceException("Message {$message->getId()} does not have a remote id"); + } + // retrieve from remote store + $this->jmapOperationsService->connect($account); + return $this->jmapOperationsService->attachmentFetch($remoteId); + } + + #[\Override] + public function fetchAttachment(Account $account, Mailbox $mailbox, Message $message, string $attachmentId): Attachment { + $remoteId = $message->getRemoteId(); + if ($remoteId === null) { + throw new ServiceException("Message {$message->getId()} does not have a remote id"); + } + // retrieve from remote store + $this->jmapOperationsService->connect($account); + $attachment = $this->jmapOperationsService->attachmentFetch($remoteId, $attachmentId)[0] ?? null; + + if ($attachment === null) { + throw new ServiceException("Attachment $attachmentId for message {$message->getId()} could not be retrieved from server"); + } + return $attachment; + } + + #[\Override] + public function moveMessages(Account $account, Mailbox $targetMailbox, Mailbox $sourceMailbox, Message ...$messages): array { + $targetRemoteId = $targetMailbox->getRemoteId(); + if ($targetRemoteId === null) { + throw new ServiceException("Destination mailbox {$targetMailbox->getId()} does not have a remote id"); + } + $messages = $this->mapMessagesByRemoteId(...$messages); + // update remote store + $this->jmapOperationsService->connect($account); + $results = $this->jmapOperationsService->entityMove($targetRemoteId, ...array_keys($messages)); + // compute mutated messages with new mailbox id and uid if move was successful + $mutatedMessages = []; + $nextUid = ($this->dbMessageMapper->findHighestUid($targetMailbox) ?? 0) + 1; + foreach ($results as $remoteId => $status) { + if (!isset($messages[$remoteId]) || $status !== true) { + continue; + } + $messages[$remoteId]->setMailboxId($targetMailbox->getId()); + $messages[$remoteId]->setUid($nextUid++); + $mutatedMessages[] = $messages[$remoteId]; + } + + return $mutatedMessages; + } + + #[\Override] + public function deleteMessages(Account $account, Mailbox $mailbox, Message ...$messages): array { + if ($messages === []) { + return []; + } + $messages = $this->mapMessagesByRemoteId(...$messages); + // update remote store + $this->jmapOperationsService->connect($account); + $results = $this->jmapOperationsService->entityDelete(...array_keys($messages)); + // collect the messages that were successfully deleted on the remote + $mutatedMessages = []; + foreach ($results as $remoteId => $status) { + if (!isset($messages[$remoteId]) || $status !== true) { + continue; + } + $mutatedMessages[] = $messages[$remoteId]; + } + + return $mutatedMessages; + } + + #[\Override] + public function flagMessages(Account $account, Mailbox $mailbox, string $flag, bool $value, Message ...$messages): array { + if ($messages === []) { + return []; + } + $messages = $this->mapMessagesByRemoteId(...$messages); + $flag = $this->normalizeFlagForRemote($flag); + // update remote store + $this->jmapOperationsService->connect($account); + $results = $this->jmapOperationsService->entityModifyFlags([$flag => $value], ...array_keys($messages)); + + $mutatedMessages = []; + foreach ($messages as $remoteId => $message) { + if (($results[$remoteId] ?? false) !== true) { + throw new ServiceException("Message {$message->getUid()} could not be flagged on remote"); + } + $this->applyFlagValue($message, $flag, $value); + $mutatedMessages[] = $message; + } + + return $mutatedMessages; + } + + #[\Override] + public function tagMessages(Account $account, Mailbox $mailbox, Tag $tag, bool $value, Message ...$messages): array { + return $this->flagMessages($account, $mailbox, $tag->getImapLabel(), $value, ...$messages); + } + + #[\Override] + public function getQuota(Account $account): ?Quota { + return null; + } + + #[\Override] + public function clearCache(Account $account, Mailbox $mailbox): void { + $this->dbMessageMapper->deleteAll($mailbox); + $mailbox->setState(null); + $this->mailboxMapper->update($mailbox); + } + + #[\Override] + public function repairSync(Account $account, Mailbox $mailbox): void { + $this->clearCache($account, $mailbox); + $this->logger->debug('Repairing JMAP mailbox cache for ' . $mailbox->getId()); + $this->syncMailbox($account, $mailbox, $this->logger, 0, null, true); + } + + #[\Override] + public function isPermflagsEnabled(Account $account, Mailbox $mailbox): bool { + return true; + } + + /** + * @return list + */ + private function convertSearchQueryToFilters(SearchQuery $searchQuery): array { + $filters = []; + foreach ($searchQuery->getBodies() as $textToken) { + $filters[] = [ + 'attribute' => 'body', + 'value' => $textToken, + ]; + } + + return $filters; + } + + /** + * @return array + */ + private function mapMessagesByRemoteId(Message ...$messages): array { + $mapped = []; + foreach ($messages as $message) { + $rid = $message->getRemoteId(); + if ($rid === null) { + throw new ServiceException("Message {$message->getId()} does not have a remote id"); + } + $mapped[$rid] = $message; + } + return $mapped; + } + + private function mergeMessage(Message $target, Message $source): Message { + $target->setRemoteId($source->getRemoteId()); + $target->setMessageId($source->getMessageId()); + $target->setInReplyTo($source->getInReplyTo()); + $target->setReferences($source->getReferences()); + $target->setThreadRootId($source->getThreadRootId()); + $target->setSubject($source->getSubject()); + $target->setSentAt($source->getSentAt()); + $target->setFlagAnswered($source->getFlagAnswered() === true); + $target->setFlagDeleted($source->getFlagDeleted() === true); + $target->setFlagDraft($source->getFlagDraft() === true); + $target->setFlagFlagged($source->getFlagFlagged() === true); + $target->setFlagSeen($source->getFlagSeen() === true); + $target->setFlagForwarded($source->getFlagForwarded() === true); + $target->setFlagJunk($source->getFlagJunk() === true); + $target->setFlagNotjunk($source->getFlagNotjunk() === true); + $target->setFlagImportant($source->getFlagImportant() === true); + $target->setFlagMdnsent($source->getFlagMdnsent() === true); + $target->setPreviewText($source->getPreviewText()); + $target->setFlagAttachments($source->getFlagAttachments()); + $target->setStructureAnalyzed($source->getStructureAnalyzed() === true); + $target->setUpdatedAt($source->getUpdatedAt()); + $target->setFrom($source->getFrom()); + $target->setTo($source->getTo()); + $target->setCc($source->getCc()); + $target->setBcc($source->getBcc()); + $target->setTags($source->getTags()); + + return $target; + } + + private function findLocalMessageByUid(Mailbox $mailbox, int $uid): Message { + $messages = $this->dbMessageMapper->findByUids($mailbox, [$uid]); + if ($messages === []) { + throw new ServiceException("Message $uid does not exist locally"); + } + + return $messages[0]; + } + + private function normalizeFlagForRemote(string $flag): string { + return match ($flag) { + 'seen' => '$seen', + 'flagged' => '$flagged', + 'deleted' => '$deleted', + 'draft' => '$draft', + 'answered' => '$answered', + 'forwarded' => '$forwarded', + 'junk' => '$junk', + 'notjunk' => '$notjunk', + 'mdnsent' => '$mdnsent', + 'important' => Tag::LABEL_IMPORTANT, + default => $flag, + }; + } + + private function applyFlagValue(Message $message, string $flag, bool $value): void { + switch ($flag) { + case '$seen': + case '$flagged': + case '$deleted': + case '$draft': + case '$answered': + case '$forwarded': + $message->setFlag(ltrim($flag, '$'), $value); + break; + case '$junk': + case '$notjunk': + case '$phishing': + case '$mdnsent': + case Tag::LABEL_IMPORTANT: + $message->setFlag($flag, $value); + break; + default: + $this->applyTagValue($message, $flag, $value); + break; + } + } + + private function applyTagValue(Message $message, string $flag, bool $value): void { + $tags = $message->getTags(); + + if ($value) { + foreach ($tags as $tag) { + if ($tag->getImapLabel() === $flag) { + return; + } + } + + $tag = new Tag(); + $tag->setImapLabel($flag); + $tag->setDisplayName($flag); + $tag->setColor(''); + $tag->setIsDefaultTag(false); + $tags[] = $tag; + } else { + $tags = array_values(array_filter( + $tags, + static fn (Tag $tag): bool => $tag->getImapLabel() !== $flag, + )); + } + + $message->setTags($tags); + } + +} diff --git a/lib/Listener/DeleteDraftListener.php b/lib/Listener/DeleteDraftListener.php index c858495c9a..a15cd4d9a2 100644 --- a/lib/Listener/DeleteDraftListener.php +++ b/lib/Listener/DeleteDraftListener.php @@ -19,8 +19,8 @@ use OCA\Mail\Events\DraftSavedEvent; use OCA\Mail\Events\MessageDeletedEvent; use OCA\Mail\Events\OutboxMessageCreatedEvent; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventDispatcher; @@ -35,7 +35,7 @@ class DeleteDraftListener implements IEventListener { private $eventDispatcher; public function __construct( - private IMAPClientFactory $imapClientFactory, + private ProtocolFactory $protocolFactory, private MailboxMapper $mailboxMapper, private MessageMapper $messageMapper, private LoggerInterface $logger, @@ -56,7 +56,7 @@ public function handle(Event $event): void { * @param Message $draft */ private function deleteDraft(Account $account, Message $draft): void { - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $draftsMailbox = $this->getDraftsMailbox($account); } catch (DoesNotExistException $e) { diff --git a/lib/Listener/MoveJunkListener.php b/lib/Listener/MoveJunkListener.php index ddcb224219..0cf7514ca6 100644 --- a/lib/Listener/MoveJunkListener.php +++ b/lib/Listener/MoveJunkListener.php @@ -9,10 +9,12 @@ namespace OCA\Mail\Listener; -use OCA\Mail\Contracts\IMailManager; +use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Events\MessageFlaggedEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Service\MailManager; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use Psr\Log\LoggerInterface; @@ -22,7 +24,8 @@ */ class MoveJunkListener implements IEventListener { public function __construct( - private IMailManager $mailManager, + private MailManager $mailManager, + private MailboxMapper $mailboxMapper, private LoggerInterface $logger, ) { } @@ -42,6 +45,16 @@ public function handle(Event $event): void { } $mailbox = $event->getMailbox(); + $messageId = $this->mailManager->getMessageIdForUid($mailbox, $event->getUid()); + if ($messageId === null) { + return; + } + + try { + $message = $this->mailManager->getMessage($account->getUserId(), $messageId); + } catch (DoesNotExistException) { + return; + } if ($event->isSet() && $junkMailboxId !== $mailbox->getId()) { try { @@ -57,10 +70,10 @@ public function handle(Event $event): void { try { $this->mailManager->moveMessage( $account, - $mailbox->getName(), - $event->getUid(), + $mailbox, + $message, $account, - $junkMailbox->getName(), + $junkMailbox, ); } catch (ServiceException $e) { $this->logger->error('move message to junk mailbox failed. account_id: {account_id}', [ @@ -68,14 +81,19 @@ public function handle(Event $event): void { 'account_id' => $account->getId(), ]); } - } elseif (!$event->isSet() && $mailbox->getName() !== 'INBOX') { + } elseif (!$event->isSet() && !$mailbox->isInbox()) { + $inboxMailbox = $this->mailboxMapper->findSpecialUseMailbox($account, 'inbox'); + if ($inboxMailbox === null) { + return; + } + try { $this->mailManager->moveMessage( $account, - $mailbox->getName(), - $event->getUid(), + $mailbox, + $message, $account, - 'INBOX', + $inboxMailbox, ); } catch (ServiceException $e) { $this->logger->error('move message to inbox failed. account_id: {account_id}', [ diff --git a/lib/Model/IMAPMessage.php b/lib/Model/IMAPMessage.php index f428c4db6e..cd2d373ecb 100644 --- a/lib/Model/IMAPMessage.php +++ b/lib/Model/IMAPMessage.php @@ -433,6 +433,10 @@ public function isSignatureValid(): bool { return $this->signatureIsValid; } + public function hasDkimSignature(): bool { + return $this->hasDkimSignature; + } + public function getUnsubscribeUrl(): ?string { return $this->unsubscribeUrl; } diff --git a/lib/Protocol/ProtocolFactory.php b/lib/Protocol/ProtocolFactory.php index b226f4bb0e..e973172565 100644 --- a/lib/Protocol/ProtocolFactory.php +++ b/lib/Protocol/ProtocolFactory.php @@ -9,6 +9,7 @@ namespace OCA\Mail\Protocol; +use Horde_Imap_Client_Exception; use Horde_Imap_Client_Socket; use JmapClient\Client as JmapClient; use OCA\Mail\Account; @@ -18,7 +19,13 @@ use OCA\Mail\Db\MailAccount; use OCA\Mail\Exception\ServiceException; use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\ImapMailboxConnector; +use OCA\Mail\IMAP\ImapMessageConnector; +use OCA\Mail\IMAP\ImapTransmissionConnector; +use OCA\Mail\JMAP\Exception\JmapTransportException; use OCA\Mail\JMAP\JmapClientFactory; +use OCA\Mail\JMAP\JmapMailboxConnector; +use OCA\Mail\JMAP\JmapMessageConnector; use Psr\Container\ContainerInterface; class ProtocolFactory { @@ -27,16 +34,16 @@ class ProtocolFactory { * Maps protocol => connector interface => class name */ private const CONNECTOR_MAP = [ - // MailAccount::PROTOCOL_IMAP => [ - // IMailboxConnector::class => ImapMailboxConnector::class, - // IMessageConnector::class => ImapMessageConnector::class, - // ITransmissionConnector::class => ImapTransmissionConnector::class, - // ], - // MailAccount::PROTOCOL_JMAP => [ - // IMailboxConnector::class => JmapMailboxConnector::class, - // IMessageConnector::class => JmapMessageConnector::class, - // ITransmissionConnector::class => JmapTransmissionConnector::class, - // ], + MailAccount::PROTOCOL_IMAP => [ + IMailboxConnector::class => ImapMailboxConnector::class, + IMessageConnector::class => ImapMessageConnector::class, + //ITransmissionConnector::class => ImapTransmissionConnector::class, + ], + MailAccount::PROTOCOL_JMAP => [ + IMailboxConnector::class => JmapMailboxConnector::class, + IMessageConnector::class => JmapMessageConnector::class, + //ITransmissionConnector::class => JmapTransmissionConnector::class, + ], ]; public function __construct( @@ -62,6 +69,30 @@ public function jmapClient(Account $account): JmapClient { return $this->jmapClientFactory->getClient($account); } + /** + * @throws ServiceException + * @throws Horde_Imap_Client_Exception + * @throws JmapTransportException + */ + public function testConnection(Account $account): void { + $protocol = $account->getMailAccount()->getProtocol(); + + if ($protocol === MailAccount::PROTOCOL_IMAP) { + $this->imapClient($account)->close(); + return; + } + + if ($protocol === MailAccount::PROTOCOL_JMAP) { + $client = $this->jmapClient($account); + if (!$client->sessionStatus()) { + $client->connect(); + } + return; + } + + throw new ServiceException("Unsupported protocol $protocol"); + } + /** * @throws ServiceException */ @@ -102,7 +133,9 @@ public function transmissionConnector(Account $account): ITransmissionConnector */ private function resolveConnector(Account $account, string $interface): mixed { $protocol = $account->getMailAccount()->getProtocol(); - $class = self::CONNECTOR_MAP[$protocol][$interface] ?? null; + /** @var array> $map */ + $map = self::CONNECTOR_MAP; + $class = $map[$protocol][$interface] ?? null; if ($class === null) { throw new ServiceException("No $interface implementation for protocol $protocol"); diff --git a/lib/Send/Chain.php b/lib/Send/Chain.php index 5e7d60c204..bc485d1948 100644 --- a/lib/Send/Chain.php +++ b/lib/Send/Chain.php @@ -12,7 +12,7 @@ use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Attachment\AttachmentService; use OCP\DB\Exception; @@ -25,7 +25,7 @@ public function __construct( private FlagRepliedMessageHandler $flagRepliedMessageHandler, private AttachmentService $attachmentService, private LocalMessageMapper $localMessageMapper, - private IMAPClientFactory $clientFactory, + private ProtocolFactory $protocolFactory, ) { } @@ -50,7 +50,7 @@ public function process(Account $account, LocalMessage $localMessage): LocalMess throw new ServiceException('Could not send message because a previous send operation produced an unclear sent state.'); } - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $result = $handlers->process($account, $localMessage, $client); } finally { diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index 256b546e98..900f3db43a 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -22,7 +22,7 @@ use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJob; @@ -45,7 +45,7 @@ public function __construct( private MailAccountMapper $mapper, private AliasesService $aliasesService, IJobList $jobList, - private IMAPClientFactory $imapClientFactory, + private ProtocolFactory $protocolFactory, private readonly IConfig $config, private readonly ITimeFactory $timeFactory, private DelegationMapper $delegationMapper, @@ -234,8 +234,7 @@ public function getAllAcounts(): array { public function testAccountConnection(string $currentUserId, int $accountId) :bool { $account = $this->find($currentUserId, $accountId); try { - $client = $this->imapClientFactory->getClient($account); - $client->close(); + $this->protocolFactory->testConnection($account); return true; } catch (\Throwable $e) { return false; diff --git a/lib/Service/AiIntegrations/AiIntegrationsService.php b/lib/Service/AiIntegrations/AiIntegrationsService.php index e482144385..eb5d3334ac 100644 --- a/lib/Service/AiIntegrations/AiIntegrationsService.php +++ b/lib/Service/AiIntegrations/AiIntegrationsService.php @@ -13,14 +13,13 @@ use OCA\Mail\Account; use OCA\Mail\AppInfo\Application; use OCA\Mail\ConfigLexicon; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; use OCA\Mail\Exception\PotentialPromptInjectionException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Model\EventData; use OCA\Mail\Model\IMAPMessage; +use OCA\Mail\Service\MailManager; use OCP\IAppConfig; use OCP\IUserManager; use OCP\L10N\IFactory; @@ -42,8 +41,7 @@ class AiIntegrationsService { public function __construct( private LoggerInterface $logger, private Cache $cache, - private IMAPClientFactory $clientFactory, - private IMailManager $mailManager, + private MailManager $mailManager, private TaskProcessingManager $taskProcessingManager, private IFactory $l10nFactory, private IUserManager $userManager, @@ -79,52 +77,45 @@ public function summarizeMessages(Account $account, array $messages): void { } $user = $this->userManager->get($account->getUserId()); $language = explode('_', $this->l10nFactory->getUserLanguage($user))[0]; - $client = $this->clientFactory->getClient($account); - try { - foreach ($messages as $entry) { - if (mb_strlen((string)$entry->getSummary()) !== 0) { - continue; - } - // retrieve full message from server - $userId = $account->getUserId(); - $mailboxId = $entry->getMailboxId(); - $messageLocalId = $entry->getId(); - $messageRemoteId = $entry->getUid(); - $mailbox = $this->mailManager->getMailbox($userId, $mailboxId); - $message = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $messageRemoteId, - true - ); - // skip message if it is encrypted or empty - if ($message->isEncrypted() || empty(trim($message->getPlainBody()))) { - continue; - } - $messageBody = $message->getPlainBody(); - try { - self::assertNoPromptInjection($messageBody); - } catch (PotentialPromptInjectionException $e) { - $this->logger->warning('Skipped message summary: potential prompt injection', ['exception' => $e, 'messageId' => $messageLocalId]); - continue; - } - // construct prompt and task - $prompt = sprintf(DefaultPrompts::SUMMARIZE_MESSAGE, $language, $messageBody); - $task = new TaskProcessingTask( - TextToText::ID, - [ - 'max_tokens' => 1024, - 'input' => $prompt, - ], - Application::APP_ID, - $userId, - 'message:' . (string)$messageLocalId - ); - $this->taskProcessingManager->scheduleTask($task); + foreach ($messages as $entry) { + if (mb_strlen((string)$entry->getSummary()) !== 0) { + continue; + } + // retrieve full message from server + $userId = $account->getUserId(); + $mailboxId = $entry->getMailboxId(); + $messageLocalId = $entry->getId(); + $mailbox = $this->mailManager->getMailbox($userId, $mailboxId); + $message = $this->mailManager->getImapMessage( + $account, + $mailbox, + $entry, + true + ); + // skip message if it is encrypted or empty + if ($message->isEncrypted() || empty(trim($message->getPlainBody()))) { + continue; } - } finally { - $client->logout(); + $messageBody = $message->getPlainBody(); + try { + self::assertNoPromptInjection($messageBody); + } catch (PotentialPromptInjectionException $e) { + $this->logger->warning('Skipped message summary: potential prompt injection', ['exception' => $e, 'messageId' => $messageLocalId]); + continue; + } + // construct prompt and task + $prompt = sprintf(DefaultPrompts::SUMMARIZE_MESSAGE, $language, $messageBody); + $task = new TaskProcessingTask( + TextToText::ID, + [ + 'max_tokens' => 1024, + 'input' => $prompt, + ], + Application::APP_ID, + $userId, + 'message:' . (string)$messageLocalId + ); + $this->taskProcessingManager->scheduleTask($task); } } @@ -145,15 +136,14 @@ public function summarizeThread(Account $account, string $threadId, array $messa if ($cachedSummary) { return $cachedSummary; } - $client = $this->clientFactory->getClient($account); try { - $messagesBodies = array_map(function ($message) use ($client, $account, $currentUserId) { + $messagesBodies = array_map(function ($message) use ($account, $currentUserId) { $mailbox = $this->mailManager->getMailbox($currentUserId, $message->getMailboxId()); $imapMessage = $this->mailManager->getImapMessage( - $client, $account, $mailbox, - $message->getUid(), true + $message, + true ); $body = $imapMessage->getPlainBody(); self::assertNoPromptInjection($body); @@ -162,8 +152,6 @@ public function summarizeThread(Account $account, string $threadId, array $messa } catch (PotentialPromptInjectionException $e) { $this->logger->warning('Skipped thread summary: potential prompt injection', ['exception' => $e, 'threadId' => $threadId]); return null; - } finally { - $client->logout(); } $taskPrompt = implode("\n", $messagesBodies); @@ -196,15 +184,14 @@ public function generateEventData(Account $account, string $threadId, array $mes if (!isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToText::ID])) { return null; } - $client = $this->clientFactory->getClient($account); try { - $messageBodies = array_map(function ($message) use ($client, $account, $currentUserId) { + $messageBodies = array_map(function ($message) use ($account, $currentUserId) { $mailbox = $this->mailManager->getMailbox($currentUserId, $message->getMailboxId()); $imapMessage = $this->mailManager->getImapMessage( - $client, $account, $mailbox, - $message->getUid(), true + $message, + true ); $body = $imapMessage->getPlainBody(); self::assertNoPromptInjection($body); @@ -213,8 +200,6 @@ public function generateEventData(Account $account, string $threadId, array $mes } catch (PotentialPromptInjectionException $e) { $this->logger->warning('Skipped event data generation: potential prompt injection', ['exception' => $e, 'threadId' => $threadId]); return null; - } finally { - $client->logout(); } $task = new TaskProcessingTask( @@ -258,13 +243,12 @@ public function getSmartReply(Account $account, Mailbox $mailbox, Message $messa throw new ServiceException('Failed to decode smart replies JSON output', previous: $e); } } - $client = $this->clientFactory->getClient($account); try { $imapMessage = $this->mailManager->getImapMessage( - $client, $account, $mailbox, - $message->getUid(), true + $message, + true ); if (!$this->isPersonalEmail($imapMessage)) { return []; @@ -274,8 +258,6 @@ public function getSmartReply(Account $account, Mailbox $mailbox, Message $messa } catch (PotentialPromptInjectionException $e) { $this->logger->warning('Skipped smart replies: potential prompt injection', ['exception' => $e, 'messageId' => $message->getId()]); return []; - } finally { - $client->logout(); } $prompt = sprintf(DefaultPrompts::SMART_REPLY, $messageBody); $task = new TaskProcessingTask(TextToText::ID, ['input' => $prompt], Application::APP_ID, $currentUserId); @@ -321,18 +303,12 @@ public function requiresFollowUp( throw new ServiceException('No language model available for smart replies'); } - $client = $this->clientFactory->getClient($account); - try { - $imapMessage = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), - true, - ); - } finally { - $client->logout(); - } + $imapMessage = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message, + true, + ); if (!$this->isPersonalEmail($imapMessage)) { return false; diff --git a/lib/Service/AntiSpamService.php b/lib/Service/AntiSpamService.php index efb1d6b8ec..c0ec727249 100644 --- a/lib/Service/AntiSpamService.php +++ b/lib/Service/AntiSpamService.php @@ -21,9 +21,9 @@ use OCA\Mail\Db\MessageMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; use OCA\Mail\Model\Message; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\DataUri\DataUriParser; use OCA\Mail\SMTP\SmtpClientFactory; use OCP\AppFramework\Db\DoesNotExistException; @@ -36,7 +36,7 @@ class AntiSpamService { public function __construct( private MessageMapper $dbMessageMapper, private MailManager $mailManager, - private IMAPClientFactory $imapClientFactory, + private ProtocolFactory $protocolFactory, private SmtpClientFactory $smtpClientFactory, private ImapMessageMapper $messageMapper, private LoggerInterface $logger, @@ -120,7 +120,7 @@ public function sendReportEmail(Account $account, Mailbox $mailbox, int $uid, st $mailbox = $this->mailManager->getMailbox($userId, $attachmentMessage->getMailboxId()); - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $fullText = $this->messageMapper->getFullText( $client, @@ -198,7 +198,7 @@ public function sendReportEmail(Account $account, Mailbox $mailbox, int $uid, st return; } - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $this->messageMapper->save( $client, diff --git a/lib/Service/Attachment/AttachmentService.php b/lib/Service/Attachment/AttachmentService.php index 8f718d007c..149a1ef1d1 100644 --- a/lib/Service/Attachment/AttachmentService.php +++ b/lib/Service/Attachment/AttachmentService.php @@ -14,7 +14,6 @@ use OCA\Files_Sharing\SharedStorage; use OCA\Mail\Account; use OCA\Mail\Contracts\IAttachmentService; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\LocalAttachment; use OCA\Mail\Db\LocalAttachmentMapper; use OCA\Mail\Db\LocalMessage; @@ -25,6 +24,7 @@ use OCA\Mail\Exception\SmimeDecryptException; use OCA\Mail\Exception\UploadException; use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Files\File; @@ -50,7 +50,7 @@ public function __construct( $userFolder, private LocalAttachmentMapper $mapper, private AttachmentStorage $storage, - private IMailManager $mailManager, + private MailManager $mailManager, private MessageMapper $messageMapper, private ICacheFactory $cacheFactory, private IURLGenerator $urlGenerator, @@ -252,7 +252,7 @@ public function handleAttachments(Account $account, array $attachments, \Horde_I /** * @return list */ - public function getAttachmentNames(Account $account, Mailbox $mailbox, Message $message, \Horde_Imap_Client_Socket $client): array { + public function getAttachmentNames(Account $account, Mailbox $mailbox, Message $message): array { if ($message->getStructureAnalyzed() === true && $message->getFlagAttachments() === false) { // Structure analysis already confirmed no attachments, nothing to fetch. return []; @@ -276,10 +276,9 @@ public function getAttachmentNames(Account $account, Mailbox $mailbox, Message $ $attachments = []; try { $imapMessage = $this->mailManager->getImapMessage( - $client, $account, $mailbox, - $message->getUid(), + $message, true ); $attachments = $imapMessage->getAttachments(); diff --git a/lib/Service/Classification/NewMessagesClassifier.php b/lib/Service/Classification/NewMessagesClassifier.php index 449157531f..79cf7903a5 100644 --- a/lib/Service/Classification/NewMessagesClassifier.php +++ b/lib/Service/Classification/NewMessagesClassifier.php @@ -11,13 +11,13 @@ use Horde_Imap_Client; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; use OCA\Mail\Db\Tag; use OCA\Mail\Db\TagMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Service\MailManager; use Psr\Log\LoggerInterface; class NewMessagesClassifier { @@ -33,7 +33,7 @@ public function __construct( private ImportanceClassifier $classifier, private TagMapper $tagMapper, private LoggerInterface $logger, - private IMailManager $mailManager, + private MailManager $mailManager, ) { } @@ -88,8 +88,8 @@ public function classifyNewMessages( $this->logger->info("Message {$message->getUid()} ({$message->getPreviewText()}) is " . ($prediction ? 'important' : 'not important')); if ($prediction) { $message->setFlagImportant(true); - $this->mailManager->flagMessage($account, $mailbox->getName(), $message->getUid(), Tag::LABEL_IMPORTANT, true); - $this->mailManager->tagMessage($account, $mailbox->getName(), $message, $importantTag, true); + $this->mailManager->flagMessages($account, $mailbox, Tag::LABEL_IMPORTANT, true, $message); + $this->mailManager->tagMessages($account, $mailbox, $importantTag, true, $message); } } } catch (ServiceException $e) { diff --git a/lib/Service/DkimService.php b/lib/Service/DkimService.php index a498004774..a15ed8fd81 100644 --- a/lib/Service/DkimService.php +++ b/lib/Service/DkimService.php @@ -13,9 +13,9 @@ use OCA\Mail\Contracts\IDkimService; use OCA\Mail\Contracts\IDkimValidator; use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\Message; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\ICache; use OCP\ICacheFactory; @@ -27,8 +27,7 @@ class DkimService implements IDkimService { private $cache; public function __construct( - private IMAPClientFactory $clientFactory, - private MessageMapper $messageMapper, + private ProtocolFactory $protocolFactory, ICacheFactory $cacheFactory, private IDkimValidator $dkimValidator, ) { @@ -36,32 +35,23 @@ public function __construct( } #[\Override] - public function validate(Account $account, Mailbox $mailbox, int $id): bool { - $cached = $this->getCached($account, $mailbox, $id); + public function validate(Account $account, Mailbox $mailbox, Message $message): bool { + $cached = $this->getCached($account, $mailbox, $message->getId()); if (is_bool($cached)) { return $cached; } - $client = $this->clientFactory->getClient($account); - try { - $fullText = $this->messageMapper->getFullText( - $client, - $mailbox->getName(), - $id, - $account->getUserId(), - false, - ); + $fullText = $this->protocolFactory + ->messageConnector($account) + ->fetchMessageRaw($account, $mailbox, $message); - if ($fullText === null) { - throw new ServiceException("Could not fetch message source for uid $id"); - } - } finally { - $client->logout(); + if ($fullText === null) { + throw new ServiceException('Could not fetch message source for uid ' . $message->getUid()); } $result = $this->dkimValidator->validate($fullText); - $cache_key = $this->buildCacheKey($account, $mailbox, $id); + $cache_key = $this->buildCacheKey($account, $mailbox, $message->getId()); $this->cache->set($cache_key, $result, self::CACHE_TTL); return $result; diff --git a/lib/Service/DraftsService.php b/lib/Service/DraftsService.php index 1224cb8499..de8053d7c1 100644 --- a/lib/Service/DraftsService.php +++ b/lib/Service/DraftsService.php @@ -10,7 +10,6 @@ namespace OCA\Mail\Service; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; @@ -18,7 +17,7 @@ use OCA\Mail\Events\DraftMessageCreatedEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Attachment\AttachmentService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; @@ -35,8 +34,8 @@ public function __construct( private LocalMessageMapper $mapper, private AttachmentService $attachmentService, IEventDispatcher $eventDispatcher, - private IMAPClientFactory $clientFactory, - private IMailManager $mailManager, + private ProtocolFactory $protocolFactory, + private MailManager $mailManager, private LoggerInterface $logger, private AccountService $accountService, ITimeFactory $time, @@ -101,7 +100,7 @@ public function saveMessage(Account $account, LocalMessage $message, array $to, return $message; } - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); } finally { @@ -133,7 +132,7 @@ public function updateMessage(Account $account, LocalMessage $message, array $to return $message; } - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); } finally { diff --git a/lib/Service/IMipService.php b/lib/Service/IMipService.php index 146f2c9446..739e62cd0b 100644 --- a/lib/Service/IMipService.php +++ b/lib/Service/IMipService.php @@ -100,7 +100,7 @@ public function process(): void { } try { - $imapMessages = $this->mailManager->getImapMessagesForScheduleProcessing($account, $mailbox, array_map(static fn ($message) => $message->getUid(), $filteredMessages)); + $imapMessages = $this->mailManager->getImapMessages($account, $mailbox, true, ...$filteredMessages); } catch (ServiceException $e) { $this->logger->error('Could not get IMAP messages form IMAP server', ['exception' => $e]); continue; diff --git a/lib/Service/ItineraryService.php b/lib/Service/ItineraryService.php index ef285b7f71..1a656fbe73 100644 --- a/lib/Service/ItineraryService.php +++ b/lib/Service/ItineraryService.php @@ -11,9 +11,9 @@ use Nextcloud\KItinerary\Itinerary; use OCA\Mail\Account; +use OCA\Mail\Attachment; use OCA\Mail\Db\Mailbox; -use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Db\Message; use OCA\Mail\Integration\KItinerary\ItineraryExtractor; use OCP\ICache; use OCP\ICacheFactory; @@ -30,8 +30,7 @@ class ItineraryService { private $cache; public function __construct( - private IMAPClientFactory $clientFactory, - private MessageMapper $messageMapper, + private readonly MailManager $mailManager, private ItineraryExtractor $extractor, ICacheFactory $cacheFactory, private LoggerInterface $logger, @@ -51,30 +50,28 @@ public function getCached(Account $account, Mailbox $mailbox, int $id): ?Itinera return null; } - public function extract(Account $account, Mailbox $mailbox, int $id): Itinerary { - if ($cached = ($this->getCached($account, $mailbox, $id))) { + public function extract(Account $account, Mailbox $mailbox, Message $message): Itinerary { + if ($cached = ($this->getCached($account, $mailbox, $message->getId()))) { return $cached; } - $client = $this->clientFactory->getClient($account); - try { - $itinerary = new Itinerary(); - $htmlBody = $this->messageMapper->getHtmlBody($client, $mailbox->getName(), $id, $account->getUserId()); - if ($htmlBody !== null) { - $itinerary = $itinerary->merge( - $this->extractor->extract($htmlBody) - ); - $nItinerary = count($itinerary); - $this->logger->debug("Extracted $nItinerary itinerary entries from the message HTML body"); - } else { - $this->logger->debug('Message does not have an HTML body, can\'t extract itinerary info'); - } - $attachments = $this->messageMapper->getRawAttachments($client, $mailbox->getName(), $id, $account->getUserId()); - } finally { - $client->logout(); + $imapMessage = $this->mailManager->getImapMessage($account, $mailbox, $message, true); + + $itinerary = new Itinerary(); + $htmlBody = $imapMessage->htmlMessage; + if ($htmlBody !== '') { + $itinerary = $itinerary->merge( + $this->extractor->extract($htmlBody) + ); + $nItinerary = count($itinerary); + $this->logger->debug("Extracted $nItinerary itinerary entries from the message HTML body"); + } else { + $this->logger->debug('Message does not have an HTML body, can\'t extract itinerary info'); } - $itinerary = array_reduce($attachments, function (Itinerary $combined, string $attachment) { - $extracted = $this->extractor->extract($attachment); + + $attachments = $this->mailManager->getMailAttachments($account, $mailbox, $message); + $itinerary = array_reduce($attachments, function (Itinerary $combined, Attachment $attachment) { + $extracted = $this->extractor->extract($attachment->getContent()); $nExtracted = count($extracted); $this->logger->debug("Extracted $nExtracted itinerary entries from an attachment"); return $combined->merge($extracted); @@ -87,7 +84,7 @@ public function extract(Account $account, Mailbox $mailbox, int $id): Itinerary $nFinal = count($final); $this->logger->debug("Reduced $nItinerary itinerary entries to $nFinal entries"); - $cache_key = $this->buildCacheKey($account, $mailbox, $id); + $cache_key = $this->buildCacheKey($account, $mailbox, $message->getId()); $this->cache->set($cache_key, json_encode($final), self::CACHE_TTL); return $final; diff --git a/lib/Service/JMAP/JmapOperationsService.php b/lib/Service/JMAP/JmapOperationsService.php new file mode 100644 index 0000000000..913923e83d --- /dev/null +++ b/lib/Service/JMAP/JmapOperationsService.php @@ -0,0 +1,1038 @@ +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'); + } + + return true; + } + + /** + * Returns the connected data account identifier or fails if not connected. + */ + private function account(): string { + if ($this->dataAccount === null) { + throw new ServiceException('JMAP data store is not connected', 1); + } + return $this->dataAccount; + } + + /** + * Transmits one or more JMAP commands and returns the response bundle. + * + * @throws ServiceException + */ + private function transceive(array $commands): ResponseBundle { + try { + return $this->dataStore->perform($commands); + } catch (Throwable $e) { + throw new ServiceException('JMAP request failed: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Downloads a blob from remote storage into $data. + * + * @throws ServiceException + */ + private function download(string $account, string $identifier, &$data, string $type = 'application/octet-stream', string $name = 'file.bin'): void { + try { + $this->dataStore->download($account, $identifier, $data, $type, $name); + } catch (Throwable $e) { + throw new ServiceException('JMAP download failed: ' . $e->getMessage(), 0, $e); + } + } + + /** + * List of collections in remote storage + * + * @param string|null $location optional location constraint (e.g. parent collection id) + * @param list $filter optional filter conditions + * @param list $sort optional sort conditions + * + * @return Mailbox[] + */ + public function collectionList(?string $location = null, array $filter = [], array $sort = []): array { + // construct request + $r0 = new MailboxQuery($this->account()); + // define location + if (!empty($location)) { + $r0->filter()->in($location); + } + // define filter + foreach ($filter as $condition) { + $value = $condition['value']; + match($condition['attribute']) { + 'in' => $r0->filter()->in($value), + 'name' => $r0->filter()->name($value), + 'role' => $r0->filter()->role($value), + 'hasRoles' => $r0->filter()->hasRoles($value), + 'subscribed' => $r0->filter()->isSubscribed($value), + default => null + }; + } + // define order + foreach ($sort as $condition) { + $ascending = $condition['direction']; + match($condition['attribute']) { + 'name' => $r0->sort()->name($ascending), + 'order' => $r0->sort()->order($ascending), + default => null + }; + } + // construct request + $r1 = new MailboxGet($this->account()); + $r1->targetFromRequest($r0, '/ids'); + // transceive + $bundle = $this->transceive([$r0, $r1]); + // extract response + $response = $bundle->response(1); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + // convert collection objects + $list = []; + foreach ($response->objects() as $so) { + if (!$so instanceof MailboxParametersResponse) { + continue; + } + $list[] = $this->jmapMailboxAdapter->convertToMailbox($so); + } + // return collection of collections + return $list; + } + + /** + * Check existence of collections in remote storage + * + * @param string ...$identifiers remote identifiers + * + * @return array map of remote identifiers to existence status + */ + public function collectionExtant(string ...$identifiers): array { + $extant = []; + // construct request + $r0 = new MailboxGet($this->account()); + $r0->target(...$identifiers); + $r0->property('id'); + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + // construct map of extant collection identifiers + foreach ($response->objects() as $so) { + if (!$so instanceof MailboxParametersResponse) { + continue; + } + $id = $so->id(); + if ($id === null) { + continue; + } + $extant[$id] = true; + } + return $extant; + } + + /** + * Retrieve details for a specific collection in remote storage + * + * @param string $identifier remote identifier + * + * @return Mailbox|null collection object if retrieval was successful, null otherwise + */ + public function collectionFetch(string $identifier): ?Mailbox { + // construct request + $r0 = new MailboxGet($this->account()); + $r0->target($identifier); + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + // convert collection objects + $collection = $response->object(0); + if ($collection instanceof MailboxParametersResponse) { + return $this->jmapMailboxAdapter->convertToMailbox($collection); + } + return null; + } + + /** + * Create collection in remote storage + * + * @param Mailbox|null $location optional parent collection + * @param Mailbox $mailbox collection to create + * + * @return Mailbox|null created collection or null if creation failed + */ + public function collectionCreate(?Mailbox $location, Mailbox $mailbox): ?Mailbox { + // convert entity + $to = $this->jmapMailboxAdapter->convertFromMailbox($mailbox); + // define location + if (!empty($location)) { + $to->in($location->getRemoteId()); + } + $id = uniqid(); + // construct request + $r0 = new MailboxSet($this->account()); + $r0->create($id, $to); + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->createSuccess($id); + if ($result !== null) { + $mailbox->setRemoteId($result['id']); + $mailbox->setNameHash(md5($result['id'])); + return $mailbox; + } + // check for failure + $result = $response->createFailure($id); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection creation.'; + throw new ServiceException("$type: $description", 1); + } + // return null if creation failed without failure reason + return null; + } + + /** + * Modify collection in remote storage + * + * @param string $identifier remote identifier + * @param Mailbox $mailbox collection with modifications to apply + * + * @return Mailbox|null modified collection or null if modification failed + */ + public function collectionModify(string $identifier, Mailbox $mailbox, array $patch = []): ?Mailbox { + // convert entity, optionally limited to the patched properties + $to = $this->jmapMailboxAdapter->convertFromMailbox($mailbox, $patch); + // construct request + $r0 = new MailboxSet($this->account()); + $r0->update($identifier, $to); + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->updateSuccess($identifier); + if ($result !== null) { + return $mailbox; + } + // check for failure + $result = $response->updateFailure($identifier); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection modification.'; + throw new ServiceException("$type: $description", 1); + } + // return null if modification failed without failure reason + return null; + } + + /** + * Delete collection in remote storage + * + * @param string $identifier remote identifier + * @param bool $force whether to force deletion even if collection is not empty + * + * @return string|null deleted collection identifier or null if deletion failed + */ + public function collectionDestroy(string $identifier, bool $force = false): ?string { + // construct request + $r0 = new MailboxSet($this->account()); + $r0->delete($identifier); + if ($force) { + $r0->destroyContents(true); + } + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->deleteSuccess($identifier); + if ($result !== null) { + return (string)$result['id']; + } + // check for failure + $result = $response->deleteFailure($identifier); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection deletion.'; + throw new ServiceException("$type: $description", 1); + } + // return null if deletion failed without failure reason + return null; + } + + /** + * Retrieve entities from remote storage + * + * @param string|null $location optional location constraint + * @param list $filter optional filter conditions + * @param list $sort optional sort conditions + * @param JmapEntityListRange|null $range optional range conditions + * @param JmapEntityListGranularity|null $granularity optional granularity level; 'basic' returns entityPropertiesBasic, 'default' returns entityPropertiesDefault with full body + * + * @return array{state:string, list:array} + */ + public function entityList(?string $location = null, array $filter = [], array $sort = [], ?array $range = null, ?string $granularity = null): array { + // construct first request + $r0 = new MailQuery($this->account()); + // define location + if (!empty($location)) { + $r0->filter()->in($location); + } + // define filter + foreach ($filter as $condition) { + $value = $condition['value']; + match($condition['attribute']) { + '*' => $r0->filter()->text($value), + 'in' => $r0->filter()->in($value), + 'inOmit' => $r0->filter()->inOmit($value), + 'from' => $r0->filter()->from($value), + 'to' => $r0->filter()->to($value), + 'cc' => $r0->filter()->cc($value), + 'bcc' => $r0->filter()->bcc($value), + 'subject' => $r0->filter()->subject($value), + 'body' => $r0->filter()->body($value), + 'attachmentPresent' => $r0->filter()->hasAttachment($value), + 'tagPresent' => $r0->filter()->keywordPresent($value), + 'tagAbsent' => $r0->filter()->keywordAbsent($value), + 'before' => $r0->filter()->receivedBefore($value), + 'after' => $r0->filter()->receivedAfter($value), + 'min' => $r0->filter()->sizeMin((int)$value), + 'max' => $r0->filter()->sizeMax((int)$value), + default => null + }; + } + // define order + foreach ($sort as $condition) { + $direction = $condition['direction']; + match($condition['attribute']) { + 'from' => $r0->sort()->from($direction), + 'to' => $r0->sort()->to($direction), + 'subject' => $r0->sort()->subject($direction), + 'received' => $r0->sort()->received($direction), + 'sent' => $r0->sort()->sent($direction), + 'size' => $r0->sort()->size($direction), + 'tag' => $r0->sort()->keyword($direction), + default => null + }; + } + // define range + if ($range !== null) { + $anchor = $range['anchor'] ?? null; + $position = $range['position'] ?? null; + $tally = $range['tally'] ?? null; + if ($anchor === 'absolute' && $position !== null && $tally !== null) { + $r0->limitAbsolute((int)$position, (int)$tally); + } + if ($anchor === 'relative' && $position !== null && $tally !== null) { + $r0->limitRelative((int)$position, (int)$tally); + } + } + // construct second request + $r1 = new MailGet($this->account()); + $r1->targetFromRequest($r0, '/ids'); + // select properties to return + if ($granularity === 'basic') { + $r1->property(...$this->entityPropertiesBasic); + } else { + $r1->property(...$this->entityPropertiesDefault); + $r1->bodyAll(true); + } + // transceive + $bundle = $this->transceive([$r0, $r1]); + // extract response + $response = $bundle->response(1); + // convert json objects to message objects + $state = $response->state(); + $list = $response->objects(); + foreach ($list as $id => $entry) { + if (!$entry instanceof MailParametersResponse) { + continue; + } + $list[$id] = $this->jmapMessageAdapter->convertToDatabaseMessage($entry); + } + // return message collection + return ['list' => $list, 'state' => $state]; + } + + /** + * Check existence of entities in remote storage + * + * @param string ...$identifiers remote identifiers + * + * @return array array of remote identifiers and their existence status + */ + public function entityExtant(string ...$identifiers): array { + $extant = []; + // construct request + $r0 = new MailGet($this->account()); + $r0->target(...$identifiers); + $r0->property('id'); + // transmit request and receive response + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // construct map of extant collection identifiers + foreach ($response->objects() as $so) { + if (!$so instanceof MailParametersResponse) { + continue; + } + $id = $so->id(); + if ($id === null) { + continue; + } + $extant[$id] = true; + } + return $extant; + } + + /** + * Delta for entities in remote storage + * + * @param string|null $location optional remote location constraint (e.g. remote collection identifier) + * @param string $state state identifier to compare against + * + * @return array{state:string, additions:array, modifications:array, deletions:array} + */ + public function entityDelta(?string $location, string $state): array { + // if no state is given, return all entities as additions + if (empty($state)) { + $results = $this->entityList($location, [], [], null, 'basic'); + $delta = [ + 'state' => $results['state'], + 'additions' => [], + 'modifications' => [], + 'deletions' => [], + ]; + foreach ($results['list'] as $entry) { + $remoteId = $entry->getRemoteId(); + if ($remoteId === null) { + continue; + } + $delta['additions'][] = $remoteId; + } + return $delta; + } + // if location is given, perform delta for specific collection, otherwise perform delta for all collections + if (empty($location)) { + return $this->entityDeltaDefault($state); + } else { + return $this->entityDeltaSpecific($location, $state); + } + } + + /** + * Delta of changes for specific collection in remote storage + * + * @param string|null $location optional remote location constraint (e.g. remote collection identifier) + * @param string $state state identifier to compare against + * + * @return array{state:string, additions:array, modifications:array, deletions:array} + */ + public function entityDeltaSpecific(?string $location, string $state): array { + // construct set request + $r0 = new MailQueryChanges($this->account()); + // set location constraint + if (!empty($location)) { + $r0->filter()->in($location); + } + // set state constraint + if (!empty($state)) { + $r0->state($state); + } else { + $r0->state('0'); + } + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + return $this->constructDeltaResult( + $response->stateNew(), + $response->added(), + $response->removed(), + ); + } + + /** + * Delta of changes for all collections in remote storage + * + * @param string $state state identifier to compare against + * + * @return array{state:string, additions:array, modifications:array, deletions:array} + */ + public function entityDeltaDefault(string $state): array { + // construct set request + $r0 = new MailChanges($this->account()); + if (!empty($state)) { + $r0->state($state); + } else { + $r0->state(''); + } + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + + return $this->constructDeltaResult( + $response->stateNew(), + $response->added(), + $response->removed(), + ); + } + + /** + * Construct delta result from added and removed entries + * + * @param string $state state identifier to return in result + * @param array $added entries that were added + * @param array $removed entries that were removed + * + * @return array{state:string, additions:array, modifications:array, deletions:array} + */ + private function constructDeltaResult(string $state, array $added, array $removed): array { + // extract/flatten ids from added and removed entries + $extractIds = static function (array $entries): array { + $ids = []; + foreach ($entries as $entry) { + if (is_string($entry) && $entry !== '') { + $ids[] = $entry; + continue; + } + + $id = is_array($entry) ? ($entry['id'] ?? null) : null; + if (is_string($id) && $id !== '') { + $ids[] = $id; + } + } + + return array_values(array_unique($ids)); + }; + $addedIds = $extractIds($added); + $removedIds = $extractIds($removed); + // entries that are both in added and removed are considered modified + $modifiedIds = array_values(array_intersect($addedIds, $removedIds)); + $modifiedIdMap = array_fill_keys($modifiedIds, true); + // entries that are only in added are considered additions, entries that are only in removed are considered deletions + $additionIds = array_values(array_filter( + $addedIds, + static fn (string $id): bool => !isset($modifiedIdMap[$id]), + )); + $deletionIds = array_values(array_filter( + $removedIds, + static fn (string $id): bool => !isset($modifiedIdMap[$id]), + )); + + return [ + 'state' => $state, + 'additions' => $additionIds, + 'modifications' => $modifiedIds, + 'deletions' => $deletionIds, + ]; + } + + /** + * Retrieve entities from remote storage + * + * @param string ...$identifiers remote identifiers + * + * @return Message[] + */ + public function entityFetchMessage(string ...$identifiers): array { + $responses = $this->entityFetchNative(...$identifiers); + $list = []; + foreach ($responses as $id => $entry) { + $list[$id] = $this->jmapMessageAdapter->convertToDatabaseMessage($entry); + } + return $list; + } + + /** + * Retrieve entities from remote storage + * + * @param string ...$identifiers remote identifiers + * + * @return MailParametersResponse[] + */ + public function entityFetchNative(string ...$identifiers): array { + // construct request + $r0 = new MailGet($this->account()); + $r0->target(...$identifiers); + // select properties to return + $r0->property(...$this->entityPropertiesDefault); + $r0->bodyAll(true); + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // convert json objects to message objects + $list = []; + foreach ($response->objects() as $so) { + if (!$so instanceof MailParametersResponse) { + continue; + } + $id = $so->id(); + if ($id === null) { + continue; + } + $list[$id] = $so; + } + // return message collection + return $list; + } + + /** + * Retrieve raw message source from remote storage + * + * @param string $identifier remote identifier + * + * @return string|null raw message source if retrieval was successful, null otherwise + */ + public function entityFetchRaw(string $identifier): ?string { + $entities = $this->entityFetchNative($identifier); + $entity = $entities[$identifier] ?? null; + if (!$entity instanceof MailParametersResponse) { + return null; + } + + $blobId = $entity->blob(); + if ($blobId === null || $blobId === '') { + return null; + } + + $rawMessage = null; + $this->download($this->account(), $blobId, $rawMessage, 'message/rfc822', 'message.eml'); + + return is_string($rawMessage) ? $rawMessage : null; + } + + /** + * Create entity in remote storage + */ + public function entityCreate(string $location, array $so): ?array { + // convert entity + $to = new MailParametersRequest(); + $to->parametersRaw($so); + $to->in($location); + $id = uniqid(); + // construct request + $r0 = new MailSet($this->account()); + $r0->create($id, $to); + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + // check for success + $result = $response->createSuccess($id); + if ($result !== null) { + return array_merge($so, $result); + } + // check for failure + $result = $response->createFailure($id); + if ($result !== null) { + $type = $result['type'] ?? 'unknownError'; + $description = $result['description'] ?? 'An unknown error occurred during collection creation.'; + throw new ServiceException("$type: $description", 1); + } + // return null if creation failed without failure reason + return null; + } + + /** + * Update entity in remote storage + */ + public function entityModify(array $so): ?array { + // extract entity id + $id = $so['id']; + // convert entity + $to = new MailParametersRequest(); + $to->parametersRaw($so); + // construct request + $r0 = new MailSet($this->account()); + $r0->update($id, $to); + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + $results = []; + // check for success + foreach ($response->updateSuccesses() as $id) { + $results[$id] = true; + } + // check for failure + foreach ($response->updateFailures() as $id => $data) { + $results[$id] = $data['type'] ?? 'unknownError'; + } + + return $results; + } + + /** + * Partially update entity in remote storage + */ + public function entityModifyPatch(MailParametersRequest $patch, string ...$identifiers): ?array { + // construct request + $r0 = new MailSet($this->account()); + foreach ($identifiers as $id) { + $r0->patch($id, $patch); + } + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + $results = []; + // check for success + foreach ($response->updateSuccesses() as $id => $data) { + $results[$id] = true; + } + // check for failure + foreach ($response->updateFailures() as $id => $data) { + $results[$id] = $data['type'] ?? 'unknownError'; + } + + return $results; + } + + /** + * Modify entity flags in remote storage + * + * @param array $flags list of flags to set on entity (e.g. ['seen' => true, 'flagged' => false]) + * @param string ...$identifiers remote identifiers to apply flag modifications to + * + * @return array map of remote identifiers to modification result (true for success, error type for failure) + */ + public function entityModifyFlags(array $flags, string ...$identifiers): ?array { + // construct patch request with flag modifications + $patch = new MailParametersRequest(); + foreach ($flags as $flag => $value) { + $patch->keyword($flag, $value); + } + // execute command + $result = $this->entityModifyPatch($patch, ...$identifiers); + return $result; + } + + /** + * Delete entity in remote storage + * + * @param string ...$identifiers remote identifiers to delete + * + * @return array map of remote identifiers to deletion result (true for success, error type for failure) + */ + public function entityDelete(string ...$identifiers): array { + // construct set request + $r0 = new MailSet($this->account()); + foreach ($identifiers as $id) { + $r0->delete($id); + } + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + + $results = []; + // map successful and failed deletions to their identifiers + foreach ($response->deleteSuccesses() as $id) { + $results[$id] = true; + } + foreach ($response->deleteFailures() as $id => $data) { + $results[$id] = $data['type'] ?? 'unknownError'; + } + + return $results; + } + + /** + * Move entity to another collection in remote storage + * + * @param string $target remote identifier of target collection to move entities to + * @param string ...$identifiers remote identifiers of entities to move + * + * @return array map of remote identifiers to move result (true for success, error type for failure) + */ + public function entityMove(string $target, string ...$identifiers): array { + // construct set request + $r0 = new MailSet($this->account()); + foreach ($identifiers as $id) { + $r0->update($id)->in($target); + } + // transceive + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new ServiceException($response->type() . ': ' . $response->description(), 1); + } + } + + $results = []; + // map successful and failed moves to their identifiers + foreach ($response->updateSuccesses() as $id => $data) { + $results[$id] = true; + } + foreach ($response->updateFailures() as $id => $data) { + $results[$id] = $data['type'] ?? 'unknownError'; + } + + return $results; + } + + public function attachmentFetch(string $entityId, string ...$blobId): array { + $entities = $this->entityFetchNative($entityId); + $entity = $entities[$entityId] ?? null; + if (!$entity instanceof MailParametersResponse) { + return []; + } + + $attachments = []; + foreach ($entity->attachments() as $attachment) { + if ($blobId === [] || in_array($attachment->blob(), $blobId, true)) { + $content = ''; + $this->download($this->account(), $attachment->blob(), $content); + + $attachments[] = new Attachment( + $attachment->blob(), + $attachment->name() ?? 'unknown.file', + $attachment->type() ?? 'application/octet-stream', + $content, + $attachment->size() ?? 0, + $attachment->cid(), + $attachment->disposition(), + ); + } + } + + return $attachments; + } + + /** + * retrieve identity from remote storage + * + * + */ + public function identityFetch(?string $account = null): array { + if ($account === null) { + $account = $this->account(); + } + // construct set request + $r0 = new MailIdentityGet($this->account()); + // transmit request and receive response + $bundle = $this->transceive([$r0]); + // extract response + $response = $bundle->first(); + // convert json object to message object and return + return $response->objects(); + } + +} diff --git a/lib/Service/MailManager.php b/lib/Service/MailManager.php index dfcb6e9c91..fe26d948f2 100644 --- a/lib/Service/MailManager.php +++ b/lib/Service/MailManager.php @@ -9,14 +9,8 @@ namespace OCA\Mail\Service; -use Horde_Imap_Client; -use Horde_Imap_Client_Exception; -use Horde_Imap_Client_Exception_NoSupportExtension; -use Horde_Imap_Client_Socket; -use Horde_Mime_Exception; use OCA\Mail\Account; use OCA\Mail\Attachment; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; @@ -31,54 +25,38 @@ use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ImapFlagEncodingException; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Exception\SmimeDecryptException; use OCA\Mail\Exception\TrashMailboxNotSetException; -use OCA\Mail\Folder; -use OCA\Mail\IMAP\FolderMapper; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\ImapFlag; -use OCA\Mail\IMAP\MailboxSync; -use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; use OCA\Mail\Model\IMAPMessage; +use OCA\Mail\Protocol\ProtocolFactory; +use OCA\Mail\Service\Search\SearchQuery; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; use Psr\Log\LoggerInterface; use function array_map; use function array_values; -class MailManager implements IMailManager { - /** - * https://datatracker.ietf.org/doc/html/rfc9051#name-flags-message-attribute - */ - private const SYSTEM_FLAGS = [ - 'seen' => [Horde_Imap_Client::FLAG_SEEN], - 'answered' => [Horde_Imap_Client::FLAG_ANSWERED], - 'flagged' => [Horde_Imap_Client::FLAG_FLAGGED], - 'deleted' => [Horde_Imap_Client::FLAG_DELETED], - 'draft' => [Horde_Imap_Client::FLAG_DRAFT], - 'recent' => [Horde_Imap_Client::FLAG_RECENT], - ]; - - /** @var IEventDispatcher */ - private $eventDispatcher; +class MailManager { public function __construct( - private IMAPClientFactory $imapClientFactory, - private MailboxMapper $mailboxMapper, - private MailboxSync $mailboxSync, - private FolderMapper $folderMapper, - private ImapMessageMapper $imapMessageMapper, - private DbMessageMapper $dbMessageMapper, - IEventDispatcher $eventDispatcher, - private LoggerInterface $logger, - private TagMapper $tagMapper, - private MessageTagsMapper $messageTagsMapper, - private ThreadMapper $threadMapper, - private ImapFlag $imapFlag, + private readonly MailboxMapper $mailboxMapper, + private readonly DbMessageMapper $dbMessageMapper, + private readonly IEventDispatcher $eventDispatcher, + private readonly LoggerInterface $logger, + private readonly TagMapper $tagMapper, + private readonly MessageTagsMapper $messageTagsMapper, + private readonly ProtocolFactory $protocolFactory, + private readonly ImapFlag $imapFlag, + private readonly ThreadMapper $threadMapper, ) { - $this->eventDispatcher = $eventDispatcher; } - #[\Override] + //** ============================ Mailbox Operations ============================ */ + + /** + * @throws ClientException + */ public function getMailbox(string $uid, int $id): Mailbox { try { return $this->mailboxMapper->findByUid($id, $uid); @@ -94,213 +72,217 @@ public function getMailbox(string $uid, int $id): Mailbox { * @return Mailbox[] * @throws ServiceException */ - #[\Override] public function getMailboxes(Account $account, bool $forceSync = false): array { - $this->mailboxSync->sync($account, $this->logger, $forceSync); + $this->protocolFactory + ->mailboxConnector($account) + ->syncAll($account, $forceSync); return $this->mailboxMapper->findAll($account); } - #[\Override] + /** + * @throws ServiceException + * @throws DoesNotExistException the mailbox could not be re-read after being created on the remote (IMAP-backed accounts only) + */ public function createMailbox(Account $account, string $name, array $specialUse = []): Mailbox { - $client = $this->imapClientFactory->getClient($account); - try { - $folder = $this->folderMapper->createFolder($client, $name, $specialUse); - $this->folderMapper->fetchFolderAcls([$folder], $client); - $this->folderMapper->detectFolderSpecialUse([$folder]); - $this->mailboxSync->sync($account, $this->logger, true, $client); - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException( - 'Could not get mailbox status: ' . $e->getMessage(), - $e->getCode(), - $e - ); - } finally { - $client->logout(); + return $this->protocolFactory + ->mailboxConnector($account) + ->create($account, $name, $specialUse); + } + + /** + * @throws ServiceException + */ + public function renameMailbox(Account $account, Mailbox $mailbox, string $name): Mailbox { + return $this->protocolFactory + ->mailboxConnector($account) + ->rename($account, $mailbox, $name); + } + + /** + * @throws ServiceException + */ + public function deleteMailbox(Account $account, Mailbox $mailbox): void { + $this->protocolFactory + ->mailboxConnector($account) + ->delete($account, $mailbox); + } + + /** + * @throws ServiceException + * @throws DoesNotExistException the mailbox could not be re-read after being updated on the remote (IMAP-backed accounts only) + */ + public function updateSubscription(Account $account, Mailbox $mailbox, bool $subscribed): Mailbox { + return $this->protocolFactory + ->mailboxConnector($account) + ->subscribe($account, $mailbox, $subscribed); + } + + /** + * @throws ClientException + * @throws ServiceException + */ + public function clearMailbox(Account $account, Mailbox $mailbox): void { + $uids = $this->dbMessageMapper->findAllUids($mailbox); + if ($uids === []) { + return; } + // deleteMessages already moves to trash, or expunges if the mailbox is the trash + $messages = $this->dbMessageMapper->findByUids($mailbox, $uids); + $this->deleteMessages($account, $mailbox, ...$messages); + } + + //** ============================ Message Operations ============================ */ - return $this->mailboxMapper->find($account, $name); + /** + * @throws DoesNotExistException + */ + public function getMessage(string $uid, int $id): Message { + return $this->dbMessageMapper->findByUserId($uid, $id); } - #[\Override] - public function getImapMessage(Horde_Imap_Client_Socket $client, - Account $account, - Mailbox $mailbox, - int $uid, - bool $loadBody = false): IMAPMessage { - try { - return $this->imapMessageMapper->find( - $client, - $mailbox->getName(), - $uid, - $account->getUserId(), - $loadBody - ); - } catch (DoesNotExistException|Horde_Mime_Exception|Horde_Imap_Client_Exception $e) { - throw new ServiceException( - 'Could not load message', - $e->getCode(), - $e - ); + /** + * @throws ClientException + * @throws ServiceException + * @throws SmimeDecryptException + */ + public function getImapMessage(Account $account, Mailbox $mailbox, Message $message, bool $loadBody = false): IMAPMessage { + $messages = $this->protocolFactory + ->messageConnector($account) + ->fetchMessages($account, $mailbox, $loadBody, $message); + + if ($messages === []) { + throw new ClientException('Message not found on remote server'); } + return reset($messages); } /** * @param Account $account * @param Mailbox $mailbox - * @param int[] $uids + * @param bool $loadBody + * @param Message ...$messages * @return IMAPMessage[] * @throws ServiceException + * @throws SmimeDecryptException */ - public function getImapMessagesForScheduleProcessing(Account $account, - Mailbox $mailbox, - array $uids): array { - $client = $this->imapClientFactory->getClient($account); - try { - return $this->imapMessageMapper->findByIds( - $client, - $mailbox->getName(), - $uids, - $account->getUserId(), - true - ); - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException( - 'Could not load messages: ' . $e->getMessage(), - $e->getCode(), - $e - ); - } finally { - $client->logout(); - } + public function getImapMessages(Account $account, Mailbox $mailbox, bool $loadBody = false, Message ...$messages): array { + return $this->protocolFactory + ->messageConnector($account) + ->fetchMessages($account, $mailbox, $loadBody, ...$messages); } - #[\Override] - public function getThread(Account $account, string $threadRootId): array { - return $this->dbMessageMapper->findThread($account, $threadRootId); + /** + * @param Account $account + * @param Mailbox $mailbox + * @param Message $message + * + * @return string|null + * + * @throws ClientException + * @throws ServiceException + */ + public function getSource(Account $account, Mailbox $mailbox, Message $message): ?string { + $raw = $this->protocolFactory + ->messageConnector($account) + ->fetchMessageRaw($account, $mailbox, $message); + if ($raw === null) { + throw new ClientException('Message not found on remote server'); + } + return $raw; } - #[\Override] public function getMessageIdForUid(Mailbox $mailbox, $uid): ?int { return $this->dbMessageMapper->getIdForUid($mailbox, $uid); } - #[\Override] - public function getMessage(string $uid, int $id): Message { - return $this->dbMessageMapper->findByUserId($uid, $id); + /** + * @return Message[] + */ + public function getByMessageId(Account $account, string $messageId): array { + return $this->dbMessageMapper->findByMessageId($account, $messageId); } /** - * @param Horde_Imap_Client_Socket $client - * @param Account $account - * @param string $mailbox - * @param int $uid - * - * @return string - * + * @return int[] * @throws ServiceException */ - #[\Override] - public function getSource(Horde_Imap_Client_Socket $client, - Account $account, - string $mailbox, - int $uid): ?string { - try { - return $this->imapMessageMapper->getFullText( - $client, - $mailbox, - $uid, - $account->getUserId(), - false, - ); - } catch (Horde_Imap_Client_Exception|DoesNotExistException $e) { - throw new ServiceException('Could not load message', 0, $e); - } + public function findMessages(Account $account, Mailbox $mailbox, SearchQuery $searchQuery): array { + return $this->protocolFactory + ->messageConnector($account) + ->findMessages($account, $mailbox, $searchQuery); } /** * @param Account $sourceAccount - * @param string $sourceFolderId - * @param int $uid + * @param Mailbox $sourceMailbox + * @param Message $message * @param Account $destinationAccount - * @param string $destFolderId + * @param Mailbox $destinationMailbox * * @return ?int the new UID (or null if couldn't be determined) * @throws ServiceException */ - #[\Override] - public function moveMessage(Account $sourceAccount, - string $sourceFolderId, - int $uid, - Account $destinationAccount, - string $destFolderId): ?int { - if ($sourceAccount->getId() === $destinationAccount->getId()) { - try { - $sourceMailbox = $this->mailboxMapper->find($sourceAccount, $sourceFolderId); - } catch (DoesNotExistException $e) { - throw new ServiceException("Source mailbox $sourceFolderId does not exist", 0, $e); - } - - $newUid = $this->moveMessageOnSameAccount( - $sourceAccount, - $sourceFolderId, - $destFolderId, - $uid - ); - - // Delete cached source message (the source imap message is copied and deleted) - $this->eventDispatcher->dispatch( - MessageDeletedEvent::class, - new MessageDeletedEvent($sourceAccount, $sourceMailbox, $uid) - ); - - return $newUid; - } else { + public function moveMessage(Account $sourceAccount, Mailbox $sourceMailbox, Message $message, Account $destinationAccount, Mailbox $destinationMailbox): ?int { + if ($sourceAccount->getId() !== $destinationAccount->getId()) { throw new ServiceException('It is not possible to move across accounts yet'); } + + $mutatedUids = $this->moveMessages($sourceAccount, $destinationMailbox, $sourceMailbox, $message); + + return $mutatedUids[0] ?? null; } /** - * @throws ClientException * @throws ServiceException - * @todo evaluate if we should sync mailboxes first */ - #[\Override] - public function deleteMessage(Account $account, - string $mailboxId, - int $messageUid): void { - try { - $sourceMailbox = $this->mailboxMapper->find($account, $mailboxId); - } catch (DoesNotExistException $e) { - throw new ServiceException("Source mailbox $mailboxId does not exist", 0, $e); + public function moveMessages(Account $account, Mailbox $targetMailbox, Mailbox $sourceMailbox, Message ...$messages): array { + if ($messages === []) { + return []; + } + $sourceUids = []; + foreach ($messages as $message) { + $sourceUids[spl_object_id($message)] = $message->getUid(); } - $client = $this->imapClientFactory->getClient($account); - try { - $this->deleteMessageWithClient($account, $sourceMailbox, $messageUid, $client); - } finally { - $client->logout(); + // update remote store + $mutatedMessages = $this->protocolFactory + ->messageConnector($account) + ->moveMessages($account, $targetMailbox, $sourceMailbox, ...$messages); + + // update local store + $mutatedUids = []; + foreach ($mutatedMessages as $mutatedMessage) { + $this->dbMessageMapper->update($mutatedMessage); + $mutatedUids[] = $mutatedMessage->getUid(); + + $this->eventDispatcher->dispatchTyped(new MessageDeletedEvent( + $account, + $sourceMailbox, + $sourceUids[spl_object_id($mutatedMessage)], + )); } + + return $mutatedUids; } /** - * @throws ServiceException * @throws ClientException - * @throws TrashMailboxNotSetException - * + * @throws ServiceException * @todo evaluate if we should sync mailboxes first */ - #[\Override] - public function deleteMessageWithClient( - Account $account, - Mailbox $mailbox, - int $messageUid, - Horde_Imap_Client_Socket $client, - ): void { - $this->eventDispatcher->dispatchTyped( - new BeforeMessageDeletedEvent($account, $mailbox, $messageUid) - ); + public function deleteMessage(Account $account, Mailbox $mailbox, Message $message): void { + $this->deleteMessages($account, $mailbox, $message); + } + /** + * @throws ClientException + * @throws ServiceException + */ + public function deleteMessages(Account $account, Mailbox $sourceMailbox, Message ...$messages): void { + if ($messages === []) { + return; + } try { $trashMailboxId = $account->getMailAccount()->getTrashMailboxId(); if ($trashMailboxId === null) { @@ -310,403 +292,223 @@ public function deleteMessageWithClient( } catch (DoesNotExistException $e) { throw new ServiceException('No trash folder', 0, $e); } + $operation = $sourceMailbox->getId() === $trashMailbox->getId() ? 'delete' : 'move'; + $mappedUids = []; - if ($mailbox->getName() === $trashMailbox->getName()) { - // Delete inside trash -> expunge - $this->imapMessageMapper->expunge( - $client, - $mailbox->getName(), - $messageUid - ); - } else { - $this->imapMessageMapper->move( - $client, - $mailbox->getName(), - $messageUid, - $trashMailbox->getName() - ); + // dispatch events and map objects to their original UIDs before mutation + foreach ($messages as $message) { + $this->eventDispatcher->dispatchTyped(new BeforeMessageDeletedEvent($account, $sourceMailbox, $message->getUid())); + $this->logger->debug("$operation message", ['messageId' => $message->getUid(), 'mailboxId' => $message->getMailboxId()]); + $mappedUids[spl_object_id($message)] = $message->getUid(); + } + + // update remote store + $mutatedMessages = match ($operation) { + 'move' => $this->protocolFactory + ->messageConnector($account) + ->moveMessages($account, $trashMailbox, $sourceMailbox, ...$messages), + 'delete' => $this->protocolFactory + ->messageConnector($account) + ->deleteMessages($account, $sourceMailbox, ...$messages), + }; + + // update local store + if ($operation === 'move') { + foreach ($mutatedMessages as $mutatedMessage) { + $this->dbMessageMapper->update($mutatedMessage); + } + } + if ($operation === 'delete') { + $mutatedUids = array_map(static fn (Message $message): int => $message->getUid(), $mutatedMessages); + $this->dbMessageMapper->deleteByUid($sourceMailbox, ...$mutatedUids); } - $this->eventDispatcher->dispatchTyped( - new MessageDeletedEvent($account, $mailbox, $messageUid) - ); + // dispatch events + foreach ($mutatedMessages as $mutatedMessage) { + $this->eventDispatcher->dispatchTyped(new MessageDeletedEvent($account, $sourceMailbox, $mappedUids[spl_object_id($mutatedMessage)])); + } } /** - * @param Account $account - * @param string $sourceFolderId - * @param string $destFolderId - * @param int $messageId - * - * @return ?int the new UID (or null if it couldn't be determined) * @throws ServiceException - * */ - private function moveMessageOnSameAccount(Account $account, - string $sourceFolderId, - string $destFolderId, - int $messageId): ?int { - $client = $this->imapClientFactory->getClient($account); - try { - return $this->imapMessageMapper->move($client, $sourceFolderId, $messageId, $destFolderId); - } finally { - $client->logout(); + public function flagMessages(Account $account, Mailbox $mailbox, string $flag, bool $value, Message ...$messages): void { + if ($messages === []) { + return; } - } + // update remote store + $mutatedMessages = $this->protocolFactory + ->messageConnector($account) + ->flagMessages($account, $mailbox, $flag, $value, ...$messages); - #[\Override] - public function markFolderAsRead(Account $account, Mailbox $mailbox): void { - $client = $this->imapClientFactory->getClient($account); - try { - $this->imapMessageMapper->markAllRead($client, $mailbox->getName()); - } finally { - $client->logout(); - } - } + // update local store + $this->dbMessageMapper->updateBulk($account, true, ...$mutatedMessages); - #[\Override] - public function updateSubscription(Account $account, Mailbox $mailbox, bool $subscribed): Mailbox { - /** - * 1. Change subscription on IMAP - */ - $client = $this->imapClientFactory->getClient($account); - try { - $client->subscribeMailbox($mailbox->getName(), $subscribed); - - /** - * 2. Pull changes into the mailbox database cache - */ - $this->mailboxSync->sync($account, $this->logger, true, $client); - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException( - "Could not set subscription status for mailbox {$mailbox->getId()} on IMAP: {$e->getMessage()}", - $e->getCode(), - $e - ); - } finally { - $client->logout(); + // dispatch events + foreach ($mutatedMessages as $message) { + $this->eventDispatcher->dispatchTyped(new MessageFlaggedEvent($account, $mailbox, $message, $flag, $value)); } - - /** - * 3. Return the updated object - */ - return $this->mailboxMapper->find($account, $mailbox->getName()); } - #[\Override] - public function enableMailboxBackgroundSync(Mailbox $mailbox, - bool $syncInBackground): Mailbox { - $mailbox->setSyncInBackground($syncInBackground); - - return $this->mailboxMapper->update($mailbox); - } - - #[\Override] - public function flagMessage(Account $account, string $mailbox, int $uid, string $flag, bool $value): void { - try { - $mb = $this->mailboxMapper->find($account, $mailbox); - } catch (DoesNotExistException $e) { - throw new ClientException("Mailbox $mailbox does not exist", 0, $e); - } - - $client = $this->imapClientFactory->getClient($account); - try { - // Only send system flags to the IMAP server as other flags might not be supported - $imapFlags = $this->filterFlags($client, $account, $flag, $mailbox); - foreach ($imapFlags as $imapFlag) { - if (empty($imapFlag) === true) { - continue; - } - if ($value) { - $this->imapMessageMapper->addFlag($client, $mb, [$uid], $imapFlag); - } else { - $this->imapMessageMapper->removeFlag($client, $mb, [$uid], $imapFlag); - } - } - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException( - 'Could not set message flag on IMAP: ' . $e->getMessage(), - $e->getCode(), - $e - ); - } finally { - $client->logout(); - } - - // Looking the message up by uid is a shortcut to avoid changing this method's - // signature, which the JMAP PR does anyway. - $messages = $this->dbMessageMapper->findByUids($mb, [$uid]); - if (count($messages) < 1) { - // The message should be in the database cache, otherwise the client wouldn't - // know about the uid. Skip the event rather than fail the whole flag operation. + /** + * @throws ServiceException + */ + public function tagMessages(Account $account, Mailbox $mailbox, Tag $tag, bool $value, Message ...$messages): void { + if ($messages === []) { return; } + // update remote store + $mutatedMessages = $this->protocolFactory + ->messageConnector($account) + ->tagMessages($account, $mailbox, $tag, $value, ...$messages); - $message = reset($messages); - - $this->eventDispatcher->dispatch( - MessageFlaggedEvent::class, - new MessageFlaggedEvent( - $account, - $mb, - $message, - $flag, - $value - ) - ); + // update local store + $this->dbMessageMapper->updateBulk($account, true, ...$mutatedMessages); } /** - * Tag (flag) multiple messages on IMAP using a given client instance - * - * @param Message[] $messages - * - * @throws ClientException * @throws ServiceException */ - public function tagMessagesWithClient(Horde_Imap_Client_Socket $client, Account $account, Mailbox $mailbox, array $messages, Tag $tag, bool $value):void { - if ($this->isPermflagsEnabled($client, $account, $mailbox->getName()) === true) { - $messageIds = array_map(static fn (Message $message) => $message->getUid(), $messages); - try { - if ($value) { - // imap keywords and flags work the same way - $this->imapMessageMapper->addFlag($client, $mailbox, $messageIds, $tag->getImapLabel()); - } else { - $this->imapMessageMapper->removeFlag($client, $mailbox, $messageIds, $tag->getImapLabel()); - } - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException( - 'Could not set message keyword on IMAP: ' . $e->getMessage(), - $e->getCode(), - $e - ); - } + public function markFolderAsRead(Account $account, Mailbox $mailbox): void { + // find all messages in mailbox with their remote ids + $messages = $this->dbMessageMapper->findByUids($mailbox, $this->dbMessageMapper->findAllUids($mailbox)); + if ($messages === []) { + return; } + $this->flagMessages($account, $mailbox, 'seen', true, ...$messages); + } - if ($value) { - foreach ($messages as $message) { - $this->tagMapper->tagMessage($tag, $message->getMessageId(), $account->getUserId()); - } - } else { - foreach ($messages as $message) { - $this->tagMapper->untagMessage($tag, $message->getMessageId()); - } - } + public function getThread(Account $account, string $threadRootId): array { + return $this->dbMessageMapper->findThread($account, $threadRootId); } /** - * Tag (flag) a message on IMAP + * Finds all messages in the thread of the given thread root id * - * @param Account $account - * @param string $mailbox - * @param Message $message - * @param Tag $tag - * @param boolean $value - * @return void - * - * @throws ClientException - * @throws ServiceException - * @uses - * - * @link https://github.com/nextcloud/mail/issues/25 + * @return array array of messages in the thread, keyed by remote id + * @throws DoesNotExistException */ - #[\Override] - public function tagMessage(Account $account, string $mailbox, Message $message, Tag $tag, bool $value): void { - try { - $mb = $this->mailboxMapper->find($account, $mailbox); - } catch (DoesNotExistException $e) { - throw new ClientException("Mailbox $mailbox does not exist", 0, $e); + public function fetchThread(Account $account, Mailbox $mailbox, string $threadRootId): array { + $mailAccount = $account->getMailAccount(); + $messageInTrash = $mailbox->getId() === $mailAccount->getTrashMailboxId(); + $threadMessages = $this->threadMapper->findMessageUidsAndMailboxNamesByAccountAndThreadRoot( + $mailAccount, + $threadRootId, + $messageInTrash, + ); + + // group message uids by mailbox + $uids = []; + foreach ($threadMessages as $threadMessage) { + $uids[$threadMessage['mailboxName']][] = $threadMessage['messageUid']; } - $client = $this->imapClientFactory->getClient($account); - try { - $this->tagMessagesWithClient($client, $account, $mb, [$message], $tag, $value); - } finally { - $client->logout(); + unset($threadMessages); + + // retrieve messages from local store + $messages = []; + $mailboxes = []; + foreach ($uids as $mailboxName => $messageUids) { + $sourceMailbox = $mailboxes[$mailboxName] ??= $this->mailboxMapper->find($account, $mailboxName); + $sourceMessages = $this->dbMessageMapper->findByUids($sourceMailbox, $messageUids); + $messages = array_merge($messages, $sourceMessages); } + + return $messages; } /** - * @param Account $account - * - * @return Quota|null - * @see https://tools.ietf.org/html/rfc2087 + * @throws DoesNotExistException + * @throws ServiceException */ - #[\Override] - public function getQuota(Account $account): ?Quota { - /** - * Get all the quotas roots of the user's mailboxes - */ - $client = $this->imapClientFactory->getClient($account); - try { - $quotas = array_map(static fn (Folder $mb) => $client->getQuotaRoot($mb->getMailbox()), $this->folderMapper->getFolders($account, $client)); - } catch (Horde_Imap_Client_Exception_NoSupportExtension $ex) { - return null; - } finally { - $client->logout(); + public function moveThread(Account $srcAccount, Mailbox $srcMailbox, Account $dstAccount, Mailbox $dstMailbox, string $threadRootId): array { + if ($srcAccount->getId() !== $dstAccount->getId()) { + throw new ServiceException('It is not possible to move across accounts yet'); } - /** - * Extract the 'storage' quota - * - * Falls back to 0/0 if this quota has no storage information - * - * @see https://tools.ietf.org/html/rfc2087#section-3 - */ - $storageQuotas = array_map(static fn (array $root) => $root['storage'] ?? [ - 'usage' => 0, - 'limit' => 0, - ], array_merge(...array_values($quotas))); - - if ($storageQuotas === []) { - // Nothing left to do, and array_merge doesn't like to be called with zero arguments. - return null; + $messages = $this->fetchThread($srcAccount, $srcMailbox, $threadRootId); + if ($messages === []) { + return []; } - /** - * Deduplicate identical quota roots - */ - $storage = array_merge(...array_values($storageQuotas)); - - return new Quota( - 1024 * (int)($storage['usage'] ?? 0), - 1024 * (int)($storage['limit'] ?? 0) - ); + return $this->moveMessages($srcAccount, $dstMailbox, $srcMailbox, ...$messages); } - #[\Override] - public function renameMailbox(Account $account, Mailbox $mailbox, string $name): Mailbox { - /* - * 1. Rename on IMAP - */ - $client = $this->imapClientFactory->getClient($account); - try { - $this->folderMapper->renameFolder( - $client, - $mailbox->getName(), - $name - ); - - /** - * 2. Get the IMAP changes into our database cache - */ - $this->mailboxSync->sync($account, $this->logger, true, $client); - } finally { - $client->logout(); + /** + * @throws ClientException + * @throws DoesNotExistException + * @throws ServiceException + */ + public function deleteThread(Account $account, Mailbox $mailbox, string $threadRootId): void { + if ($account->getMailAccount()->getTrashMailboxId() === null) { + throw new TrashMailboxNotSetException(); } - /** - * 3. Return the cached object with the new ID - */ - try { - return $this->mailboxMapper->find($account, $name); - } catch (DoesNotExistException $e) { - throw new ServiceException("The renamed mailbox $name does not exist", 0, $e); + $messages = $this->fetchThread($account, $mailbox, $threadRootId); + if ($messages === []) { + return; } + + $this->deleteMessages($account, $mailbox, ...$messages); } /** * @param Account $account * @param Mailbox $mailbox + * @param Message $message + * @return Attachment[] * + * @throws DoesNotExistException * @throws ServiceException */ - #[\Override] - public function deleteMailbox(Account $account, - Mailbox $mailbox): void { - $client = $this->imapClientFactory->getClient($account); - try { - $this->folderMapper->delete($client, $mailbox->getName()); - } finally { - $client->logout(); - } - $this->mailboxMapper->delete($mailbox); + public function getMailAttachments(Account $account, Mailbox $mailbox, Message $message): array { + return $this->protocolFactory + ->messageConnector($account) + ->fetchAttachments($account, $mailbox, $message); } /** - * Clear messages in folder - * * @param Account $account * @param Mailbox $mailbox + * @param Message $message + * @param string $attachmentId + * @return Attachment * * @throws DoesNotExistException - * @throws Horde_Imap_Client_Exception - * @throws Horde_Imap_Client_Exception_NoSupportExtension * @throws ServiceException */ - #[\Override] - public function clearMailbox(Account $account, - Mailbox $mailbox): void { - $client = $this->imapClientFactory->getClient($account); - $trashMailboxId = $account->getMailAccount()->getTrashMailboxId(); - $currentMailboxId = $mailbox->getId(); - try { - if (($currentMailboxId !== $trashMailboxId) && !is_null($trashMailboxId)) { - $trash = $this->mailboxMapper->findById($trashMailboxId); - $client->copy($mailbox->getName(), $trash->getName(), [ - 'move' => true - ]); - } else { - $client->expunge($mailbox->getName(), [ - 'delete' => true - ]); - } - $this->dbMessageMapper->deleteAll($mailbox); - } finally { - $client->logout(); - } + public function getMailAttachment(Account $account, Mailbox $mailbox, Message $message, string $attachmentId): Attachment { + return $this->protocolFactory + ->messageConnector($account) + ->fetchAttachment($account, $mailbox, $message, $attachmentId); } /** * @param Account $account - * @param Mailbox $mailbox - * @param Message $message - * @return Attachment[] + * + * @return Quota|null + * @see https://tools.ietf.org/html/rfc2087 + * + * @throws ServiceException */ - #[\Override] - public function getMailAttachments(Account $account, Mailbox $mailbox, Message $message): array { - $client = $this->imapClientFactory->getClient($account); - try { - return $this->imapMessageMapper->getAttachments( - $client, - $mailbox->getName(), - $message->getUid(), - $account->getUserId(), - ); - } finally { - $client->logout(); - } + public function getQuota(Account $account): ?Quota { + return $this->protocolFactory + ->messageConnector($account) + ->getQuota($account); } /** + * Check IMAP server for support for PERMANENTFLAGS + * * @param Account $account * @param Mailbox $mailbox - * @param Message $message - * @param string $attachmentId - * @return Attachment + * @return boolean * - * @throws DoesNotExistException - * @throws Horde_Imap_Client_Exception - * @throws Horde_Imap_Client_Exception_NoSupportExtension * @throws ServiceException - * @throws Horde_Mime_Exception */ - #[\Override] - public function getMailAttachment(Account $account, - Mailbox $mailbox, - Message $message, - string $attachmentId): Attachment { - $client = $this->imapClientFactory->getClient($account); - try { - return $this->imapMessageMapper->getAttachment( - $client, - $mailbox->getName(), - $message->getUid(), - $attachmentId, - $account->getUserId(), - ); - } finally { - $client->logout(); - } + public function isPermflagsEnabled(Account $account, Mailbox $mailbox): bool { + return $this->protocolFactory + ->messageConnector($account) + ->isPermflagsEnabled($account, $mailbox); } /** @@ -715,8 +517,7 @@ public function getMailAttachment(Account $account, * @return Tag * @throws ClientException */ - #[\Override] - public function getTagByImapLabel(string $imapLabel, string $userId): Tag { + public function getTagByLabel(string $imapLabel, string $userId): Tag { try { return $this->tagMapper->getTagByImapLabel($imapLabel, $userId); } catch (DoesNotExistException $e) { @@ -725,61 +526,8 @@ public function getTagByImapLabel(string $imapLabel, string $userId): Tag { } /** - * Filter out IMAP flags that aren't supported by the client server - * - * @param string $flag - * @param string $mailbox - * @return array - */ - public function filterFlags(Horde_Imap_Client_Socket $client, Account $account, string $flag, string $mailbox): array { - // check if flag is RFC defined system flag - if (array_key_exists($flag, self::SYSTEM_FLAGS) === true) { - return self::SYSTEM_FLAGS[$flag]; - } - // check if server supports custom keywords / this specific keyword - try { - $capabilities = $client->status($mailbox, Horde_Imap_Client::STATUS_PERMFLAGS); - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException( - 'Could not get message flag options from IMAP: ' . $e->getMessage(), - $e->getCode(), - $e - ); - } - // check if server returned supported flags - if (!isset($capabilities['permflags'])) { - return []; - } - // check if server supports custom flags or specific flag - if (in_array("\*", $capabilities['permflags']) || in_array($flag, $capabilities['permflags'])) { - return [$flag]; - } - - return []; - } - - /** - * Check IMAP server for support for PERMANENTFLAGS - * - * @param Account $account - * @param string $mailbox - * @return boolean + * @throws ClientException */ - #[\Override] - public function isPermflagsEnabled(Horde_Imap_Client_Socket $client, Account $account, string $mailbox): bool { - try { - $capabilities = $client->status($mailbox, Horde_Imap_Client::STATUS_PERMFLAGS); - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException( - 'Could not get message flag options from IMAP: ' . $e->getMessage(), - $e->getCode(), - $e - ); - } - return (is_array($capabilities) === true && array_key_exists('permflags', $capabilities) === true && in_array("\*", $capabilities['permflags'], true) === true); - } - - #[\Override] public function createTag(string $displayName, string $color, string $userId): Tag { try { $imapLabel = $this->imapFlag->create($displayName); @@ -788,7 +536,11 @@ public function createTag(string $displayName, string $color, string $userId): T } try { - return $this->getTagByImapLabel($imapLabel, $userId); + try { + return $this->tagMapper->getTagByImapLabel($imapLabel, $userId); + } catch (DoesNotExistException $e) { + throw new ClientException('Unknown Tag', 0, $e); + } } catch (ClientException $e) { // it's valid that a tag does not exist. } @@ -803,7 +555,9 @@ public function createTag(string $displayName, string $color, string $userId): T return $this->tagMapper->insert($tag); } - #[\Override] + /** + * @throws ClientException + */ public function updateTag(int $id, string $displayName, string $color, string $userId): Tag { try { $tag = $this->tagMapper->getTagForUser($id, $userId); @@ -817,120 +571,55 @@ public function updateTag(int $id, string $displayName, string $color, string $u return $this->tagMapper->update($tag); } - #[\Override] - public function deleteTag(int $id, string $userId, array $accounts) :Tag { + /** + * @param int $id tag id + * @param string $userId user id of the tag owner + * @param Account[] $accounts accounts to remove the tag from + * + * @throws ClientException + * @throws ServiceException + */ + public function deleteTag(int $id, string $userId, array $accounts): Tag { try { $tag = $this->tagMapper->getTagForUser($id, $userId); } catch (DoesNotExistException $e) { throw new ClientException('Tag not found', 0, $e); } - foreach ($accounts as $account) { - $this->deleteTagForAccount($id, $userId, $tag, $account); - } - return $this->tagMapper->delete($tag); - } - - #[\Override] - public function deleteTagForAccount(int $id, string $userId, Tag $tag, Account $account) :void { - try { - $messageTags = $this->messageTagsMapper->getMessagesByTag($id); - $messages = array_merge(... array_map(fn ($messageTag) => $this->getByMessageId($account, $messageTag->getImapMessageId()), array_values($messageTags))); - } catch (DoesNotExistException $e) { - throw new ClientException('Messages not found', 0, $e); - } - - $client = $this->imapClientFactory->getClient($account); + // find all messages with this tag (independent of the account) + $messageTags = $this->messageTagsMapper->getMessagesByTag($id); - foreach ($messageTags as $messageTag) { - $this->messageTagsMapper->delete($messageTag); - } - $groupedMessages = []; - foreach ($messages as $message) { - $mailboxId = $message->getMailboxId(); - if (array_key_exists($mailboxId, $groupedMessages)) { - $groupedMessages[$mailboxId][] = $message; - } else { - $groupedMessages[$mailboxId] = [$message]; + foreach ($accounts as $account) { + try { + $messages = array_merge(... array_map(fn ($messageTag) => $this->getByMessageId($account, $messageTag->getImapMessageId()), array_values($messageTags))); + } catch (DoesNotExistException $e) { + throw new ClientException('Messages not found', 0, $e); } - } - try { - foreach ($groupedMessages as $mailboxId => $messages) { - $mailbox = $this->getMailbox($userId, $mailboxId); - $this->tagMessagesWithClient($client, $account, $mailbox, $messages, $tag, false); + if ($messages === []) { + continue; } - } finally { - $client->logout(); - } - } - - #[\Override] - public function moveThread(Account $srcAccount, Mailbox $srcMailbox, Account $dstAccount, Mailbox $dstMailbox, string $threadRootId): array { - $mailAccount = $srcAccount->getMailAccount(); - $messageInTrash = $srcMailbox->getId() === $mailAccount->getTrashMailboxId(); - $messages = $this->threadMapper->findMessageUidsAndMailboxNamesByAccountAndThreadRoot( - $mailAccount, - $threadRootId, - $messageInTrash - ); - - $newUids = []; - foreach ($messages as $message) { - $this->logger->debug('move message', [ - 'messageId' => $message['messageUid'], - 'srcMailboxId' => $srcMailbox->getId(), - 'dstMailboxId' => $dstMailbox->getId() - ]); - - $newUid = $this->moveMessage( - $srcAccount, - $message['mailboxName'], - $message['messageUid'], - $dstAccount, - $dstMailbox->getName() - ); - if ($newUid !== null) { - $newUids[] = $newUid; + // the connector removes keywords per mailbox, so group the messages accordingly + $messagesByMailbox = []; + foreach ($messages as $message) { + $messagesByMailbox[$message->getMailboxId()][] = $message; + } + foreach ($messagesByMailbox as $mailboxId => $mailboxMessages) { + try { + $mailbox = $this->mailboxMapper->findById($mailboxId); + } catch (DoesNotExistException $e) { + continue; + } + $this->tagMessages($account, $mailbox, $tag, false, ...$mailboxMessages); } } - return $newUids; - } - - /** - * @throws ClientException - * @throws ServiceException - */ - #[\Override] - public function deleteThread(Account $account, Mailbox $mailbox, string $threadRootId): void { - $mailAccount = $account->getMailAccount(); - $messageInTrash = $mailbox->getId() === $mailAccount->getTrashMailboxId(); - - $messages = $this->threadMapper->findMessageUidsAndMailboxNamesByAccountAndThreadRoot( - $mailAccount, - $threadRootId, - $messageInTrash - ); - foreach ($messages as $message) { - $this->logger->debug('deleting message', [ - 'messageId' => $message['messageUid'], - 'mailboxId' => $mailbox->getId(), - ]); - - $this->deleteMessage( - $account, - $message['mailboxName'], - $message['messageUid'] - ); + // update the local store + foreach ($messageTags as $messageTag) { + $this->messageTagsMapper->delete($messageTag); } - } - /** - * @return Message[] - */ - #[\Override] - public function getByMessageId(Account $account, string $messageId): array { - return $this->dbMessageMapper->findByMessageId($account, $messageId); + return $this->tagMapper->delete($tag); } + } diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php index c44dbde5da..d67155942b 100644 --- a/lib/Service/MailTransmission.php +++ b/lib/Service/MailTransmission.php @@ -30,7 +30,6 @@ use OCA\Mail\Account; use OCA\Mail\Address; use OCA\Mail\AddressList; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\Mailbox; @@ -42,9 +41,9 @@ use OCA\Mail\Events\SaveDraftEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper; use OCA\Mail\Model\NewMessageData; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\DataUri\DataUriParser; use OCA\Mail\SMTP\SmtpClientFactory; use OCA\Mail\Support\PerformanceLogger; @@ -61,7 +60,7 @@ class MailTransmission implements IMailTransmission { ]; public function __construct( - private IMAPClientFactory $imapClientFactory, + private ProtocolFactory $protocolFactory, private SmtpClientFactory $smtpClientFactory, private IEventDispatcher $eventDispatcher, private MailboxMapper $mailboxMapper, @@ -70,7 +69,7 @@ public function __construct( private PerformanceLogger $performanceLogger, private AliasesService $aliasesService, private TransmissionService $transmissionService, - private IMailManager $mailManager, + private MailManager $mailManager, ) { } @@ -252,7 +251,7 @@ public function saveLocalDraft(Account $account, LocalMessage $message): void { $perfLogger->step('build local draft message'); // Use a null transport to trigger MIME body encoding without sending - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $mail->send(new Horde_Mail_Transport_Null(), false, false); $perfLogger->step('create IMAP draft message'); @@ -334,7 +333,7 @@ public function saveDraft(NewMessageData $message, ?Message $previousDraft = nul $perfLogger->step('build draft message'); // Use a null transport to trigger MIME body encoding without sending - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $mail->send(new Horde_Mail_Transport_Null(), false, false); $perfLogger->step('create IMAP message'); @@ -402,7 +401,7 @@ public function sendMdn(Account $account, Mailbox $mailbox, Message $message): v 'peek' => true, ]); - $imapClient = $this->imapClientFactory->getClient($account); + $imapClient = $this->protocolFactory->imapClient($account); try { /** @var Horde_Imap_Client_Data_Fetch[] $fetchResults */ $fetchResults = iterator_to_array($imapClient->fetch($mailbox->getName(), $query, [ diff --git a/lib/Service/OutboxService.php b/lib/Service/OutboxService.php index f4aee70355..530f3218ab 100644 --- a/lib/Service/OutboxService.php +++ b/lib/Service/OutboxService.php @@ -10,14 +10,13 @@ namespace OCA\Mail\Service; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Db\Recipient; use OCA\Mail\Events\OutboxMessageCreatedEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Send\Chain; use OCA\Mail\Service\Attachment\AttachmentService; use OCP\AppFramework\Db\DoesNotExistException; @@ -39,8 +38,8 @@ public function __construct( private LocalMessageMapper $mapper, private AttachmentService $attachmentService, IEventDispatcher $eventDispatcher, - private IMAPClientFactory $clientFactory, - private IMailManager $mailManager, + private ProtocolFactory $protocolFactory, + private MailManager $mailManager, private AccountService $accountService, ITimeFactory $timeFactory, private LoggerInterface $logger, @@ -118,7 +117,7 @@ public function saveMessage(Account $account, LocalMessage $message, array $to, return $message; } - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); } finally { @@ -150,7 +149,7 @@ public function updateMessage(Account $account, LocalMessage $message, array $to return $message; } - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); } finally { diff --git a/lib/Service/Search/MailSearch.php b/lib/Service/Search/MailSearch.php index 6212fa5214..aacb4d3046 100644 --- a/lib/Service/Search/MailSearch.php +++ b/lib/Service/Search/MailSearch.php @@ -20,7 +20,7 @@ use OCA\Mail\Exception\MailboxNotCachedException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\IMAP\PreviewEnhancer; -use OCA\Mail\IMAP\Search\Provider as ImapSearchProvider; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IUser; @@ -31,7 +31,7 @@ class MailSearch implements IMailSearch { public function __construct( private FilterStringParser $filterStringParser, - private ImapSearchProvider $imapSearchProvider, + private MailManager $mailManager, private MessageMapper $messageMapper, private PreviewEnhancer $previewEnhancer, ITimeFactory $timeFactory, @@ -140,7 +140,7 @@ private function getIdsLocally(Account $account, Mailbox $mailbox, SearchQuery $ return $this->messageMapper->findIdsByQuery($mailbox, $query, $sortOrder, $limit); } - $fromImap = $this->imapSearchProvider->findMatches( + $fromImap = $this->mailManager->findMessages( $account, $mailbox, $query diff --git a/lib/Service/SetupService.php b/lib/Service/SetupService.php index beb857e560..e7ce92aec2 100644 --- a/lib/Service/SetupService.php +++ b/lib/Service/SetupService.php @@ -18,7 +18,7 @@ use OCA\Mail\Db\TagMapper; use OCA\Mail\Exception\CouldNotConnectException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\SMTP\SmtpClientFactory; use OCP\Security\ICrypto; use Psr\Log\LoggerInterface; @@ -32,7 +32,7 @@ public function __construct( private AccountService $accountService, ICrypto $crypto, private SmtpClientFactory $smtpClientFactory, - private IMAPClientFactory $imapClientFactory, + private ProtocolFactory $protocolFactory, private LoggerInterface $logger, private TagMapper $tagMapper, ) { @@ -109,7 +109,7 @@ public function createNewAccount(string $accountName, protected function testConnectivity(Account $account): void { $mailAccount = $account->getMailAccount(); - $imapClient = $this->imapClientFactory->getClient($account); + $imapClient = $this->protocolFactory->imapClient($account); try { $imapClient->login(); } catch (Horde_Imap_Client_Exception $e) { diff --git a/lib/Service/SnoozeService.php b/lib/Service/SnoozeService.php index 56426923fa..0fb224be01 100644 --- a/lib/Service/SnoozeService.php +++ b/lib/Service/SnoozeService.php @@ -10,7 +10,6 @@ namespace OCA\Mail\Service; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; @@ -20,7 +19,6 @@ use OCA\Mail\Db\MessageSnoozeMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\DB\Exception; @@ -32,12 +30,11 @@ class SnoozeService { public function __construct( private ITimeFactory $time, private LoggerInterface $logger, - private IMAPClientFactory $clientFactory, private MessageMapper $messageMapper, private MessageSnoozeMapper $messageSnoozeMapper, private MailAccountMapper $accountMapper, private MailboxMapper $mailboxMapper, - private IMailManager $mailManager, + private MailManager $mailManager, private AccountService $accountService, ) { } @@ -87,10 +84,10 @@ public function snoozeMessage( ): void { $newUid = $this->mailManager->moveMessage( $srcAccount, - $srcMailbox->getName(), - $message->getUid(), + $srcMailbox, + $message, $dstAccount, - $dstMailbox->getName() + $dstMailbox ); // TODO: This is bad - we should handle this case more gracefully! @@ -127,21 +124,24 @@ public function unSnoozeMessage( $originalMailboxName = 'INBOX'; + $originalMailbox = null; if ($originalMailboxId !== null) { try { $originalMailbox = $this->mailboxMapper->findById($originalMailboxId); - $originalMailboxName = $originalMailbox->getName(); } catch (DoesNotExistException $e) { // Could not find mailbox, moving back to INBOX } } + if ($originalMailbox === null) { + $originalMailbox = $this->mailboxMapper->find($srcAccount, $originalMailboxName); + } $this->mailManager->moveMessage( $srcAccount, - $snoozedMailbox->getName(), - $message->getUid(), + $snoozedMailbox, + $message, $srcAccount, - $originalMailboxName + $originalMailbox ); $this->messageSnoozeMapper->deleteByMailboxIdAndUid( @@ -204,24 +204,27 @@ public function unSnoozeThread( $originalMailboxName = 'INBOX'; + $originalMailbox = null; if ($originalMailboxId !== null) { try { $originalMailbox = $this->mailboxMapper->findById($originalMailboxId); - $originalMailboxName = $originalMailbox->getName(); } catch (DoesNotExistException $e) { // Could not find mailbox, moving back to INBOX } } + if ($originalMailbox === null) { + $originalMailbox = $this->mailboxMapper->find($srcAccount, $originalMailboxName); + } $messages = $this->messageMapper->findThread($srcAccount, $selectedMessage->getThreadRootId()); foreach ($messages as $message) { $this->mailManager->moveMessage( $srcAccount, - $snoozedMailbox->getName(), - $message->getUid(), + $snoozedMailbox, + $message, $srcAccount, - $originalMailboxName + $originalMailbox ); $this->messageSnoozeMapper->deleteByMailboxIdAndUid( @@ -301,42 +304,40 @@ private function wakeMessagesByAccount(Account $account): void { return; } - $client = $this->clientFactory->getClient($account); - try { - foreach ($messages as $message) { - $srcMailboxId = $this->messageSnoozeMapper->getSrcMailboxId( - $message->getMailboxId(), - $message->getUid(), - ); - - $srcMailboxName = 'INBOX'; - - if ($srcMailboxId !== null) { - try { - $srcMailbox = $this->mailboxMapper->findById($srcMailboxId); - $srcMailboxName = $srcMailbox->getName(); - } catch (DoesNotExistException $e) { - // Could not find mailbox, moving back to INBOX - } + foreach ($messages as $message) { + $srcMailboxId = $this->messageSnoozeMapper->getSrcMailboxId( + $message->getMailboxId(), + $message->getUid(), + ); + + $srcMailboxName = 'INBOX'; + + $srcMailbox = null; + if ($srcMailboxId !== null) { + try { + $srcMailbox = $this->mailboxMapper->findById($srcMailboxId); + } catch (DoesNotExistException $e) { + // Could not find mailbox, moving back to INBOX } + } + if ($srcMailbox === null) { + $srcMailbox = $this->mailboxMapper->find($account, $srcMailboxName); + } - $this->mailManager->flagMessage($account, $snoozeMailbox->getName(), $message->getUid(), 'seen', false); + $this->mailManager->flagMessages($account, $snoozeMailbox, 'seen', false, $message); - $this->mailManager->moveMessage( - $account, - $snoozeMailbox->getName(), - $message->getUid(), - $account, - $srcMailboxName - ); + $this->mailManager->moveMessage( + $account, + $snoozeMailbox, + $message, + $account, + $srcMailbox + ); - $this->messageSnoozeMapper->deleteByMailboxIdAndUid( - $message->getMailboxId(), - $message->getUid(), - ); - } - } finally { - $client->logout(); + $this->messageSnoozeMapper->deleteByMailboxIdAndUid( + $message->getMailboxId(), + $message->getUid(), + ); } } diff --git a/lib/Service/Sync/ImapToDbSynchronizer.php b/lib/Service/Sync/ImapToDbSynchronizer.php index 31d679696a..3efa591552 100644 --- a/lib/Service/Sync/ImapToDbSynchronizer.php +++ b/lib/Service/Sync/ImapToDbSynchronizer.php @@ -14,7 +14,6 @@ use Horde_Imap_Client_Exception; use Horde_Imap_Client_Ids; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\MessageMapper as DatabaseMessageMapper; @@ -29,12 +28,13 @@ use OCA\Mail\Exception\MailboxNotCachedException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Exception\UidValidityChangedException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; use OCA\Mail\IMAP\Sync\Request; use OCA\Mail\IMAP\Sync\Synchronizer; use OCA\Mail\Model\IMAPMessage; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Classification\NewMessagesClassifier; +use OCA\Mail\Service\MailManager; use OCA\Mail\Support\PerformanceLogger; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; @@ -55,7 +55,7 @@ class ImapToDbSynchronizer { public function __construct( private DatabaseMessageMapper $dbMapper, - private IMAPClientFactory $clientFactory, + private ProtocolFactory $protocolFactory, private ImapMessageMapper $imapMapper, private MailboxMapper $mailboxMapper, DatabaseMessageMapper $messageMapper, @@ -63,7 +63,7 @@ public function __construct( IEventDispatcher $dispatcher, private PerformanceLogger $performanceLogger, private LoggerInterface $logger, - private IMailManager $mailManager, + private MailManager $mailManager, private TagMapper $tagMapper, private NewMessagesClassifier $newMessagesClassifier, ) { @@ -84,7 +84,7 @@ public function syncAccount(Account $account, $sentMailboxId = $account->getMailAccount()->getSentMailboxId(); $trashRetentionDays = $account->getMailAccount()->getTrashRetentionDays(); - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); foreach ($this->mailboxMapper->findAll($account) as $mailbox) { $syncTrash = $trashMailboxId === $mailbox->getId() && $trashRetentionDays !== null; @@ -304,7 +304,7 @@ private function runInitialSync( // every iteration of the findAll loop, causing a memory leak on large // mailboxes (commit e50c214ff). Do NOT logout $client — the caller owns // it and we need it below for getSyncToken. - $noCacheClient = $this->clientFactory->getClient($account, false); + $noCacheClient = $this->protocolFactory->imapClient($account, false); try { $highestKnownUid = $this->dbMapper->findHighestUid($mailbox); try { @@ -466,7 +466,7 @@ private function runPartialSync( ); $perf->step('get changed messages via Horde'); - $permflagsEnabled = $this->mailManager->isPermflagsEnabled($client, $account, $mailbox->getName()); + $permflagsEnabled = $this->mailManager->isPermflagsEnabled($account, $mailbox); foreach (array_chunk($response->getChangedMessages(), 500) as $chunk) { $this->dbMapper->updateBulk($account, $permflagsEnabled, ...array_map(static fn (IMAPMessage $imapMessage) => $imapMessage->toDbMessage($mailbox->getId(), $account->getMailAccount()), $chunk)); @@ -536,7 +536,7 @@ public function repairSync( ); // Need to use a client without a cache here (to disable QRESYNC entirely) - $client = $this->clientFactory->getClient($account, false); + $client = $this->protocolFactory->imapClient($account, false); try { $knownUids = $this->dbMapper->findAllUids($mailbox); $hordeMailbox = new \Horde_Imap_Client_Mailbox($mailbox->getName()); diff --git a/lib/Service/Sync/SyncService.php b/lib/Service/Sync/SyncService.php index 2dd31103f3..03c834d443 100644 --- a/lib/Service/Sync/SyncService.php +++ b/lib/Service/Sync/SyncService.php @@ -18,10 +18,10 @@ use OCA\Mail\Exception\MailboxLockedException; use OCA\Mail\Exception\MailboxNotCachedException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MailboxSync; use OCA\Mail\IMAP\PreviewEnhancer; use OCA\Mail\IMAP\Sync\Response; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Search\FilterStringParser; use OCA\Mail\Service\Search\SearchQuery; use Psr\Log\LoggerInterface; @@ -31,7 +31,7 @@ class SyncService { public function __construct( - private IMAPClientFactory $clientFactory, + private ProtocolFactory $protocolFactory, private ImapToDbSynchronizer $synchronizer, private FilterStringParser $filterStringParser, private MessageMapper $messageMapper, @@ -48,9 +48,8 @@ public function __construct( * @throws MailboxLockedException * @throws ServiceException */ - public function clearCache(Account $account, - Mailbox $mailbox): void { - $this->synchronizer->clearCache($account, $mailbox); + public function clearCache(Account $account, Mailbox $mailbox): void { + $this->protocolFactory->messageConnector($account)->clearCache($account, $mailbox); } /** @@ -60,7 +59,7 @@ public function clearCache(Account $account, * @throws ServiceException */ public function repairSync(Account $account, Mailbox $mailbox): void { - $this->synchronizer->repairSync($account, $mailbox, $this->logger); + $this->protocolFactory->messageConnector($account)->repairSync($account, $mailbox); } /** @@ -84,26 +83,26 @@ public function syncMailbox(Account $account, ?int $lastMessageTimestamp, ?array $knownIds = null, string $sortOrder = IMailSearch::ORDER_NEWEST_FIRST, - ?string $filter = null): Response { + ?string $filter = null, + ): Response { if ($partialOnly && !$mailbox->isCached()) { throw MailboxNotCachedException::from($mailbox); } - $client = $this->clientFactory->getClient($account); - - $this->synchronizer->sync( - $account, - $client, - $mailbox, - $this->logger, - $criteria, - $knownIds === null ? null : $this->messageMapper->findUidsForIds($mailbox, $knownIds), - !$partialOnly - ); - - $this->mailboxSync->syncStats($client, $mailbox); - - $client->logout(); + $this->protocolFactory + ->mailboxConnector($account) + ->syncOne($account, $mailbox); + + $this->protocolFactory + ->messageConnector($account) + ->syncMailbox( + $account, + $mailbox, + $this->logger, + $criteria, + $knownIds === null ? null : $this->messageMapper->findUidsForIds($mailbox, $knownIds), + !$partialOnly, + ); $query = $filter === null ? null : $this->filterStringParser->parse($filter); return $this->getDatabaseSyncChanges( diff --git a/lib/SetupChecks/MailConnectionPerformance.php b/lib/SetupChecks/MailConnectionPerformance.php index ec764e1216..aff68314a9 100644 --- a/lib/SetupChecks/MailConnectionPerformance.php +++ b/lib/SetupChecks/MailConnectionPerformance.php @@ -13,7 +13,7 @@ use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Db\ProvisioningMapper; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\IL10N; use OCP\SetupCheck\ISetupCheck; use OCP\SetupCheck\SetupResult; @@ -26,7 +26,7 @@ public function __construct( private LoggerInterface $logger, private ProvisioningMapper $provisioningMapper, private MailAccountMapper $accountMapper, - private IMAPClientFactory $clientFactory, + private ProtocolFactory $protocolFactory, private MicroTime $microtime, ) { } @@ -59,7 +59,7 @@ public function run(): SetupResult { foreach ($collection as $accountId) { $account = new Account($this->accountMapper->findById((int)$accountId)); try { - $client = $this->clientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); } catch (ServiceException $e) { $this->logger->warning('Error occurred while getting IMAP client for setup check: ' . $e->getMessage(), [ 'exception' => $e, diff --git a/tests/Integration/Connector/AbstractMailboxConnectorTest.php b/tests/Integration/Connector/AbstractMailboxConnectorTest.php new file mode 100644 index 0000000000..0311668b1f --- /dev/null +++ b/tests/Integration/Connector/AbstractMailboxConnectorTest.php @@ -0,0 +1,117 @@ +mailboxMapper = Server::get(MailboxMapper::class); + $this->connector = $this->createConnector(); + $this->account = $this->createAccount(); + $this->connector->syncAll($this->account, true); + } + + /** Unique per test run so leftover server state never collides. */ + private function uniqueName(string $prefix): string { + return $prefix . '-' . uniqid(); + } + + /** @return string[] */ + private function localMailboxNames(): array { + return array_map( + static fn (Mailbox $mailbox): string => $mailbox->getName(), + $this->mailboxMapper->findAll($this->account), + ); + } + + public function testSyncListsTheInbox(): void { + $inboxes = array_filter( + $this->mailboxMapper->findAll($this->account), + static fn (Mailbox $mailbox): bool => $mailbox->isInbox(), + ); + + self::assertNotEmpty($inboxes, 'Synced mailboxes should include the inbox'); + } + + public function testCreateMailboxAppearsLocally(): void { + $name = $this->uniqueName('Created'); + + $created = $this->connector->create($this->account, $name); + + self::assertSame($name, $created->getName()); + self::assertContains($name, $this->localMailboxNames()); + + $this->connector->delete($this->account, $created); + } + + public function testDeleteMailboxRemovesItLocally(): void { + $name = $this->uniqueName('Doomed'); + $created = $this->connector->create($this->account, $name); + + $this->connector->delete($this->account, $created); + + self::assertNotContains($name, $this->localMailboxNames()); + $this->expectException(DoesNotExistException::class); + $this->mailboxMapper->find($this->account, $name); + } + + public function testRenameMailbox(): void { + $name = $this->uniqueName('Before'); + $newName = $this->uniqueName('After'); + $created = $this->connector->create($this->account, $name); + + $renamed = $this->connector->rename($this->account, $created, $newName); + + self::assertSame($newName, $renamed->getName()); + $names = $this->localMailboxNames(); + self::assertContains($newName, $names); + self::assertNotContains($name, $names); + + $this->connector->delete($this->account, $renamed); + } + + public function testSubscribeAndUnsubscribe(): void { + $name = $this->uniqueName('Sub'); + $created = $this->connector->create($this->account, $name); + + $unsubscribed = $this->connector->subscribe($this->account, $created, false); + self::assertStringNotContainsString('\\subscribed', $unsubscribed->getAttributes() ?? ''); + + $subscribed = $this->connector->subscribe($this->account, $unsubscribed, true); + self::assertStringContainsString('\\subscribed', $subscribed->getAttributes() ?? ''); + + $this->connector->delete($this->account, $subscribed); + } +} diff --git a/tests/Integration/Connector/ImapMailboxConnectorTest.php b/tests/Integration/Connector/ImapMailboxConnectorTest.php new file mode 100644 index 0000000000..835eb27add --- /dev/null +++ b/tests/Integration/Connector/ImapMailboxConnectorTest.php @@ -0,0 +1,30 @@ +createTestAccount()); + } +} diff --git a/tests/Integration/Connector/JmapMailboxConnectorTest.php b/tests/Integration/Connector/JmapMailboxConnectorTest.php new file mode 100644 index 0000000000..165270541c --- /dev/null +++ b/tests/Integration/Connector/JmapMailboxConnectorTest.php @@ -0,0 +1,28 @@ +createTestAccount()); + } +} diff --git a/tests/Integration/Db/TagMapperTest.php b/tests/Integration/Db/TagMapperTest.php new file mode 100644 index 0000000000..88d9b14c9d --- /dev/null +++ b/tests/Integration/Db/TagMapperTest.php @@ -0,0 +1,54 @@ +db = \OCP\Server::get(IDBConnection::class); + $this->mapper = new TagMapper( + $this->db, + $this->createMock(IL10N::class), + ); + + $qb = $this->db->getQueryBuilder(); + $qb->delete('mail_message_tags')->executeStatement(); + $qb->delete($this->mapper->getTableName())->executeStatement(); + } + + public function testTagMessageSetsUserIdWhenInsertingNewTag(): void { + $tag = new Tag(); + $tag->setImapLabel('project-x'); + $tag->setDisplayName('project-x'); + $tag->setColor(''); + $tag->setIsDefaultTag(false); + + $this->mapper->tagMessage($tag, '', 'sync-user'); + + $storedTag = $this->mapper->getTagByImapLabel('project-x', 'sync-user'); + + self::assertSame('sync-user', $storedTag->getUserId()); + self::assertSame('project-x', $storedTag->getImapLabel()); + } +} diff --git a/tests/Integration/Framework/JmapTestAccount.php b/tests/Integration/Framework/JmapTestAccount.php index 6de1eaecba..9f35d7481d 100644 --- a/tests/Integration/Framework/JmapTestAccount.php +++ b/tests/Integration/Framework/JmapTestAccount.php @@ -31,8 +31,8 @@ public function createTestAccount(?string $userId = null): MailAccount { $mailAccount->setName('Tester'); $mailAccount->setEmail('user@example.com'); $mailAccount->setProtocol(MailAccount::PROTOCOL_JMAP); - $mailAccount->setInboundHost('127.0.0.1'); - $mailAccount->setInboundPort(10080); + $mailAccount->setInboundHost('stalwart'); + $mailAccount->setInboundPort(8080); $mailAccount->setInboundSslMode('none'); $mailAccount->setInboundUser('user@example.com'); $mailAccount->setInboundPassword(Server::get(ICrypto::class)->encrypt('mypassword')); diff --git a/tests/Integration/IMAP/MessageMapperTest.php b/tests/Integration/IMAP/MessageMapperTest.php index d8e2fbd7e4..6bde410533 100644 --- a/tests/Integration/IMAP/MessageMapperTest.php +++ b/tests/Integration/IMAP/MessageMapperTest.php @@ -13,9 +13,9 @@ use Horde_Imap_Client; use Horde_Imap_Client_Exception; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\MessageMapper; use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\Sync\SyncService; use OCA\Mail\Tests\Integration\Framework\ImapTest; use OCA\Mail\Tests\Integration\Framework\ImapTestAccount; @@ -44,8 +44,8 @@ public function testTagging(): void { $imapMessageMapper = Server::get(ImapMessageMapper::class); /** @var MessageMapper $messageMapper */ $messageMapper = Server::get(MessageMapper::class); - /** @var IMailManager $mailManager */ - $mailManager = Server::get(IMailManager::class); + /** @var MailManager $mailManager */ + $mailManager = Server::get(MailManager::class); $mailBoxes = $mailManager->getMailboxes(new Account($account)); $inbox = null; foreach ($mailBoxes as $mailBox) { @@ -126,8 +126,8 @@ public function testGetFlagged(): void { $account = $this->createTestAccount(); /** @var ImapMessageMapper $messageMapper */ $imapMessageMapper = Server::get(ImapMessageMapper::class); - /** @var IMailManager $mailManager */ - $mailManager = Server::get(IMailManager::class); + /** @var MailManager $mailManager */ + $mailManager = Server::get(MailManager::class); $mailBoxes = $mailManager->getMailboxes(new Account($account)); $inbox = null; foreach ($mailBoxes as $mailBox) { diff --git a/tests/Integration/MailboxSynchronizationTest.php b/tests/Integration/MailboxSynchronizationTest.php index ba7c5b9a24..59b676b9fb 100644 --- a/tests/Integration/MailboxSynchronizationTest.php +++ b/tests/Integration/MailboxSynchronizationTest.php @@ -12,11 +12,12 @@ use Horde_Imap_Client; use Horde_Imap_Client_Socket; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Controller\MailboxesController; +use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\MessageMapper as DbMessageMapper; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\DelegationService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\Sync\ImapToDbSynchronizer; use OCA\Mail\Service\Sync\SyncService; use OCA\Mail\Tests\Integration\Framework\ImapTest; @@ -48,11 +49,12 @@ protected function setUp(): void { Server::get(IRequest::class), Server::get(AccountService::class), $this->getTestAccountUserId(), - Server::get(IMailManager::class), + Server::get(MailManager::class), Server::get(SyncService::class), Server::get(IConfig::class), Server::get(ITimeFactory::class), Server::get(DelegationService::class), + Server::get(MailboxMapper::class), ); $this->account = $this->createTestAccount('user12345'); @@ -65,8 +67,8 @@ public function tearDown(): void { } public function testSyncEmptyMailbox() { - /** @var IMailManager $mailManager */ - $mailManager = Server::get(IMailManager::class); + /** @var MailManager $mailManager */ + $mailManager = Server::get(MailManager::class); $mailBoxes = $mailManager->getMailboxes(new Account($this->account)); $inbox = null; foreach ($mailBoxes as $mailBox) { @@ -105,8 +107,8 @@ public function testSyncEmptyMailbox() { public function testSyncNewMessage() { /** @var SyncService $syncService */ $syncService = Server::get(SyncService::class); - /** @var IMailManager $mailManager */ - $mailManager = Server::get(IMailManager::class); + /** @var MailManager $mailManager */ + $mailManager = Server::get(MailManager::class); $mailBoxes = $mailManager->getMailboxes(new Account($this->account)); $inbox = null; foreach ($mailBoxes as $mailBox) { @@ -153,8 +155,8 @@ public function testSyncChangedMessage() { ->to('user@domain.tld') ->finish(); $uid = $this->saveMessage($mailbox, $message, $this->account); - /** @var IMailManager $mailManager */ - $mailManager = Server::get(IMailManager::class); + /** @var MailManager $mailManager */ + $mailManager = Server::get(MailManager::class); $mailBoxes = $mailManager->getMailboxes(new Account($this->account)); $inbox = null; foreach ($mailBoxes as $mailBox) { @@ -194,8 +196,8 @@ public function testSyncVanishedMessage() { ->to('user@domain.tld') ->finish(); $uid = $this->saveMessage($mailbox, $message, $this->account); - /** @var IMailManager $mailManager */ - $mailManager = Server::get(IMailManager::class); + /** @var MailManager $mailManager */ + $mailManager = Server::get(MailManager::class); $mailBoxes = $mailManager->getMailboxes(new Account($this->account)); $inbox = null; foreach ($mailBoxes as $mailBox) { @@ -244,8 +246,8 @@ public function testUnsolicitedVanishedMessage() { ->subject('Msg 2') ->finish(); $uid2 = $this->saveMessage($mailbox, $message, $this->account); - /** @var IMailManager $mailManager */ - $mailManager = Server::get(IMailManager::class); + /** @var MailManager $mailManager */ + $mailManager = Server::get(MailManager::class); $mailBoxes = $mailManager->getMailboxes(new Account($this->account)); $inbox = null; foreach ($mailBoxes as $mailBox) { @@ -279,14 +281,12 @@ public function testUnsolicitedVanishedMessage() { self::assertCount(2, $dbMessageMapper->findAllUids($inbox)); // Receive unsolicited vanished uid - $client = $this->getClient($this->account); + $message = $dbMessageMapper->findByUids($inbox, [$uid2])[0]; $mailManager->getSource( - $client, new Account($this->account), - $mailbox, - $uid2, + $inbox, + $message, ); - $client->logout(); // Assert that the unsolicited change was synced to the db self::assertCount(1, $dbMessageMapper->findAllUids($inbox)); diff --git a/tests/Integration/Service/AntiSpamServiceIntegrationTest.php b/tests/Integration/Service/AntiSpamServiceIntegrationTest.php index b17ea36126..0a49f001ce 100644 --- a/tests/Integration/Service/AntiSpamServiceIntegrationTest.php +++ b/tests/Integration/Service/AntiSpamServiceIntegrationTest.php @@ -10,10 +10,10 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use Horde_Imap_Client; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\MessageMapper; use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; use OCA\Mail\Service\AntiSpamService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\Sync\SyncService; use OCA\Mail\Tests\Integration\Framework\ImapTest; use OCA\Mail\Tests\Integration\Framework\ImapTestAccount; @@ -51,8 +51,8 @@ public function testFlagJunkWithSpamReportActive(): void { $imapMessageMapper = Server::get(ImapMessageMapper::class); /** @var MessageMapper $messageMapper */ $messageMapper = Server::get(MessageMapper::class); - /** @var IMailManager $mailManager */ - $mailManager = Server::get(IMailManager::class); + /** @var MailManager $mailManager */ + $mailManager = Server::get(MailManager::class); $mailBoxes = $mailManager->getMailboxes(new Account($account)); $inbox = null; foreach ($mailBoxes as $mailBox) { @@ -79,15 +79,17 @@ public function testFlagJunkWithSpamReportActive(): void { null ); + $message = $messageMapper->findByUids($inbox, [$newUid])[0]; + // now we flag this message as junk - $mailManager->flagMessage(new Account($account), $inbox->getName(), $newUid, 'junk', true); + $mailManager->flagMessages(new Account($account), $inbox, 'junk', true, $message); // if everything runs through, we can assert the run has been fine, // but we can't really test if Listener and Transmission have actually sent the message $this->addToAssertionCount(1); // now we flag this message as not junk - $mailManager->flagMessage(new Account($account), $inbox->getName(), $newUid, 'notjunk', true); + $mailManager->flagMessages(new Account($account), $inbox, 'notjunk', true, $message); // same as before $this->addToAssertionCount(1); diff --git a/tests/Integration/Service/DraftServiceIntegrationTest.php b/tests/Integration/Service/DraftServiceIntegrationTest.php index 828fa89287..5f9e9d564e 100644 --- a/tests/Integration/Service/DraftServiceIntegrationTest.php +++ b/tests/Integration/Service/DraftServiceIntegrationTest.php @@ -12,18 +12,18 @@ use ChristophWurst\Nextcloud\Testing\TestUser; use OCA\Mail\Account; use OCA\Mail\Contracts\IAttachmentService; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\LocalAttachmentMapper; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Db\MailAccount; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\Attachment\AttachmentStorage; use OCA\Mail\Service\DraftsService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; use OCA\Mail\Tests\Integration\Framework\ImapTest; use OCA\Mail\Tests\Integration\Framework\ImapTestAccount; @@ -66,7 +66,7 @@ class DraftServiceIntegrationTest extends TestCase { /** @var IEventDispatcher */ private $eventDispatcher; - /** @var IMAPClientFactory */ + /** @var ProtocolFactory */ private $clientFactory; /** @var LocalMessageMapper */ @@ -92,7 +92,7 @@ protected function setUp(): void { $c = Server::get(ContainerInterface::class); $userContainer = $c->get(IServerContainer::class); $this->userFolder = $userContainer->getUserFolder($this->account->getUserId()); - $mailManager = Server::get(IMailManager::class); + $mailManager = Server::get(MailManager::class); $this->attachmentService = new AttachmentService( $this->userFolder, Server::get(LocalAttachmentMapper::class), @@ -109,7 +109,7 @@ protected function setUp(): void { $this->mapper = Server::get(LocalMessageMapper::class); $this->transmission = Server::get(IMailTransmission::class); $this->eventDispatcher = Server::get(IEventDispatcher::class); - $this->clientFactory = Server::get(IMAPClientFactory::class); + $this->clientFactory = Server::get(ProtocolFactory::class); $this->accountService = $this->createMock(AccountService::class); $this->timeFactory = $this->createMock(ITimeFactory::class); diff --git a/tests/Integration/Service/MailTransmissionIntegrationTest.php b/tests/Integration/Service/MailTransmissionIntegrationTest.php index ca124f63d4..70013d9cac 100644 --- a/tests/Integration/Service/MailTransmissionIntegrationTest.php +++ b/tests/Integration/Service/MailTransmissionIntegrationTest.php @@ -13,7 +13,6 @@ use OC; use OCA\Mail\Account; use OCA\Mail\Contracts\IAttachmentService; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; @@ -23,10 +22,10 @@ use OCA\Mail\Db\Message; use OCA\Mail\Db\Recipient; use OCA\Mail\Db\RecipientMapper; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MailboxSync; use OCA\Mail\IMAP\MessageMapper; use OCA\Mail\Model\NewMessageData; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Send\AntiAbuseHandler; use OCA\Mail\Send\Chain; use OCA\Mail\Send\CopySentMessageHandler; @@ -35,6 +34,7 @@ use OCA\Mail\Send\SentMailboxHandler; use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\Attachment\UploadedFile; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\MailTransmission; use OCA\Mail\Service\TransmissionService; use OCA\Mail\SMTP\SmtpClientFactory; @@ -129,10 +129,10 @@ protected function setUp(): void { Server::get(FlagRepliedMessageHandler::class), $this->attachmentService, $this->localMessageMapper, - Server::get(IMAPClientFactory::class), + Server::get(ProtocolFactory::class), ); - $this->transmission = new MailTransmission(Server::get(IMAPClientFactory::class), + $this->transmission = new MailTransmission(Server::get(ProtocolFactory::class), Server::get(SmtpClientFactory::class), Server::get(IEventDispatcher::class), Server::get(MailboxMapper::class), @@ -141,7 +141,7 @@ protected function setUp(): void { Server::get(PerformanceLogger::class), Server::get(AliasesService::class), Server::get(TransmissionService::class), - Server::get(IMailManager::class) + Server::get(MailManager::class) ); } diff --git a/tests/Integration/Service/OutboxServiceIntegrationTest.php b/tests/Integration/Service/OutboxServiceIntegrationTest.php index c392a656d6..0bef06dc14 100644 --- a/tests/Integration/Service/OutboxServiceIntegrationTest.php +++ b/tests/Integration/Service/OutboxServiceIntegrationTest.php @@ -13,18 +13,18 @@ use Horde_Imap_Client; use OCA\Mail\Account; use OCA\Mail\Contracts\IAttachmentService; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\LocalAttachmentMapper; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\MessageMapper; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Send\Chain; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\Attachment\AttachmentStorage; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\Sync\SyncService; use OCA\Mail\Tests\Integration\Framework\ImapTest; @@ -65,7 +65,7 @@ class OutboxServiceIntegrationTest extends TestCase { /** @var IEventDispatcher */ private $eventDispatcher; - /** @var IMAPClientFactory */ + /** @var ProtocolFactory */ private $clientFactory; /** @var LocalMessageMapper */ @@ -93,7 +93,7 @@ protected function setUp(): void { $c = Server::get(ContainerInterface::class); $userContainer = $c->get(IServerContainer::class); $this->userFolder = $userContainer->getUserFolder($this->account->getUserId()); - $mailManager = Server::get(IMailManager::class); + $mailManager = Server::get(MailManager::class); $this->attachmentService = new AttachmentService( $this->userFolder, Server::get(LocalAttachmentMapper::class), @@ -109,7 +109,7 @@ protected function setUp(): void { $this->client = $this->getClient($this->account); $this->mapper = Server::get(LocalMessageMapper::class); $this->eventDispatcher = Server::get(IEventDispatcher::class); - $this->clientFactory = Server::get(IMAPClientFactory::class); + $this->clientFactory = Server::get(ProtocolFactory::class); $this->accountService = Server::get(AccountService::class); $this->timeFactory = Server::get(ITimeFactory::class); $this->chain = Server::get(Chain::class); diff --git a/tests/Integration/Sync/ImapToDbSynchronizerTest.php b/tests/Integration/Sync/ImapToDbSynchronizerTest.php index 7ac1c7aa3e..7afc93a57c 100644 --- a/tests/Integration/Sync/ImapToDbSynchronizerTest.php +++ b/tests/Integration/Sync/ImapToDbSynchronizerTest.php @@ -11,9 +11,9 @@ use Horde_Imap_Client; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\MessageMapper as DbMessageMapper; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\Sync\ImapToDbSynchronizer; use OCA\Mail\Service\Sync\SyncService; use OCA\Mail\Tests\Integration\Framework\ImapTest; @@ -59,7 +59,7 @@ public function testRepairSync(): void { $uid3 = $this->saveMessage($mailbox, $message, $this->account); // Retrieve mailbox object - $mailManager = Server::get(IMailManager::class); + $mailManager = Server::get(MailManager::class); $mailBoxes = $mailManager->getMailboxes(new Account($this->account)); $inbox = null; foreach ($mailBoxes as $mailBox) { @@ -120,7 +120,7 @@ public function testRepairSyncNoopIfNoneVanished(): void { $uid3 = $this->saveMessage($mailbox, $message, $this->account); // Retrieve mailbox object - $mailManager = Server::get(IMailManager::class); + $mailManager = Server::get(MailManager::class); $mailBoxes = $mailManager->getMailboxes(new Account($this->account)); $inbox = null; foreach ($mailBoxes as $mailBox) { diff --git a/tests/Unit/BackgroundJob/ContextChat/SubmitContentJobTest.php b/tests/Unit/BackgroundJob/ContextChat/SubmitContentJobTest.php index 1581c1aeb2..375895c62a 100644 --- a/tests/Unit/BackgroundJob/ContextChat/SubmitContentJobTest.php +++ b/tests/Unit/BackgroundJob/ContextChat/SubmitContentJobTest.php @@ -21,7 +21,7 @@ use OCA\Mail\Db\MessageMapper; use OCA\Mail\Events\MessageDeletedEvent; use OCA\Mail\Events\NewMessagesSynchronized; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Exception\ClientException; use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\ContextChat\TaskService; @@ -63,9 +63,6 @@ class SubmitContentJobTest extends TestCase { /** @var ITimeFactory|MockObject */ private $time; - /** @var IMAPClientFactory|MockObject */ - private $imapClientFactory; - /** @var LoggerInterface|MockObject */ private $logger; @@ -87,7 +84,6 @@ protected function setUp(): void { $this->accountService = $this->createMock(AccountService::class); $this->mailManager = $this->createMock(MailManager::class); $this->messageMapper = $this->createMock(MessageMapper::class); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); $this->contextChatProvider = $this->createMock(ContextChatProvider::class); $this->contentManager = $this->createMock(IContentManager::class); $this->logger = $this->createMock(LoggerInterface::class); @@ -99,7 +95,6 @@ protected function setUp(): void { $this->accountService, $this->mailManager, $this->messageMapper, - $this->imapClientFactory, $this->contextChatProvider, $this->contentManager, $this->logger, @@ -174,8 +169,6 @@ public function testRunWithContextChat(): void { $message->setId(2); $message->setUid(2); $this->messageMapper->expects($this->once())->method('findByIds')->willReturn([$message]); - $client = $this->createMock(\Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once())->method('getClient')->willReturn($client); $imapMessage = $this->createMock(IMAPMessage::class); $this->mailManager->expects($this->once())->method('getImapMessage')->willReturn($imapMessage); $imapMessage->expects($this->once())->method('isEncrypted')->willReturn(false); @@ -184,7 +177,6 @@ public function testRunWithContextChat(): void { $imapMessage->expects($this->once())->method('getSubject')->willReturn('subject'); $sent = new \Horde_Imap_Client_DateTime('2025-01-01 00:00:00'); $imapMessage->expects($this->once())->method('getSentDate')->willReturn($sent); - $client->expects($this->once())->method('close'); $this->contextChatProvider->expects($this->once())->method('getAppId')->willReturn('mail'); $this->contextChatProvider->expects($this->once())->method('getId')->willReturn('mail'); $this->contentManager->expects($this->once())->method('submitContent'); @@ -264,10 +256,7 @@ public function testRunWithContextChatWithTimeout(): void { $this->accountService->expects($this->once())->method('findById')->with()->willReturn($account); $message = new Message(); $this->messageMapper->expects($this->once())->method('findByIds')->willReturn([$message]); - $client = $this->createMock(\Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once())->method('getClient')->willReturn($client); $this->mailManager->expects($this->never())->method('getImapMessage'); // will not get called because the job takes too long already - $client->expects($this->once())->method('close'); $this->submitContentJob->setLastRun(0); $this->submitContentJob->start($this->createMock(IJobList::class)); @@ -310,13 +299,56 @@ public function testRunWithContextChatWithEncryptedMessage(): void { $message->setId(1); $message->setUid(1); $this->messageMapper->expects($this->once())->method('findByIds')->willReturn([$message]); - $client = $this->createMock(\Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once())->method('getClient')->willReturn($client); $imapMessage = $this->createMock(IMAPMessage::class); $this->mailManager->expects($this->once())->method('getImapMessage')->willReturn($imapMessage); $imapMessage->expects($this->once())->method('isEncrypted')->willReturn(true); $imapMessage->expects($this->never())->method('getFullMessage'); - $client->expects($this->once())->method('close'); + + $this->submitContentJob->setLastRun(0); + $this->submitContentJob->start($this->createMock(IJobList::class)); + } + + public function testRunWithContextChatSkipsMessageVanishedFromRemote(): void { + $this->contentManager->expects($this->once()) + ->method('isContextChatAvailable') + ->willReturn(true); + $task = new Task(); + $task->setLastMessageId(0); + $task->setMailboxId(1); + $task->setId(1); + $this->taskService->expects($this->once())->method('findNext')->willReturn($task); + $mailbox = new Mailbox(); + $mailbox->setId(1); + $mailbox->setAccountId(5); + $this->mailboxMapper->expects($this->once())->method('findById')->willReturn($mailbox); + $this->time->expects($this->any())->method('getTime') + ->willReturn( + // returned when Job#start asks + 12 * 60 * 60, + 12 * 60 * 60, + // returned when filtering messages + ContextChatProvider::CONTEXT_CHAT_MESSAGE_MAX_AGE, + // returned before processing messages + 0, + // returned on first message + 0, + 0, + 0, + 0, + ); + $this->messageMapper->expects($this->once())->method('findIdsAfter') + ->with($mailbox, 0, 0, ContextChatProvider::CONTEXT_CHAT_IMPORT_MAX_ITEMS)->willReturn([1]); + $account = $this->createMock(Account::class); + $account->expects($this->any())->method('getUserId')->willReturn('user123'); + $this->accountService->expects($this->once())->method('findById')->with()->willReturn($account); + $message = new Message(); + $message->setId(1); + $message->setUid(1); + $this->messageMapper->expects($this->once())->method('findByIds')->willReturn([$message]); + $this->mailManager->expects($this->once())->method('getImapMessage') + ->willThrowException(new ClientException('Message not found on remote server')); + $this->contentManager->expects($this->never())->method('submitContent'); + $this->taskService->expects($this->once())->method('setLastMessage')->with($task->getMailboxId(), 1); $this->submitContentJob->setLastRun(0); $this->submitContentJob->start($this->createMock(IJobList::class)); @@ -331,7 +363,6 @@ public function testRunWithContextChatWithFindNextTaskException(): void { $this->taskService->expects($this->once())->method('findNext')->willThrowException(new \OCP\DB\Exception('An error')); $this->contentManager->expects($this->never())->method('submitContent'); - $this->imapClientFactory->expects($this->never())->method('getClient'); $this->submitContentJob->setLastRun(0); $this->submitContentJob->start($this->createMock(IJobList::class)); @@ -346,7 +377,6 @@ public function testRunWithContextChatWithFindNextTaskException2(): void { $this->taskService->expects($this->once())->method('findNext')->willThrowException(new DoesNotExistException('ERROR')); $this->contentManager->expects($this->never())->method('submitContent'); - $this->imapClientFactory->expects($this->never())->method('getClient'); $this->submitContentJob->setLastRun(0); $this->submitContentJob->start($this->createMock(IJobList::class)); @@ -369,7 +399,6 @@ public function testRunWithContextChatWithFindByIdException1(): void { $mailbox->setAccountId(5); $this->mailboxMapper->expects($this->once())->method('findById')->willThrowException(new \OCA\Mail\Exception\ServiceException()); $this->contentManager->expects($this->never())->method('submitContent'); - $this->imapClientFactory->expects($this->never())->method('getClient'); $this->submitContentJob->setLastRun(0); $this->submitContentJob->start($this->createMock(IJobList::class)); @@ -392,7 +421,6 @@ public function testRunWithContextChatWithFindByIdException2(): void { $mailbox->setAccountId(5); $this->mailboxMapper->expects($this->once())->method('findById')->willThrowException(new DoesNotExistException('ERROR')); $this->contentManager->expects($this->never())->method('submitContent'); - $this->imapClientFactory->expects($this->never())->method('getClient'); $this->submitContentJob->setLastRun(0); $this->submitContentJob->start($this->createMock(IJobList::class)); diff --git a/tests/Unit/BackgroundJob/SyncJobTest.php b/tests/Unit/BackgroundJob/SyncJobTest.php index 5977d3f4a8..ec7b49aedb 100644 --- a/tests/Unit/BackgroundJob/SyncJobTest.php +++ b/tests/Unit/BackgroundJob/SyncJobTest.php @@ -56,12 +56,9 @@ public function testAccountDoesntExist(): void { ->expects(self::once()) ->method('remove') ->with(SyncJob::class, ['accountId' => 123]); - $this->serviceMock->getParameter('mailboxSync') + $this->serviceMock->getParameter('protocolFactory') ->expects(self::never()) - ->method('sync'); - $this->serviceMock->getParameter('syncService') - ->expects(self::never()) - ->method('syncAccount'); + ->method('mailboxConnector'); $this->job->setArgument([ 'accountId' => 123, @@ -71,9 +68,8 @@ public function testAccountDoesntExist(): void { } public function testNoAuthentication(): void { - $mailAccount = $this->createConfiguredMock(MailAccount::class, [ - 'canAuthenticateImap' => false, - ]); + $mailAccount = new MailAccount(); + $mailAccount->setProtocol(MailAccount::PROTOCOL_IMAP); $account = $this->createMock(Account::class); $account->method('getId')->willReturn(123); $account->method('getUserId')->willReturn('user123'); @@ -91,12 +87,9 @@ public function testNoAuthentication(): void { $this->serviceMock->getParameter('userManager') ->expects(self::never()) ->method('get'); - $this->serviceMock->getParameter('mailboxSync') + $this->serviceMock->getParameter('protocolFactory') ->expects(self::never()) - ->method('sync'); - $this->serviceMock->getParameter('syncService') - ->expects(self::never()) - ->method('syncAccount'); + ->method('mailboxConnector'); $this->job->setArgument([ 'accountId' => 123, @@ -127,12 +120,9 @@ public function testUserDoesntExist(): void { ->expects(self::once()) ->method('debug') ->with('Account 123 of user user123 could not be found or was disabled, skipping background sync'); - $this->serviceMock->getParameter('mailboxSync') - ->expects(self::never()) - ->method('sync'); - $this->serviceMock->getParameter('syncService') + $this->serviceMock->getParameter('protocolFactory') ->expects(self::never()) - ->method('syncAccount'); + ->method('mailboxConnector'); $this->job->setArgument([ 'accountId' => 123, diff --git a/tests/Unit/Controller/AccountsControllerTest.php b/tests/Unit/Controller/AccountsControllerTest.php index f44fb623e1..873a4777eb 100644 --- a/tests/Unit/Controller/AccountsControllerTest.php +++ b/tests/Unit/Controller/AccountsControllerTest.php @@ -12,7 +12,6 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Controller\AccountsController; use OCA\Mail\Db\MailAccount; @@ -24,6 +23,7 @@ use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\DelegationService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\SetupService; use OCA\Mail\Service\Sync\SyncService; use OCP\AppFramework\Db\DoesNotExistException; @@ -75,7 +75,7 @@ class AccountsControllerTest extends TestCase { /** @var SetupService|MockObject */ private $setupService; - /** @var IMailManager|MockObject */ + /** @var MailManager|MockObject */ private $mailManager; /** @var SyncService|MockObject */ @@ -110,7 +110,7 @@ protected function setUp(): void { $this->aliasesService = $this->createMock(AliasesService::class); $this->transmission = $this->createMock(IMailTransmission::class); $this->setupService = $this->createMock(SetupService::class); - $this->mailManager = $this->createMock(IMailManager::class); + $this->mailManager = $this->createMock(MailManager::class); $this->syncService = $this->createMock(SyncService::class); $this->mailboxSync = $this->createMock(mailboxSync::class); $this->config = $this->createMock(IConfig::class); diff --git a/tests/Unit/Controller/MailboxesApiControllerTest.php b/tests/Unit/Controller/MailboxesApiControllerTest.php index 052d40c0ff..53e64c0473 100644 --- a/tests/Unit/Controller/MailboxesApiControllerTest.php +++ b/tests/Unit/Controller/MailboxesApiControllerTest.php @@ -11,7 +11,6 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Controller\MailboxesApiController; use OCA\Mail\Db\MailAccount; @@ -20,6 +19,7 @@ use OCA\Mail\Folder; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\DelegationService; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\IRequest; @@ -31,7 +31,7 @@ class MailboxesApiControllerTest extends TestCase { private MailboxesApiController $controller; private IRequest&MockObject $request; - private IMailManager|MockObject $mailManager; + private MailManager|MockObject $mailManager; private AccountService&MockObject $accountService; private MockObject|IMailSearch $mailSearch; private DelegationService&MockObject $delegationService; @@ -41,7 +41,7 @@ protected function setUp(): void { $this->request = $this->createMock(IRequest::class); $this->accountService = $this->createMock(AccountService::class); - $this->mailManager = $this->createMock(IMailManager::class); + $this->mailManager = $this->createMock(MailManager::class); $this->mailSearch = $this->createMock(IMailSearch::class); $this->delegationService = $this->createMock(DelegationService::class); $this->delegationService->method('resolveAccountUserId') diff --git a/tests/Unit/Controller/MailboxesControllerTest.php b/tests/Unit/Controller/MailboxesControllerTest.php index 8531b024af..b38bc7ad97 100644 --- a/tests/Unit/Controller/MailboxesControllerTest.php +++ b/tests/Unit/Controller/MailboxesControllerTest.php @@ -11,14 +11,15 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Controller\MailboxesController; use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Exception\NotImplemented; use OCA\Mail\Folder; use OCA\Mail\IMAP\MailboxStats; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\DelegationService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\Sync\SyncService; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Utility\ITimeFactory; @@ -39,7 +40,7 @@ class MailboxesControllerTest extends TestCase { /** @var string */ private $userId = 'john'; - /** @var IMailManager|MockObject */ + /** @var MailManager|MockObject */ private $mailManager; /** @var MailboxesController */ @@ -51,19 +52,21 @@ class MailboxesControllerTest extends TestCase { private IConfig|MockObject $config; private ITimeFactory|MockObject $timeFactory; private DelegationService|MockObject $delegationService; + private MailboxMapper|MockObject $mailboxMapper; public function setUp(): void { parent::setUp(); $this->request = $this->createMock(IRequest::class); $this->accountService = $this->createMock(AccountService::class); - $this->mailManager = $this->createMock(IMailManager::class); + $this->mailManager = $this->createMock(MailManager::class); $this->syncService = $this->createMock(SyncService::class); $this->config = $this->createMock(IConfig::class); $this->timeFactory = $this->createMock(ITimeFactory::class); $this->delegationService = $this->createMock(DelegationService::class); $this->delegationService->method('resolveAccountUserId')->willReturn($this->userId); $this->delegationService->method('resolveMailboxUserId')->willReturn($this->userId); + $this->mailboxMapper = $this->createMock(MailboxMapper::class); $this->controller = new MailboxesController( $this->appName, @@ -75,6 +78,7 @@ public function setUp(): void { $this->config, $this->timeFactory, $this->delegationService, + $this->mailboxMapper, ); } @@ -226,9 +230,9 @@ public function testPatchEnableSyncLogsDelegatedAction(): void { $this->accountService->expects($this->once()) ->method('find') ->willReturn($account); - $this->mailManager->expects($this->once()) - ->method('enableMailboxBackgroundSync') - ->with($mailbox, true) + $this->mailboxMapper->expects($this->once()) + ->method('update') + ->with($mailbox) ->willReturn($mailbox); $this->delegationService->expects($this->once()) ->method('logDelegatedAction') diff --git a/tests/Unit/Controller/MessageApiControllerTest.php b/tests/Unit/Controller/MessageApiControllerTest.php index 09fa39e1ca..0c76a7363b 100644 --- a/tests/Unit/Controller/MessageApiControllerTest.php +++ b/tests/Unit/Controller/MessageApiControllerTest.php @@ -21,7 +21,6 @@ use OCA\Mail\Exception\ServiceException; use OCA\Mail\Exception\SmimeDecryptException; use OCA\Mail\Exception\UploadException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Model\SmimeData; use OCA\Mail\Service\AccountService; @@ -52,7 +51,6 @@ class MessageApiControllerTest extends TestCase { private AttachmentService|MockObject $attachmentService; private OutboxService|MockObject $outboxService; private MailManager|MockObject $mailManager; - private IMAPClientFactory|MockObject $imapClientFactory; private LoggerInterface|MockObject $logger; private MockObject|ITimeFactory $time; private MockObject|IURLGenerator $urlGenerator; @@ -77,7 +75,6 @@ protected function setUp(): void { $this->attachmentService = $this->createMock(AttachmentService::class); $this->outboxService = $this->createMock(OutboxService::class); $this->mailManager = $this->createMock(MailManager::class); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); $this->logger = $this->createMock(LoggerInterface::class); $this->time = $this->createMock(ITimeFactory::class); $this->urlGenerator = $this->createMock(IURLGenerator::class); @@ -96,7 +93,6 @@ protected function setUp(): void { $this->attachmentService, $this->outboxService, $this->mailManager, - $this->imapClientFactory, $this->logger, $this->time, $this->urlGenerator, @@ -128,7 +124,6 @@ public function testGet(bool $encrypted, bool $signed, array $json): void { $message->setUid(1); $mailbox = new Mailbox(); $mailbox->setAccountId($this->accountId); - $client = $this->createMock(\Horde_Imap_Client_Socket::class); $imapMessage = $this->createMock(IMAPMessage::class); $this->logger->expects(self::never()) @@ -147,14 +142,9 @@ public function testGet(bool $encrypted, bool $signed, array $json): void { ->method('find') ->with($this->userId, $this->accountId) ->willReturn($this->account); - $this->imapClientFactory->expects(self::once()) - ->method('getClient') - ->willReturn($client); $this->mailManager->expects(self::once()) ->method('getImapMessage') ->willReturn($imapMessage); - $client->expects(self::once()) - ->method('logout'); $imapMessage->expects(self::once()) ->method('getFullMessage') ->with($this->messageId, true) @@ -242,7 +232,6 @@ public function testGetWithSmimeEncryptionFailed(): void { $message->setUid(1); $mailbox = new Mailbox(); $mailbox->setAccountId($this->accountId); - $client = $this->createMock(\Horde_Imap_Client_Socket::class); $imapMessage = $this->createMock(IMAPMessage::class); $smime = new SmimeData(); $smime->setIsEncrypted(true); @@ -270,19 +259,14 @@ public function testGetWithSmimeEncryptionFailed(): void { ->method('find') ->with($this->userId, $this->accountId) ->willReturn($this->account); - $this->imapClientFactory->expects(self::once()) - ->method('getClient') - ->willReturn($client); $this->mailManager->expects(self::exactly(2)) ->method('getImapMessage') - ->willReturnCallback(function ($client, $account, $mailbox, $uid, $loadBody) use ($imapMessage) { + ->willReturnCallback(function ($account, $mailbox, $message, $loadBody) use ($imapMessage) { if ($loadBody) { throw new SmimeDecryptException(); } return $imapMessage; }); - $client->expects(self::once()) - ->method('logout'); $imapMessage->expects(self::once()) ->method('getFullMessage') ->with($this->messageId, false) @@ -319,7 +303,6 @@ public function testGetWithSmimeException(): void { $message->setUid(1); $mailbox = new Mailbox(); $mailbox->setAccountId($this->accountId); - $client = $this->createMock(\Horde_Imap_Client_Socket::class); $this->logger->expects(self::never()) ->method('warning'); @@ -337,14 +320,9 @@ public function testGetWithSmimeException(): void { ->method('find') ->with($this->userId, $this->accountId) ->willReturn($this->account); - $this->imapClientFactory->expects(self::once()) - ->method('getClient') - ->willReturn($client); $this->mailManager->expects(self::once()) ->method('getImapMessage') ->willThrowException(new ServiceException()); - $client->expects(self::once()) - ->method('logout'); $this->itineraryService->expects(self::never()) ->method('getCached'); $this->trustedSenderService->expects(self::never()) @@ -381,8 +359,6 @@ public function testMailboxNotFound(): void { ->willThrowException(new ClientException('')); $this->accountService->expects(self::never()) ->method('find'); - $this->imapClientFactory->expects(self::never()) - ->method('getClient'); $this->mailManager->expects(self::never()) ->method('getImapMessage'); $this->itineraryService->expects(self::never()) diff --git a/tests/Unit/Controller/MessagesControllerTest.php b/tests/Unit/Controller/MessagesControllerTest.php index 1cf19b2221..8a8a4e63b9 100644 --- a/tests/Unit/Controller/MessagesControllerTest.php +++ b/tests/Unit/Controller/MessagesControllerTest.php @@ -18,7 +18,6 @@ use OCA\Mail\Account; use OCA\Mail\Attachment; use OCA\Mail\Contracts\IDkimService; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Contracts\ITrustedSenderService; @@ -33,7 +32,6 @@ use OCA\Mail\Exception\ServiceException; use OCA\Mail\Http\AttachmentDownloadResponse; use OCA\Mail\Http\HtmlResponse; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Model\Message; use OCA\Mail\Service\AccountService; @@ -124,8 +122,6 @@ class MessagesControllerTest extends TestCase { /** @var MockObject|SmimeService */ private $smimeService; - /** @var MockObject|IMAPClientFactory */ - private $clientFactory; private IDkimService $dkimService; /** @var MockObject|IUserPreferences */ @@ -145,7 +141,7 @@ protected function setUp(): void { $this->appName = 'mail'; $this->request = $this->getMockBuilder(IRequest::class)->getMock(); $this->accountService = $this->createMock(AccountService::class); - $this->mailManager = $this->createMock(IMailManager::class); + $this->mailManager = $this->createMock(MailManager::class); $this->mailSearch = $this->createMock(IMailSearch::class); $this->itineraryService = $this->createMock(ItineraryService::class); $this->userId = 'john'; @@ -159,7 +155,6 @@ protected function setUp(): void { $this->trustedSenderService = $this->createMock(ITrustedSenderService::class); $this->mailTransmission = $this->createMock(IMailTransmission::class); $this->smimeService = $this->createMock(SmimeService::class); - $this->clientFactory = $this->createMock(IMAPClientFactory::class); $this->dkimService = $this->createMock(IDkimService::class); $this->userPreferences = $this->createMock(IUserPreferences::class); $this->snoozeService = $this->createMock(SnoozeService::class); @@ -202,7 +197,6 @@ protected function setUp(): void { $this->trustedSenderService, $this->mailTransmission, $this->smimeService, - $this->clientFactory, $this->dkimService, $this->userPreferences, $this->snoozeService, @@ -249,16 +243,11 @@ public function testGetHtmlBody(): void { ->method('find') ->with($this->equalTo($this->userId), $this->equalTo($accountId)) ->will($this->returnValue($this->account)); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $imapMessage = $this->createStub(IMAPMessage::class); $this->mailManager->expects($this->exactly(2)) ->method('getImapMessage') - ->with($client, $this->account, $mailbox, 123, true) + ->with($this->account, $mailbox, $message, true) ->willReturn($imapMessage); - $this->clientFactory->expects($this->exactly(2)) - ->method('getClient') - ->with($this->account) - ->willReturn($client); $expectedPlainResponse = HtmlResponse::plain(''); $expectedPlainResponse->cacheFor(3600); @@ -700,8 +689,8 @@ public function testSetFlagsUnseen() { ->with($this->equalTo($this->userId), $this->equalTo($accountId)) ->will($this->returnValue($this->account)); $this->mailManager->expects($this->once()) - ->method('flagMessage') - ->with($this->account, 'INBOX', 444, 'seen', false); + ->method('flagMessages') + ->with($this->account, $mailbox, 'seen', false, $message); $this->delegationService->expects($this->once()) ->method('logDelegatedAction') ->with($this->userId, $this->userId, "$this->userId updated flags on message <$id> with [seen=false] on behalf of $this->userId"); @@ -739,9 +728,9 @@ public function testSetTagFailing() { ->with($this->equalTo($this->userId), $this->equalTo($accountId)) ->willThrowException(new DoesNotExistException('')); $this->mailManager->expects($this->never()) - ->method('getTagByImapLabel'); + ->method('getTagByLabel'); $this->mailManager->expects($this->never()) - ->method('tagMessage'); + ->method('tagMessages'); $this->controller->setTag($id, Tag::LABEL_IMPORTANT); } @@ -771,11 +760,11 @@ public function testSetTagNotFound() { ->with($this->equalTo($this->userId), $this->equalTo($accountId)) ->will($this->returnValue($this->account)); $this->mailManager->expects($this->once()) - ->method('getTagByImapLabel') + ->method('getTagByLabel') ->with($imapLabel, $this->userId) ->willThrowException(new ClientException('Computer says no')); $this->mailManager->expects($this->never()) - ->method('tagMessage'); + ->method('tagMessages'); $this->controller->setTag($id, $imapLabel); } @@ -806,12 +795,12 @@ public function testSetTag() { ->with($this->equalTo($this->userId), $this->equalTo($accountId)) ->will($this->returnValue($this->account)); $this->mailManager->expects($this->once()) - ->method('getTagByImapLabel') + ->method('getTagByLabel') ->with($tag->getImapLabel(), $this->userId) ->willReturn($tag); $this->mailManager->expects($this->once()) - ->method('tagMessage') - ->with($this->account, $mailbox->getName(), $message, $tag, true); + ->method('tagMessages') + ->with($this->account, $mailbox, $tag, true, $message); $this->delegationService->expects($this->once()) ->method('logDelegatedAction') ->with($this->userId, $this->userId, "$this->userId added tag <{$tag->getImapLabel()}> on message <$id> on behalf of $this->userId"); @@ -843,9 +832,9 @@ public function testRemoveTagFailing() { ->with($this->equalTo($this->userId), $this->equalTo($accountId)) ->willThrowException(new DoesNotExistException('')); $this->mailManager->expects($this->never()) - ->method('getTagByImapLabel'); + ->method('getTagByLabel'); $this->mailManager->expects($this->never()) - ->method('tagMessage'); + ->method('tagMessages'); $this->controller->removeTag($id, Tag::LABEL_IMPORTANT); } @@ -875,11 +864,11 @@ public function testRemoveTagNotFound() { ->with($this->equalTo($this->userId), $this->equalTo($accountId)) ->will($this->returnValue($this->account)); $this->mailManager->expects($this->once()) - ->method('getTagByImapLabel') + ->method('getTagByLabel') ->with($imapLabel, $this->userId) ->willThrowException(new ClientException('Computer says no')); $this->mailManager->expects($this->never()) - ->method('tagMessage'); + ->method('tagMessages'); $this->controller->removeTag($id, $imapLabel); } @@ -910,12 +899,12 @@ public function testRemoveTag() { ->with($this->equalTo($this->userId), $this->equalTo($accountId)) ->will($this->returnValue($this->account)); $this->mailManager->expects($this->once()) - ->method('getTagByImapLabel') + ->method('getTagByLabel') ->with($tag->getImapLabel(), $this->userId) ->willReturn($tag); $this->mailManager->expects($this->once()) - ->method('tagMessage') - ->with($this->account, $mailbox->getName(), $message, $tag, false); + ->method('tagMessages') + ->with($this->account, $mailbox, $tag, false, $message); $this->delegationService->expects($this->once()) ->method('logDelegatedAction') ->with($this->userId, $this->userId, "$this->userId removed tag <{$tag->getImapLabel()}> on message <$id> on behalf of $this->userId"); @@ -949,8 +938,8 @@ public function testSetFlagsFlagged() { ->with($this->equalTo($this->userId), $this->equalTo($accountId)) ->will($this->returnValue($this->account)); $this->mailManager->expects($this->once()) - ->method('flagMessage') - ->with($this->account, 'INBOX', 444, 'flagged', true); + ->method('flagMessages') + ->with($this->account, $mailbox, 'flagged', true, $message); $this->delegationService->expects($this->once()) ->method('logDelegatedAction') ->with($this->userId, $this->userId, "$this->userId updated flags on message <$id> with [flagged=true] on behalf of $this->userId"); @@ -988,7 +977,7 @@ public function testDestroy() { ->will($this->returnValue($this->account)); $this->mailManager->expects($this->once()) ->method('deleteMessage') - ->with($this->account, 'INBOX', 444); + ->with($this->account, $mailbox, $message); $this->delegationService->expects($this->once()) ->method('logDelegatedAction') ->with($this->userId, $this->userId, "$this->userId deleted message <$id> on behalf of $this->userId"); @@ -1051,7 +1040,7 @@ public function testDestroyWithFolderOrMessageNotFound() { ->will($this->returnValue($this->account)); $this->mailManager->expects($this->once()) ->method('deleteMessage') - ->with($this->account, 'INBOX', 444) + ->with($this->account, $mailbox, $message) ->willThrowException(new ServiceException()); $this->expectException(ServiceException::class); @@ -1177,14 +1166,10 @@ public function testExport() { ->with($this->equalTo($this->userId), $this->equalTo($accountId)) ->will($this->returnValue($this->account)); $source = file_get_contents(__DIR__ . '/../../data/mail-message-123.txt'); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $this->mailManager->expects($this->exactly(1)) ->method('getSource') - ->with($client, $this->account, $folderId, 123) + ->with($this->account, $mailbox, $message) ->willReturn($source); - $this->clientFactory->expects($this->once()) - ->method('getClient') - ->willReturn($client); $expectedResponse = new AttachmentDownloadResponse( $source, @@ -1225,10 +1210,9 @@ public function testSaveFile() { ->with($this->equalTo($this->userId), $this->equalTo($accountId)) ->will($this->returnValue($this->account)); $source = file_get_contents(__DIR__ . '/../../data/mail-message-123.txt'); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $this->mailManager->expects($this->exactly(1)) ->method('getSource') - ->with($client, $this->account, $folderId, 123) + ->with($this->account, $mailbox, $message) ->willReturn($source); $folderNode = $this->createStub(Folder::class); $this->userFolder->expects($this->once()) @@ -1246,9 +1230,6 @@ public function testSaveFile() { ->method('newFile') ->with('Downloads/core_master has new results.eml') ->will($this->returnValue($file)); - $this->clientFactory->expects($this->once()) - ->method('getClient') - ->willReturn($client); $expectedResponse = new JSONResponse(); $actualResponse = $this->controller->saveFile($messageId, $targetPath); @@ -1287,7 +1268,7 @@ public function testGetDkim() { ->will($this->returnValue($account)); $this->dkimService->expects($this->exactly(1)) ->method('validate') - ->with($account, $mailbox, $message->getUid()) + ->with($account, $mailbox, $message) ->willReturn(true); $actualResponse = $this->controller->getDkim($message->getId()); @@ -1413,7 +1394,6 @@ public function testSmartReplyNoUser(): void { $this->trustedSenderService, $this->mailTransmission, $this->smimeService, - $this->clientFactory, $this->dkimService, $this->userPreferences, $this->snoozeService, diff --git a/tests/Unit/Controller/ThreadControllerTest.php b/tests/Unit/Controller/ThreadControllerTest.php index fcbb1eb904..1dbb3dd669 100644 --- a/tests/Unit/Controller/ThreadControllerTest.php +++ b/tests/Unit/Controller/ThreadControllerTest.php @@ -11,7 +11,6 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Controller\ThreadController; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; @@ -22,6 +21,7 @@ use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\DelegationService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\SnoozeService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -42,7 +42,7 @@ class ThreadControllerTest extends TestCase { /** @var AccountService|MockObject */ private $accountService; - /** @var IMailManager|MockObject */ + /** @var MailManager|MockObject */ private $mailManager; /** @var SnoozeService|MockObject */ @@ -67,7 +67,7 @@ protected function setUp(): void { $this->request = $this->getMockBuilder(IRequest::class)->getMock(); $this->userId = 'john'; $this->accountService = $this->createMock(AccountService::class); - $this->mailManager = $this->createMock(IMailManager::class); + $this->mailManager = $this->createMock(MailManager::class); $this->snoozeService = $this->createMock(SnoozeService::class); $this->aiIntergrationsService = $this->createMock(AiIntegrationsService::class); $this->logger = $this->createMock(LoggerInterface::class); diff --git a/tests/Unit/Db/MailboxTest.php b/tests/Unit/Db/MailboxTest.php index 5d7aa17cbd..f1c4c76cea 100644 --- a/tests/Unit/Db/MailboxTest.php +++ b/tests/Unit/Db/MailboxTest.php @@ -23,8 +23,9 @@ protected function setUp(): void { public static function provideCacheBusterData(): array { return [ - ['new', 'changed', 'vanished', 'bbddae86e09069fc10c9f2ac401363b4'], - [null, null, null, 'dca1f7641c34734a8cd1c7b1c45abf73'], + ['new', 'changed', 'vanished', null, 'bbddae86e09069fc10c9f2ac401363b4'], + [null, null, null, null, 'dca1f7641c34734a8cd1c7b1c45abf73'], + [null, null, null, 'state', '3cc6800869b5dcd6495d309a4bd16273'], ]; } @@ -33,12 +34,14 @@ public function testGetCacheBuster( ?string $syncNewToken, ?string $syncChangedToken, ?string $syncVanishedToken, + ?string $state, string $expectedCacheBuster, ): void { $this->mailbox->setId(100); $this->mailbox->setSyncNewToken($syncNewToken); $this->mailbox->setSyncChangedToken($syncChangedToken); $this->mailbox->setSyncVanishedToken($syncVanishedToken); + $this->mailbox->setState($state); $this->assertEquals($expectedCacheBuster, $this->mailbox->getCacheBuster()); } @@ -48,12 +51,14 @@ public function testJsonSerializeCacheBuster( ?string $syncNewToken, ?string $syncChangedToken, ?string $syncVanishedToken, + ?string $state, string $expectedCacheBuster, ): void { $this->mailbox->setId(100); $this->mailbox->setSyncNewToken($syncNewToken); $this->mailbox->setSyncChangedToken($syncChangedToken); $this->mailbox->setSyncVanishedToken($syncVanishedToken); + $this->mailbox->setState($state); $this->mailbox->setName('INBOX'); $json = $this->mailbox->jsonSerialize(); @@ -61,6 +66,31 @@ public function testJsonSerializeCacheBuster( $this->assertEquals($expectedCacheBuster, $json['cacheBuster']); } + public static function provideCachedData(): array { + return [ + ['new', 'changed', 'vanished', null, true], + [null, null, null, 'state', true], + ['new', null, 'vanished', null, false], + [null, null, null, null, false], + ]; + } + + /** @dataProvider provideCachedData */ + public function testIsCached( + ?string $syncNewToken, + ?string $syncChangedToken, + ?string $syncVanishedToken, + ?string $state, + bool $expected, + ): void { + $this->mailbox->setSyncNewToken($syncNewToken); + $this->mailbox->setSyncChangedToken($syncChangedToken); + $this->mailbox->setSyncVanishedToken($syncVanishedToken); + $this->mailbox->setState($state); + + $this->assertSame($expected, $this->mailbox->isCached()); + } + public function testHasLocksIgnoresExpiredLock(): void { $this->mailbox->setSyncNewLock(1000); diff --git a/tests/Unit/IMAP/ImapMessageConnectorTest.php b/tests/Unit/IMAP/ImapMessageConnectorTest.php new file mode 100644 index 0000000000..224f098659 --- /dev/null +++ b/tests/Unit/IMAP/ImapMessageConnectorTest.php @@ -0,0 +1,183 @@ +protocolFactory = $this->createMock(ProtocolFactory::class); + $this->synchronizer = $this->createMock(ImapToDbSynchronizer::class); + $this->imapMailboxMapper = $this->createMock(FolderMapper::class); + $this->imapMessageMapper = $this->createMock(MessageMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->connector = new ImapMessageConnector( + $this->protocolFactory, + $this->synchronizer, + $this->imapMailboxMapper, + $this->imapMessageMapper, + $this->logger, + ); + + $this->account = $this->createMock(Account::class); + $this->client = $this->createMock(Horde_Imap_Client_Socket::class); + $this->protocolFactory->method('imapClient') + ->with($this->account) + ->willReturn($this->client); + } + + public function testMoveMessagesLogsOutClientWhenMapperThrows(): void { + $sourceMailbox = new Mailbox(); + $sourceMailbox->setName('INBOX'); + $targetMailbox = new Mailbox(); + $targetMailbox->setName('Archive'); + $message = new Message(); + $message->setUid(1); + + $this->imapMessageMapper->method('move') + ->willThrowException(new ServiceException('could not move')); + $this->client->expects(self::once()) + ->method('logout'); + + $this->expectException(ServiceException::class); + + $this->connector->moveMessages($this->account, $targetMailbox, $sourceMailbox, $message); + } + + public function testDeleteMessagesLogsOutClientWhenMapperThrows(): void { + $mailbox = new Mailbox(); + $mailbox->setName('INBOX'); + $message = new Message(); + $message->setUid(1); + + $this->imapMessageMapper->method('expunge') + ->willThrowException(new ServiceException('could not expunge')); + $this->client->expects(self::once()) + ->method('logout'); + + $this->expectException(ServiceException::class); + + $this->connector->deleteMessages($this->account, $mailbox, $message); + } + + public function testFlagMessagesLogsOutClientWhenMapperThrows(): void { + $mailbox = new Mailbox(); + $mailbox->setName('INBOX'); + $message = new Message(); + $message->setUid(1); + + $this->imapMessageMapper->method('addFlag') + ->willThrowException(new Horde_Imap_Client_Exception('store failed')); + $this->client->expects(self::once()) + ->method('logout'); + + $this->expectException(ServiceException::class); + + $this->connector->flagMessages($this->account, $mailbox, 'seen', true, $message); + } + + public function testTagMessagesLogsOutClientWhenPermflagsCheckThrows(): void { + $mailbox = new Mailbox(); + $mailbox->setName('INBOX'); + $tag = new Tag(); + $tag->setDisplayName('Important'); + $tag->setImapLabel('$important'); + $message = new Message(); + $message->setUid(1); + + $this->client->method('status') + ->willThrowException(new Horde_Imap_Client_Exception('status failed')); + $this->client->expects(self::once()) + ->method('logout'); + + $this->expectException(ServiceException::class); + + $this->connector->tagMessages($this->account, $mailbox, $tag, true, $message); + } + + public function testTagMessagesLogsOutClientWhenPermflagsNotSupported(): void { + $mailbox = new Mailbox(); + $mailbox->setName('INBOX'); + $tag = new Tag(); + $tag->setDisplayName('Important'); + $tag->setImapLabel('$important'); + $message = new Message(); + $message->setUid(1); + + $this->client->method('status') + ->willReturn(['permflags' => []]); + $this->client->expects(self::once()) + ->method('logout'); + + $result = $this->connector->tagMessages($this->account, $mailbox, $tag, true, $message); + + self::assertSame([], $result); + } + + public function testIsPermflagsEnabledLogsOutClientWhenStatusThrows(): void { + $mailbox = new Mailbox(); + $mailbox->setName('INBOX'); + + $this->client->method('status') + ->willThrowException(new Horde_Imap_Client_Exception('status failed')); + $this->client->expects(self::once()) + ->method('logout'); + + $this->expectException(ServiceException::class); + + $this->connector->isPermflagsEnabled($this->account, $mailbox); + } + + public function testIsPermflagsEnabledLogsOutClientOnSuccess(): void { + $mailbox = new Mailbox(); + $mailbox->setName('INBOX'); + + $this->client->method('status') + ->willReturn(['permflags' => ['\\*']]); + $this->client->expects(self::once()) + ->method('logout'); + + $result = $this->connector->isPermflagsEnabled($this->account, $mailbox); + + self::assertTrue($result); + } +} diff --git a/tests/Unit/IMAP/MailboxSyncTest.php b/tests/Unit/IMAP/MailboxSyncTest.php index 0eb77709df..a09156f8f3 100644 --- a/tests/Unit/IMAP/MailboxSyncTest.php +++ b/tests/Unit/IMAP/MailboxSyncTest.php @@ -23,9 +23,9 @@ use OCA\Mail\Events\MailboxesSynchronizedEvent; use OCA\Mail\Folder; use OCA\Mail\IMAP\FolderMapper; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MailboxStats; use OCA\Mail\IMAP\MailboxSync; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; @@ -42,8 +42,8 @@ class MailboxSyncTest extends TestCase { /** @var MailAccountMapper|MockObject */ private $mailAccountMapper; - /** @var IMAPClientFactory|MockObject */ - private $imapClientFactory; + /** @var ProtocolFactory|MockObject */ + private $protocolFactory; /** @var TimeFactory|MockObject */ private $timeFactory; @@ -62,7 +62,7 @@ protected function setUp(): void { $this->mailboxMapper = $this->createMock(MailboxMapper::class); $this->folderMapper = $this->createMock(FolderMapper::class); $this->mailAccountMapper = $this->createMock(MailAccountMapper::class); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->timeFactory = $this->createMock(ITimeFactory::class); $this->dispatcher = $this->createMock(IEventDispatcher::class); $this->dbConnection = $this->createMock(IDBConnection::class); @@ -71,7 +71,7 @@ protected function setUp(): void { $this->mailboxMapper, $this->folderMapper, $this->mailAccountMapper, - $this->imapClientFactory, + $this->protocolFactory, $this->timeFactory, $this->dispatcher, $this->dbConnection, @@ -81,11 +81,11 @@ protected function setUp(): void { public function testSyncSkipped() { $account = $this->createMock(Account::class); $mailAccount = new MailAccount(); - $mailAccount->setLastMailboxSync(100000 - 2000); + $mailAccount->setLastMailboxSync(100000 - 100); $account->method('getMailAccount')->willReturn($mailAccount); $this->timeFactory->method('getTime')->willReturn(100000); - $this->imapClientFactory->expects($this->never()) - ->method('getClient'); + $this->protocolFactory->expects($this->never()) + ->method('imapClient'); $this->dispatcher->expects($this->never())->method('dispatchTyped'); $this->sync->sync($account, new NullLogger()); @@ -98,8 +98,8 @@ public function testSync(): void { $account->method('getMailAccount')->willReturn($mailAccount); $this->timeFactory->method('getTime')->willReturn(100000); $client = $this->createMock(Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') + $this->protocolFactory->expects($this->once()) + ->method('imapClient') ->with($account) ->willReturn($client); $client->expects($this->once()) @@ -156,8 +156,8 @@ public function testSyncShared(): void { $account->method('getMailAccount')->willReturn($mailAccount); $this->timeFactory->method('getTime')->willReturn(100000); $client = $this->createMock(Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') + $this->protocolFactory->expects($this->once()) + ->method('imapClient') ->with($account) ->willReturn($client); $personal = new Horde_Imap_Client_Data_Namespace(); @@ -218,7 +218,7 @@ public function testSyncSharedNamespaceWithoutPrefix(): void { $account->method('getMailAccount')->willReturn($mailAccount); $this->timeFactory->method('getTime')->willReturn(100000); $client = $this->createMock(Horde_Imap_Client_Socket::class); - $this->imapClientFactory->method('getClient') + $this->protocolFactory->method('imapClient') ->with($account) ->willReturn($client); $personal = new Horde_Imap_Client_Data_Namespace(); @@ -273,8 +273,8 @@ public function testSyncWithClient(): void { ->with($account) ->willReturn([]); - $this->imapClientFactory->expects($this->never()) - ->method('getClient'); + $this->protocolFactory->expects($this->never()) + ->method('imapClient'); $client->expects($this->never()) ->method('logout'); @@ -300,8 +300,8 @@ public function testSyncWithoutClient(): void { ->with($account) ->willReturn([]); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') + $this->protocolFactory->expects($this->once()) + ->method('imapClient') ->with($account) ->willReturn($client); $client->expects($this->once()) diff --git a/tests/Unit/IMAP/PreviewEnhancerTest.php b/tests/Unit/IMAP/PreviewEnhancerTest.php index 6a08b3deb7..02591ceaea 100644 --- a/tests/Unit/IMAP/PreviewEnhancerTest.php +++ b/tests/Unit/IMAP/PreviewEnhancerTest.php @@ -10,14 +10,13 @@ namespace Unit\IMAP; use ChristophWurst\Nextcloud\Testing\TestCase; -use Horde_Imap_Client_Socket; use OCA\Mail\Address; use OCA\Mail\AddressList; use OCA\Mail\Db\Message; use OCA\Mail\Db\MessageMapper as DbMapper; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper as ImapMapper; use OCA\Mail\IMAP\PreviewEnhancer; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\Avatar\Avatar; use OCA\Mail\Service\AvatarService; @@ -26,8 +25,8 @@ class PreviewEnhancerTest extends TestCase { - /** @var IMAPClientFactory|MockObject */ - private $imapClientFactory; + /** @var ProtocolFactory|MockObject */ + private $protocolFactory; /** @var ImapMapper|MockObject */ private $imapMapper; /** @var DbMapper|MockObject */ @@ -44,7 +43,7 @@ class PreviewEnhancerTest extends TestCase { protected function setUp(): void { parent::setUp(); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->imapMapper = $this->createMock(ImapMapper::class); $this->dbMapper = $this->createMock(DbMapper::class); $this->logger = $this->createMock(LoggerInterface::class); @@ -52,7 +51,7 @@ protected function setUp(): void { $this->attachmentService = $this->createMock(AttachmentService::class); $this->previewEnhancer = new PreviewEnhancer( - $this->imapClientFactory, + $this->protocolFactory, $this->imapMapper, $this->dbMapper, $this->logger, @@ -74,16 +73,11 @@ public function testAvatars(): void { $message2->setFrom(new AddressList([Address::fromRaw('Bob', 'bob@example.com')])); $messages = [$message1, $message2]; $message2Avatar = new Avatar('example.com', 'image/png', true); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') - ->with($account) - ->willReturn($client); $this->attachmentService->expects($this->exactly(2)) ->method('getAttachmentNames') ->withConsecutive( - [$account, $mailbox, $message1, $client], - [$account, $mailbox, $message2, $client], + [$account, $mailbox, $message1], + [$account, $mailbox, $message2], ) ->willReturnOnConsecutiveCalls( [], [] diff --git a/tests/Unit/JMAP/JmapMailboxAdapterTest.php b/tests/Unit/JMAP/JmapMailboxAdapterTest.php new file mode 100644 index 0000000000..1e3b4ecb39 --- /dev/null +++ b/tests/Unit/JMAP/JmapMailboxAdapterTest.php @@ -0,0 +1,158 @@ +adapter = new JmapMailboxAdapter(); + } + + /** + * @param array $rights permission => granted; null means "no rights object" + * @param array $values + */ + private function source(array $values = [], ?array $rights = null): MailboxParametersResponse&MockObject { + $source = $this->createMock(MailboxParametersResponse::class); + + $defaults = [ + 'id' => 'remote-1', + 'label' => 'Folder', + 'in' => null, + 'role' => null, + 'subscribed' => null, + 'objectsTotal' => null, + 'objectsUnseen' => null, + ]; + foreach (array_merge($defaults, $values) as $method => $value) { + $source->method($method)->willReturn($value); + } + + $source->method('rights')->willReturn($rights === null ? null : $this->rights($rights)); + + return $source; + } + + /** + * @param array $granted + */ + private function rights(array $granted): MailboxPermissions&MockObject { + $permissions = $this->createMock(MailboxPermissions::class); + foreach (['readItems', 'addItems', 'removeItems', 'setSeen', 'setKeywords', 'createChild', 'rename', 'delete', 'submit'] as $right) { + $permissions->method($right)->willReturn($granted[$right] ?? false); + } + return $permissions; + } + + public function testMapsBasicMetadata(): void { + $source = $this->source([ + 'id' => 'mbx-7', + 'label' => 'Receipts', + 'in' => 'parent-1', + 'objectsTotal' => 12, + 'objectsUnseen' => 3, + ]); + + $mailbox = $this->adapter->convertToMailbox($source); + + self::assertSame('Receipts', $mailbox->getName()); + self::assertSame('mbx-7', $mailbox->getRemoteId()); + self::assertSame('parent-1', $mailbox->getRemoteParentId()); + self::assertSame(12, $mailbox->getMessages()); + self::assertSame(3, $mailbox->getUnseen()); + self::assertSame(md5('mbx-7'), $mailbox->getNameHash()); + } + + public function testNullIdDoesNotBreakNameHash(): void { + $mailbox = $this->adapter->convertToMailbox($this->source(['id' => null, 'label' => 'X'])); + + self::assertSame(md5(''), $mailbox->getNameHash()); + } + + public function testRoleIsMappedToSpecialUse(): void { + $mailbox = $this->adapter->convertToMailbox($this->source(['role' => 'sent'], ['readItems' => true])); + + self::assertSame(['sent'], json_decode($mailbox->getSpecialUse(), true)); + } + + public function testFlaggedRoleMapsToImportantSpecialUse(): void { + $mailbox = $this->adapter->convertToMailbox($this->source(['role' => 'flagged'], ['readItems' => true])); + + self::assertSame(['flagged'], json_decode($mailbox->getSpecialUse(), true)); + } + + public function testReadableMailboxIsSelectable(): void { + $mailbox = $this->adapter->convertToMailbox($this->source([], ['readItems' => true])); + + self::assertTrue($mailbox->getSelectable()); + self::assertStringNotContainsString('\\noselect', $mailbox->getAttributes()); + } + + public function testUnreadableMailboxIsNotSelectableAndNoselect(): void { + $mailbox = $this->adapter->convertToMailbox($this->source([], ['readItems' => false])); + + self::assertFalse($mailbox->getSelectable()); + self::assertStringContainsString('\\noselect', $mailbox->getAttributes()); + } + + public function testAclStringIsBuiltFromRights(): void { + $mailbox = $this->adapter->convertToMailbox($this->source([], [ + 'readItems' => true, + 'addItems' => true, + 'setSeen' => true, + ])); + + $acls = $mailbox->getMyAcls(); + self::assertStringContainsString('l', $acls); + self::assertStringContainsString('r', $acls); + self::assertStringContainsString('i', $acls); + self::assertStringContainsString('s', $acls); + } + + public function testFullRightsGrantAdministerFlag(): void { + $mailbox = $this->adapter->convertToMailbox($this->source([], [ + 'readItems' => true, + 'createChild' => true, + 'rename' => true, + 'delete' => true, + ])); + + self::assertStringContainsString('a', $mailbox->getMyAcls()); + } + + public function testNoRightsObjectYieldsNullAcl(): void { + $mailbox = $this->adapter->convertToMailbox($this->source([], null)); + + self::assertNull($mailbox->getMyAcls()); + } + + public function testNoGrantedRightsYieldsNullAcl(): void { + $mailbox = $this->adapter->convertToMailbox($this->source([], [])); + + self::assertNull($mailbox->getMyAcls()); + } + + public function testSubscribedAttributeReflectsResponse(): void { + $subscribed = $this->adapter->convertToMailbox($this->source(['subscribed' => true], ['readItems' => true])); + $unsubscribed = $this->adapter->convertToMailbox($this->source(['subscribed' => false], ['readItems' => true])); + + self::assertStringContainsString('\\subscribed', $subscribed->getAttributes()); + self::assertStringNotContainsString('\\subscribed', $unsubscribed->getAttributes()); + } +} diff --git a/tests/Unit/JMAP/JmapMailboxConnectorTest.php b/tests/Unit/JMAP/JmapMailboxConnectorTest.php new file mode 100644 index 0000000000..636c06aa02 --- /dev/null +++ b/tests/Unit/JMAP/JmapMailboxConnectorTest.php @@ -0,0 +1,112 @@ +jmapOperationsService = $this->createMock(JmapOperationsService::class); + $this->mailboxMapper = $this->createMock(MailboxMapper::class); + $this->account = $this->createMock(Account::class); + $this->account->method('getId')->willReturn(42); + + $this->connector = new JmapMailboxConnector( + $this->jmapOperationsService, + $this->mailboxMapper, + $this->createMock(ITimeFactory::class), + $this->createMock(IEventDispatcher::class), + $this->createMock(LoggerInterface::class), + ); + } + + public function testCreateFindsParentByNameHashAndHashesFullPath(): void { + $parent = new Mailbox(); + $parent->setRemoteId('parent-id'); + + $this->jmapOperationsService->expects(self::once()) + ->method('connect') + ->with($this->account) + ->willReturn(true); + $this->mailboxMapper->expects(self::once()) + ->method('find') + ->with($this->account, 'Parent') + ->willReturn($parent); + $this->jmapOperationsService->expects(self::once()) + ->method('collectionCreate') + ->with($parent, self::callback(static function (Mailbox $mailbox): bool { + return $mailbox->getName() === 'Child'; + })) + ->willReturnCallback(static function (?Mailbox $location, Mailbox $mailbox): Mailbox { + self::assertSame('parent-id', $location?->getRemoteId()); + $mailbox->setRemoteId('child-id'); + $mailbox->setNameHash(md5('child-id')); + return $mailbox; + }); + $this->mailboxMapper->expects(self::once()) + ->method('insert') + ->with(self::callback(static function (Mailbox $mailbox): bool { + return $mailbox->getName() === 'Parent/Child' + && $mailbox->getNameHash() === md5('Parent/Child'); + })) + ->willReturnArgument(0); + + $mailbox = $this->connector->create($this->account, 'Parent/Child'); + + self::assertSame('Parent/Child', $mailbox->getName()); + self::assertSame(md5('Parent/Child'), $mailbox->getNameHash()); + } + + public function testRenameHashesFullPath(): void { + $mailbox = new Mailbox(); + $mailbox->setRemoteId('mailbox-id'); + $mailbox->setName('Parent/Before'); + $mailbox->setNameHash(md5('Parent/Before')); + + $this->jmapOperationsService->expects(self::once()) + ->method('connect') + ->with($this->account) + ->willReturn(true); + $this->jmapOperationsService->expects(self::once()) + ->method('collectionModify') + ->with('mailbox-id', self::callback(static function (Mailbox $mailbox): bool { + return $mailbox->getName() === 'After'; + }), ['name']) + ->willReturnArgument(1); + $this->mailboxMapper->expects(self::once()) + ->method('update') + ->with(self::callback(static function (Mailbox $mailbox): bool { + return $mailbox->getName() === 'Parent/After' + && $mailbox->getNameHash() === md5('Parent/After'); + })) + ->willReturnArgument(0); + + $renamed = $this->connector->rename($this->account, $mailbox, 'Parent/After'); + + self::assertSame('Parent/After', $renamed->getName()); + self::assertSame(md5('Parent/After'), $renamed->getNameHash()); + } +} diff --git a/tests/Unit/JMAP/JmapMessageAdapterTest.php b/tests/Unit/JMAP/JmapMessageAdapterTest.php new file mode 100644 index 0000000000..5e1c991342 --- /dev/null +++ b/tests/Unit/JMAP/JmapMessageAdapterTest.php @@ -0,0 +1,183 @@ +adapter = new JmapMessageAdapter($this->createMock(Html::class)); + } + + /** + * Build a fully stubbed JMAP message response. Every accessor returns a + * harmless default so a test only has to override what it cares about. + * + * @param array $values + * @param array $keywords keyword => present + */ + private function source(array $values = [], array $keywords = []): MailParametersResponse&MockObject { + $source = $this->createMock(MailParametersResponse::class); + + $defaults = [ + 'id' => 'remote-1', + 'messageId' => null, + 'inReplyTo' => null, + 'references' => null, + 'thread' => null, + 'subject' => null, + 'sent' => null, + 'received' => null, + 'answered' => null, + 'draft' => null, + 'flagged' => null, + 'seen' => null, + 'forwarded' => null, + 'junk' => null, + 'notjunk' => null, + 'bodyTextPreview' => null, + 'hasAttachment' => null, + 'from' => null, + 'sender' => [], + 'to' => null, + 'cc' => null, + 'bcc' => null, + ]; + foreach (array_merge($defaults, $values) as $method => $value) { + $source->method($method)->willReturn($value); + } + + $source->method('keywords')->willReturn($keywords); + $source->method('parameter')->willReturn($values['__updatedAt'] ?? null); + $source->method('keyword')->willReturnCallback( + static fn (string $name): ?bool => $keywords[$name] ?? null, + ); + + return $source; + } + + public function testMapsFlags(): void { + $source = $this->source([ + 'answered' => true, + 'draft' => true, + 'flagged' => true, + 'seen' => true, + 'forwarded' => true, + 'junk' => true, + 'notjunk' => true, + 'hasAttachment' => true, + ], [ + '$deleted' => true, + '$mdnsent' => true, + Tag::LABEL_IMPORTANT => true, + ]); + + $message = $this->adapter->convertToDatabaseMessage($source); + + self::assertTrue($message->getFlagAnswered()); + self::assertTrue($message->getFlagDraft()); + self::assertTrue($message->getFlagFlagged()); + self::assertTrue($message->getFlagSeen()); + self::assertTrue($message->getFlagForwarded()); + self::assertTrue($message->getFlagJunk()); + self::assertTrue($message->getFlagNotjunk()); + self::assertTrue($message->getFlagAttachments()); + self::assertTrue($message->getFlagDeleted()); + self::assertTrue($message->getFlagMdnsent()); + self::assertTrue($message->getFlagImportant()); + } + + public function testUnsetFlagsDefaultToFalseNotNull(): void { + $message = $this->adapter->convertToDatabaseMessage($this->source()); + + self::assertFalse($message->getFlagAnswered()); + self::assertFalse($message->getFlagSeen()); + self::assertFalse($message->getFlagImportant()); + self::assertFalse($message->getFlagAttachments()); + } + + public function testImportantViaLegacyLabel(): void { + $source = $this->source([], ['$label1' => true]); + + $message = $this->adapter->convertToDatabaseMessage($source); + + self::assertTrue($message->getFlagImportant()); + } + + public function testReferencesArrayIsJsonEncoded(): void { + $source = $this->source(['references' => ['', '', '']]); + + $message = $this->adapter->convertToDatabaseMessage($source); + + self::assertSame('["",""]', $message->getReferences()); + } + + public function testReferencesNullStaysNull(): void { + $message = $this->adapter->convertToDatabaseMessage($this->source(['references' => null])); + + self::assertNull($message->getReferences()); + } + + public function testSubjectDefaultsToEmptyString(): void { + $message = $this->adapter->convertToDatabaseMessage($this->source(['subject' => null])); + + self::assertSame('', $message->getSubject()); + } + + public function testConvertsCustomKeywordsToTagsAndSkipsReserved(): void { + $source = $this->source([], [ + '$seen' => true, + '$flagged' => true, + 'work' => true, + 'urgent' => true, + 'ignored' => false, + ]); + + $message = $this->adapter->convertToDatabaseMessage($source); + + $labels = array_map(static fn (Tag $tag): string => $tag->getImapLabel(), $message->getTags()); + sort($labels); + self::assertSame(['urgent', 'work'], $labels); + } + + public function testMapsFromAddress(): void { + $source = $this->source([ + 'from' => [['email' => 'alice@example.com', 'name' => 'Alice']], + ]); + + $message = $this->adapter->convertToDatabaseMessage($source); + + $from = $message->getFrom()->first(); + self::assertNotNull($from); + self::assertSame('alice@example.com', $from->getEmail()); + } + + public function testFallsBackToSenderWhenFromMissing(): void { + $source = $this->source([ + 'from' => null, + 'sender' => [['email' => 'sender@example.com', 'name' => 'Sender']], + ]); + + $message = $this->adapter->convertToDatabaseMessage($source); + + $from = $message->getFrom()->first(); + self::assertNotNull($from); + self::assertSame('sender@example.com', $from->getEmail()); + } +} diff --git a/tests/Unit/Job/FollowUpClassifierJobTest.php b/tests/Unit/Job/FollowUpClassifierJobTest.php index 4f5de430dc..0731e228a5 100644 --- a/tests/Unit/Job/FollowUpClassifierJobTest.php +++ b/tests/Unit/Job/FollowUpClassifierJobTest.php @@ -12,7 +12,6 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; use OCA\Mail\BackgroundJob\FollowUpClassifierJob; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; @@ -21,6 +20,7 @@ use OCA\Mail\Exception\ServiceException; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Utility\ITimeFactory; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -38,7 +38,7 @@ class FollowUpClassifierJobTest extends TestCase { /** @var AccountService|MockObject */ private $accountService; - /** @var IMailManager|MockObject */ + /** @var MailManager|MockObject */ private $mailManager; /** @var AiIntegrationsService|MockObject */ @@ -53,7 +53,7 @@ protected function setUp(): void { $this->time = $this->createMock(ITimeFactory::class); $this->logger = $this->createMock(LoggerInterface::class); $this->accountService = $this->createMock(AccountService::class); - $this->mailManager = $this->createMock(IMailManager::class); + $this->mailManager = $this->createMock(MailManager::class); $this->aiService = $this->createMock(AiIntegrationsService::class); $this->threadMapper = $this->createMock(ThreadMapper::class); @@ -115,8 +115,8 @@ public function testRun(): void { ->with('Follow up', '#d77000', 'user') ->willReturn($tag); $this->mailManager->expects(self::once()) - ->method('tagMessage') - ->with($account, 'sent', $message, $tag, true); + ->method('tagMessages') + ->with($account, $mailbox, $tag, true, $message); $this->job->run($argument); } @@ -150,7 +150,7 @@ public function testRunLlmProcessingDisabled(): void { $this->mailManager->expects(self::never()) ->method('createTag'); $this->mailManager->expects(self::never()) - ->method('tagMessage'); + ->method('tagMessages'); $this->job->run($argument); } @@ -192,7 +192,7 @@ public function testRunNoMessages(): void { $this->mailManager->expects(self::never()) ->method('createTag'); $this->mailManager->expects(self::never()) - ->method('tagMessage'); + ->method('tagMessages'); $this->job->run($argument); } @@ -248,8 +248,8 @@ public function testRunMultipleMessages(): void { ->with('Follow up', '#d77000', 'user') ->willReturn($tag); $this->mailManager->expects(self::once()) - ->method('tagMessage') - ->with($account, 'sent', $message, $tag, true); + ->method('tagMessages') + ->with($account, $mailbox, $tag, true, $message); $this->job->run($argument); } @@ -302,8 +302,8 @@ public function testRunCreateTag(): void { ->with('Follow up', '#d77000', 'user') ->willReturn($tag); $this->mailManager->expects(self::once()) - ->method('tagMessage') - ->with($account, 'sent', $message, $tag, true); + ->method('tagMessages') + ->with($account, $mailbox, $tag, true, $message); $this->job->run($argument); } @@ -354,7 +354,7 @@ public function testRunNoFollowUp(): void { $this->mailManager->expects(self::never()) ->method('createTag'); $this->mailManager->expects(self::never()) - ->method('tagMessage'); + ->method('tagMessages'); $this->job->run($argument); } @@ -403,7 +403,7 @@ public function testRunFollowedUp(): void { $this->mailManager->expects(self::never()) ->method('createTag'); $this->mailManager->expects(self::never()) - ->method('tagMessage'); + ->method('tagMessages'); $this->job->run($argument); } @@ -453,7 +453,7 @@ public function testRunServiceFailure(): void { ->method('error') ->with('Failed to classify message for follow-up: AI task processing failed', ['exception' => $exception]); $this->mailManager->expects(self::never())->method('createTag'); - $this->mailManager->expects(self::never())->method('tagMessage'); + $this->mailManager->expects(self::never())->method('tagMessages'); $this->job->run($argument); } diff --git a/tests/Unit/Job/TrashRetentionJobTest.php b/tests/Unit/Job/TrashRetentionJobTest.php index 8cc52d6089..d6c837fd5b 100644 --- a/tests/Unit/Job/TrashRetentionJobTest.php +++ b/tests/Unit/Job/TrashRetentionJobTest.php @@ -10,10 +10,8 @@ namespace OCA\Mail\Tests\Unit\Job; use ChristophWurst\Nextcloud\Testing\TestCase; -use Horde_Imap_Client_Socket; use OCA\Mail\Account; use OCA\Mail\BackgroundJob\TrashRetentionJob; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Db\Mailbox; @@ -21,7 +19,7 @@ use OCA\Mail\Db\Message; use OCA\Mail\Db\MessageMapper; use OCA\Mail\Db\MessageRetentionMapper; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\Sync\SyncService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; @@ -38,9 +36,6 @@ class TrashRetentionJobTest extends TestCase { /** @var LoggerInterface|MockObject */ private $logger; - /** @var IMAPClientFactory|MockObject */ - private $clientFactory; - /** @var MessageMapper|MockObject */ private $messageMapper; @@ -53,7 +48,7 @@ class TrashRetentionJobTest extends TestCase { /** @var MailboxMapper|MockObject */ private $mailboxMapper; - /** @var IMailManager|MockObject */ + /** @var MailManager|MockObject */ private $mailManager; /** @var SyncService|MockObject */ @@ -66,24 +61,21 @@ protected function setUp(): void { $this->timeFactory = $this->createMock(ITimeFactory::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->clientFactory = $this->createMock(IMAPClientFactory::class); $this->messageMapper = $this->createMock(MessageMapper::class); $this->messageRetentionMapper = $this->createMock(MessageRetentionMapper::class); $this->accountMapper = $this->createMock(MailAccountMapper::class); $this->mailboxMapper = $this->createMock(MailboxMapper::class); - $this->mailManager = $this->createMock(IMailManager::class); + $this->mailManager = $this->createMock(MailManager::class); $this->syncService = $this->createMock(SyncService::class); $this->job = new TrashRetentionJob( $this->timeFactory, $this->logger, - $this->clientFactory, $this->messageMapper, $this->messageRetentionMapper, $this->accountMapper, $this->mailboxMapper, $this->mailManager, - $this->syncService, ); } @@ -97,7 +89,6 @@ public function testRun() { $message = new Message(); $message->setMailboxId(123); $message->setUid(420); - $client = $this->createMock(Horde_Imap_Client_Socket::class); $this->accountMapper->expects($this->once()) ->method('getAllAccounts') @@ -115,14 +106,9 @@ public function testRun() { ->method('findMessagesKnownSinceBefore') ->with(42, 1000000 - 24 * 60 * 3600) ->willReturn([$message]); - $this->clientFactory->expects($this->once()) - ->method('getClient') - ->willReturn($client); $this->mailManager->expects($this->once()) - ->method('deleteMessageWithClient') - ->with($account, $trash, 420, $client); - $client->expects($this->once()) - ->method('logout'); + ->method('deleteMessage') + ->with($account, $trash, $message); $this->job->run(self::ARGUMENT); } @@ -137,7 +123,7 @@ public function testRunWithoutRetention() { $this->syncService->expects($this->never()) ->method('syncMailbox'); $this->mailManager->expects($this->never()) - ->method('deleteMessageWithClient'); + ->method('deleteMessage'); $this->job->run(self::ARGUMENT); } @@ -152,7 +138,7 @@ public function testRunWith0DaysRetention() { $this->syncService->expects($this->never()) ->method('syncMailbox'); $this->mailManager->expects($this->never()) - ->method('deleteMessageWithClient'); + ->method('deleteMessage'); $this->job->run(self::ARGUMENT); } @@ -167,7 +153,7 @@ public function testRunWithNegativeRetention() { $this->syncService->expects($this->never()) ->method('syncMailbox'); $this->mailManager->expects($this->never()) - ->method('deleteMessageWithClient'); + ->method('deleteMessage'); $this->job->run(self::ARGUMENT); } @@ -185,7 +171,7 @@ public function testRunWithoutTrash() { $this->syncService->expects($this->never()) ->method('syncMailbox'); $this->mailManager->expects($this->never()) - ->method('deleteMessageWithClient'); + ->method('deleteMessage'); $this->job->run(self::ARGUMENT); } @@ -205,7 +191,7 @@ public function testRunWithNonExistingTrash() { $this->syncService->expects($this->never()) ->method('syncMailbox'); $this->mailManager->expects($this->never()) - ->method('deleteMessageWithClient'); + ->method('deleteMessage'); $this->job->run(self::ARGUMENT); } diff --git a/tests/Unit/Listener/DeleteDraftListenerTest.php b/tests/Unit/Listener/DeleteDraftListenerTest.php index f98e6aeeba..7f205423b4 100644 --- a/tests/Unit/Listener/DeleteDraftListenerTest.php +++ b/tests/Unit/Listener/DeleteDraftListenerTest.php @@ -16,10 +16,10 @@ use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; use OCA\Mail\Events\DraftSavedEvent; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper; use OCA\Mail\Listener\DeleteDraftListener; use OCA\Mail\Model\NewMessageData; +use OCA\Mail\Protocol\ProtocolFactory; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventDispatcher; @@ -28,8 +28,8 @@ use Psr\Log\LoggerInterface; class DeleteDraftListenerTest extends TestCase { - /** @var IMAPClientFactory|MockObject */ - private $imapClientFactory; + /** @var ProtocolFactory|MockObject */ + private $protocolFactory; /** @var MailboxMapper|MockObject */ private $mailboxMapper; @@ -49,14 +49,14 @@ class DeleteDraftListenerTest extends TestCase { protected function setUp(): void { parent::setUp(); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->mailboxMapper = $this->createMock(MailboxMapper::class); $this->messageMapper = $this->createMock(MessageMapper::class); $this->logger = $this->createMock(LoggerInterface::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->listener = new DeleteDraftListener( - $this->imapClientFactory, + $this->protocolFactory, $this->mailboxMapper, $this->messageMapper, $this->logger, @@ -109,8 +109,8 @@ public function testHandleDraftSavedEventNoDraftMailboxSet(): void { ); /** @var \Horde_Imap_Client_Socket|MockObject $client */ $client = $this->createStub(\Horde_Imap_Client_Socket::class); - $this->imapClientFactory - ->method('getClient') + $this->protocolFactory + ->method('imapClient') ->with($account) ->willReturn($client); $mailbox = new Mailbox(); @@ -140,8 +140,8 @@ public function testHandleDraftSavedEventDraftMailboxNotFound(): void { ); /** @var \Horde_Imap_Client_Socket|MockObject $client */ $client = $this->createStub(\Horde_Imap_Client_Socket::class); - $this->imapClientFactory - ->method('getClient') + $this->protocolFactory + ->method('imapClient') ->with($account) ->willReturn($client); $mailbox = new Mailbox(); diff --git a/tests/Unit/Listener/MoveJunkListenerTest.php b/tests/Unit/Listener/MoveJunkListenerTest.php index 32cf72018f..557d91de54 100644 --- a/tests/Unit/Listener/MoveJunkListenerTest.php +++ b/tests/Unit/Listener/MoveJunkListenerTest.php @@ -11,30 +11,41 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; use OCA\Mail\Events\MessageFlaggedEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Listener\MoveJunkListener; +use OCA\Mail\Service\MailManager; use Psr\Log\LoggerInterface; use Psr\Log\Test\TestLogger; class MoveJunkListenerTest extends TestCase { - private IMailManager $mailManager; + private MailManager $mailManager; + private MailboxMapper $mailboxMapper; private LoggerInterface $logger; private MoveJunkListener $listener; protected function setUp(): void { parent::setUp(); - $this->mailManager = $this->createMock(IMailManager::class); + $this->mailManager = $this->createMock(MailManager::class); + $this->mailboxMapper = $this->createMock(MailboxMapper::class); $this->logger = new TestLogger(); + $this->mailManager->method('getMessageIdForUid') + ->willReturn(1); + $this->mailManager->method('getMessage') + ->willReturn(new Message()); + $this->mailboxMapper->method('findSpecialUseMailbox') + ->willReturn(new Mailbox()); + $this->listener = new MoveJunkListener( $this->mailManager, + $this->mailboxMapper, $this->logger ); } diff --git a/tests/Unit/Send/ChainTest.php b/tests/Unit/Send/ChainTest.php index c580ff4997..233a270ec5 100644 --- a/tests/Unit/Send/ChainTest.php +++ b/tests/Unit/Send/ChainTest.php @@ -15,7 +15,7 @@ use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\MessageMapper; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Send\AntiAbuseHandler; use OCA\Mail\Send\Chain; use OCA\Mail\Send\CopySentMessageHandler; @@ -35,7 +35,7 @@ class ChainTest extends TestCase { private MockObject|MessageMapper $messageMapper; private AttachmentService|MockObject $attachmentService; private MockObject|LocalMessageMapper $localMessageMapper; - private MockObject&IMAPClientFactory $clientFactory; + private MockObject&ProtocolFactory $protocolFactory; protected function setUp(): void { $this->sentMailboxHandler = $this->createMock(SentMailboxHandler::class); @@ -45,7 +45,7 @@ protected function setUp(): void { $this->flagRepliedMessageHandler = $this->createMock(FlagRepliedMessageHandler::class); $this->attachmentService = $this->createMock(AttachmentService::class); $this->localMessageMapper = $this->createMock(LocalMessageMapper::class); - $this->clientFactory = $this->createMock(IMAPClientFactory::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->chain = new Chain($this->sentMailboxHandler, $this->antiAbuseHandler, $this->sendHandler, @@ -53,7 +53,7 @@ protected function setUp(): void { $this->flagRepliedMessageHandler, $this->attachmentService, $this->localMessageMapper, - $this->clientFactory, + $this->protocolFactory, ); } @@ -74,8 +74,8 @@ public function testProcess(): void { $this->sentMailboxHandler->expects(self::once()) ->method('setNext'); - $this->clientFactory->expects(self::once()) - ->method('getClient') + $this->protocolFactory->expects(self::once()) + ->method('imapClient') ->willReturn($client); $this->sentMailboxHandler->expects(self::once()) ->method('process') @@ -110,8 +110,8 @@ public function testProcessNotProcessed() { $this->sentMailboxHandler->expects(self::once()) ->method('setNext'); - $this->clientFactory->expects(self::once()) - ->method('getClient') + $this->protocolFactory->expects(self::once()) + ->method('imapClient') ->willReturn($client); $this->sentMailboxHandler->expects(self::once()) ->method('process') diff --git a/tests/Unit/Service/AccountServiceTest.php b/tests/Unit/Service/AccountServiceTest.php index 1461b14ac8..fa840fc4bd 100644 --- a/tests/Unit/Service/AccountServiceTest.php +++ b/tests/Unit/Service/AccountServiceTest.php @@ -17,7 +17,7 @@ use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Exception\ClientException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AliasesService; use OCP\AppFramework\Utility\ITimeFactory; @@ -54,8 +54,8 @@ class AccountServiceTest extends TestCase { /** @var IJobList|MockObject */ private $jobList; - /** @var IMAPClientFactory|MockObject */ - private $imapClientFactory; + /** @var ProtocolFactory|MockObject */ + private $protocolFactory; /** @var Horde_Imap_Client_Socket|MockObject */ private $client; @@ -71,7 +71,7 @@ protected function setUp(): void { $this->l10n = $this->createMock(IL10N::class); $this->aliasesService = $this->createMock(AliasesService::class); $this->jobList = $this->createMock(IJobList::class); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->config = $this->createMock(IConfig::class); $this->time = $this->createMock(ITimeFactory::class); $this->delegationMapper = $this->createMock(DelegationMapper::class); @@ -79,7 +79,7 @@ protected function setUp(): void { $this->mapper, $this->aliasesService, $this->jobList, - $this->imapClientFactory, + $this->protocolFactory, $this->config, $this->time, $this->delegationMapper, @@ -211,8 +211,8 @@ public function testUpdateSignature() { } public function testAccountsFailedConnection() { $accountId = 1; - $this->imapClientFactory->expects($this->once()) - ->method('getClient') + $this->protocolFactory->expects($this->once()) + ->method('testConnection') ->willThrowException(new ClientException()); $this->mapper->expects($this->once()) ->method('find') @@ -223,12 +223,8 @@ public function testAccountsFailedConnection() { } public function testAccountsSuccesfulConnection() { $accountId = 1; - $this->imapClientFactory->expects($this->once()) - ->method('getClient') - ->willReturn($this->client); - $this->client->expects($this->once()) - ->method('close') - ->willReturn(null); + $this->protocolFactory->expects($this->once()) + ->method('testConnection'); $this->mapper->expects($this->once()) ->method('find') ->with($this->user, $accountId) diff --git a/tests/Unit/Service/AiIntegrationsServiceTest.php b/tests/Unit/Service/AiIntegrationsServiceTest.php index d2c4c86e47..9d626cd531 100644 --- a/tests/Unit/Service/AiIntegrationsServiceTest.php +++ b/tests/Unit/Service/AiIntegrationsServiceTest.php @@ -10,22 +10,20 @@ namespace OCA\Mail\Tests\Unit\Service; use ChristophWurst\Nextcloud\Testing\TestCase; -use Horde_Imap_Client_Socket; use OCA\Mail\Account; use OCA\Mail\Address; use OCA\Mail\AddressList; use OCA\Mail\AppInfo\Application; use OCA\Mail\ConfigLexicon; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\AiIntegrations\Cache; use OCA\Mail\Service\AiIntegrations\DefaultPrompts; +use OCA\Mail\Service\MailManager; use OCP\IAppConfig; use OCP\IUser; use OCP\IUserManager; @@ -45,8 +43,7 @@ class AiIntegrationsServiceTest extends TestCase { private NullLogger|MockObject $logger; private AiIntegrationsService $aiIntegrationsService; private Cache|MockObject $cache; - private IMAPClientFactory|MockObject $clientFactory; - private IMailManager|MockObject $mailManager; + private MailManager|MockObject $mailManager; private TaskProcessingManager|MockObject $taskProcessingManager; private TaskProcessingProvider|MockObject $taskProcessingProvider; private IFactory|MockObject $l10nFactory; @@ -58,15 +55,13 @@ protected function setUp(): void { $this->logger = $this->createMock(NullLogger::class); $this->appConfig = $this->createMock(IAppConfig::class); $this->cache = $this->createMock(Cache::class); - $this->clientFactory = $this->createMock(IMAPClientFactory::class); - $this->mailManager = $this->createMock(IMailManager::class); + $this->mailManager = $this->createMock(MailManager::class); $this->taskProcessingManager = $this->createMock(TaskProcessingManager::class); $this->l10nFactory = $this->createMock(IFactory::class); $this->userManager = $this->createMock(IUserManager::class); $this->aiIntegrationsService = new AiIntegrationsService( $this->logger, $this->cache, - $this->clientFactory, $this->mailManager, $this->taskProcessingManager, $this->l10nFactory, @@ -91,10 +86,6 @@ public function testTaskProcessingExceptionIsMappedToServiceException(): void { ->method('getAvailableTaskTypes') ->willReturn([TextToTextSummary::ID => $this->taskProcessingProvider]); $this->cache->method('getValue')->willReturn(false); - $this->clientFactory - ->method('getClient') - ->with($account) - ->willReturn($this->createMock(Horde_Imap_Client_Socket::class)); $this->taskProcessingManager ->method('runTask') ->willThrowException($taskProcessingException); @@ -131,7 +122,6 @@ public function testSmartReply(): void { ->method('getAvailableTaskTypes') ->willReturn([TextToText::ID => $this->taskProcessingProvider]); $this->cache->method('getValue')->willReturn(false); - $this->clientFactory->method('getClient')->with($account)->willReturn($this->createMock(Horde_Imap_Client_Socket::class)); $this->mailManager->method('getImapMessage')->willReturn($imapMessage); $imapMessage->method('isOneClickUnsubscribe')->willReturn(false); $imapMessage->method('getUnsubscribeUrl')->willReturn(null); @@ -182,7 +172,6 @@ public function testSmartReplyMarkdownFormat(): void { ->willReturnCallback(function (string $_key, ?string $value) use (&$cachedReplies): void { $cachedReplies = $value; }); - $this->clientFactory->method('getClient')->with($account)->willReturn($this->createMock(Horde_Imap_Client_Socket::class)); $this->mailManager->method('getImapMessage')->willReturn($imapMessage); $imapMessage->method('isOneClickUnsubscribe')->willReturn(false); $imapMessage->method('getUnsubscribeUrl')->willReturn(null); @@ -229,7 +218,6 @@ public function testSmartReplyInvalidStructure(string $output): void { ->method('getAvailableTaskTypes') ->willReturn([TextToText::ID => $this->taskProcessingProvider]); $this->cache->method('getValue')->willReturn(false); - $this->clientFactory->method('getClient')->with($account)->willReturn($this->createMock(Horde_Imap_Client_Socket::class)); $this->mailManager->method('getImapMessage')->willReturn($imapMessage); $imapMessage->method('isOneClickUnsubscribe')->willReturn(false); $imapMessage->method('getUnsubscribeUrl')->willReturn(null); @@ -495,8 +483,6 @@ public function testSummarizeMessagesContainsSummary(): void { $this->taskProcessingManager->expects(self::once()) ->method('getAvailableTaskTypes') ->willReturn([TextToText::ID => $this->taskProcessingProvider]); - $this->clientFactory->expects(self::once()) - ->method('getClient'); $this->aiIntegrationsService->summarizeMessages($account, [$message]); } @@ -531,9 +517,6 @@ public function testSummarizeMessagesIsEncrypted(): void { ->method('getUserLanguage') ->with($user) ->willReturn('en'); - - $imapClient = $this->clientFactory->getClient($account); - $imapMessage = $this->createMock(IMAPMessage::class); $imapMessage->expects(self::never()) ->method('getPlainBody') @@ -547,9 +530,6 @@ public function testSummarizeMessagesIsEncrypted(): void { $this->taskProcessingManager->expects(self::never()) ->method('scheduleTask'); - $this->clientFactory->expects(self::once()) - ->method('getClient'); - $this->mailManager->expects(self::once()) ->method('getMailbox') ->with( @@ -560,10 +540,9 @@ public function testSummarizeMessagesIsEncrypted(): void { $this->mailManager->expects(self::once()) ->method('getImapMessage') ->with( - $imapClient, $account, $mailBox, - $message->getUid(), + $message, true ) ->willReturn($imapMessage); @@ -602,8 +581,6 @@ public function testSummarizeMessages(): void { ->with($user) ->willReturn('de_DE'); - $imapClient = $this->clientFactory->getClient($account); - $imapMessage = $this->createMock(IMAPMessage::class); $imapMessage->expects(self::atMost(2)) ->method('getPlainBody') @@ -625,9 +602,6 @@ public function testSummarizeMessages(): void { ); }); - $this->clientFactory->expects(self::once()) - ->method('getClient'); - $this->mailManager->expects(self::once()) ->method('getMailbox') ->with( @@ -638,10 +612,9 @@ public function testSummarizeMessages(): void { $this->mailManager->expects(self::once()) ->method('getImapMessage') ->with( - $imapClient, $account, $mailBox, - $message->getUid(), + $message, true ) ->willReturn($imapMessage); @@ -703,7 +676,6 @@ public function testRequiresFollowUp(): void { $this->taskProcessingManager ->method('getAvailableTaskTypes') ->willReturn([TextToText::ID => $this->taskProcessingProvider]); - $this->clientFactory->method('getClient')->with($account)->willReturn($this->createMock(Horde_Imap_Client_Socket::class)); $this->mailManager->method('getImapMessage')->willReturn($imapMessage); $imapMessage->method('isOneClickUnsubscribe')->willReturn(false); $imapMessage->method('getUnsubscribeUrl')->willReturn(null); @@ -746,7 +718,6 @@ public function testRequiresFollowUpRejectsUnusableOutput(?string $output, int $ $this->taskProcessingManager ->method('getAvailableTaskTypes') ->willReturn([TextToText::ID => $this->taskProcessingProvider]); - $this->clientFactory->method('getClient')->with($account)->willReturn($this->createMock(Horde_Imap_Client_Socket::class)); $this->mailManager->method('getImapMessage')->willReturn($imapMessage); $imapMessage->method('isOneClickUnsubscribe')->willReturn(false); $imapMessage->method('getUnsubscribeUrl')->willReturn(null); diff --git a/tests/Unit/Service/AntiSpamServiceTest.php b/tests/Unit/Service/AntiSpamServiceTest.php index b2ea8533f5..152abbd129 100644 --- a/tests/Unit/Service/AntiSpamServiceTest.php +++ b/tests/Unit/Service/AntiSpamServiceTest.php @@ -18,9 +18,9 @@ use OCA\Mail\Db\MessageMapper as DbMessageMapper; use OCA\Mail\Events\MessageFlaggedEvent; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; use OCA\Mail\Model\NewMessageData; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AntiSpamService; use OCA\Mail\Service\MailManager; use OCA\Mail\SMTP\SmtpClientFactory; @@ -32,7 +32,7 @@ class AntiSpamServiceTest extends TestCase { private AntiSpamService $service; private IAppConfig|MockObject $appConfig; private DbMessageMapper|MockObject $dbMessageMapper; - private IMAPClientFactory|MockObject $imapClientFactory; + private ProtocolFactory|MockObject $protocolFactory; private SmtpClientFactory|MockObject $smtpClientFactory; private MockObject|ImapMessageMapper $imapMessageMapper; private LoggerInterface|MockObject $logger; @@ -46,7 +46,7 @@ protected function setUp(): void { $this->dbMessageMapper = $this->createMock(DbMessageMapper::class); $this->transmission = $this->createMock(IMailTransmission::class); $this->mailManager = $this->createMock(MailManager::class); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->smtpClientFactory = $this->createMock(SmtpClientFactory::class); $this->imapMessageMapper = $this->createMock(ImapMessageMapper::class); $this->logger = $this->createMock(LoggerInterface::class); @@ -54,7 +54,7 @@ protected function setUp(): void { $this->service = new AntiSpamService( $this->dbMessageMapper, $this->mailManager, - $this->imapClientFactory, + $this->protocolFactory, $this->smtpClientFactory, $this->imapMessageMapper, $this->logger, @@ -174,8 +174,8 @@ public function testSendReportEmail(): void { $this->mailManager->expects(self::exactly(2)) ->method('getMailbox') ->willReturn($mailbox); - $this->imapClientFactory->expects(self::exactly(2)) - ->method('getClient') + $this->protocolFactory->expects(self::exactly(2)) + ->method('imapClient') ->willReturn($client); $client->expects(self::exactly(2)) ->method('logout'); @@ -240,8 +240,8 @@ public function testSendReportEmailNoSentCopy(): void { $this->mailManager->expects(self::exactly(2)) ->method('getMailbox') ->willReturn($mailbox); - $this->imapClientFactory->expects(self::exactly(2)) - ->method('getClient') + $this->protocolFactory->expects(self::exactly(2)) + ->method('imapClient') ->willReturn($client); $client->expects(self::exactly(2)) ->method('logout'); diff --git a/tests/Unit/Service/Attachment/AttachmentServiceTest.php b/tests/Unit/Service/Attachment/AttachmentServiceTest.php index 5c5a5e5a8a..960bb9eca6 100644 --- a/tests/Unit/Service/Attachment/AttachmentServiceTest.php +++ b/tests/Unit/Service/Attachment/AttachmentServiceTest.php @@ -14,7 +14,6 @@ use OC\Files\Node\File; use OCA\Files_Sharing\SharedStorage; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\LocalAttachment; use OCA\Mail\Db\LocalAttachmentMapper; use OCA\Mail\Db\LocalMessage; @@ -28,6 +27,7 @@ use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\Attachment\AttachmentStorage; use OCA\Mail\Service\Attachment\UploadedFile; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Files\Folder; @@ -44,7 +44,7 @@ class AttachmentServiceTest extends TestCase { private LocalAttachmentMapper&MockObject $mapper; private AttachmentStorage&MockObject $storage; - private IMailManager&MockObject $mailManager; + private MailManager&MockObject $mailManager; private MessageMapper&MockObject $messageMapper; private Folder&MockObject $userFolder; private ICache&MockObject $cache; @@ -60,7 +60,7 @@ protected function setUp(): void { $this->mapper = $this->createMock(LocalAttachmentMapper::class); $this->storage = $this->createMock(AttachmentStorage::class); - $this->mailManager = $this->createMock(IMailManager::class); + $this->mailManager = $this->createMock(MailManager::class); $this->messageMapper = $this->createMock(MessageMapper::class); $this->userFolder = $this->createMock(Folder::class); $this->cache = $this->createMock(ICache::class); @@ -632,13 +632,12 @@ public function testGetAttachmentNamesCacheHit(): void { $mailbox->setId(2); $message = new Message(); $message->setUid(3); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $cached = [['id' => '1.2', 'fileName' => 'file.pdf', 'mime' => 'application/pdf', 'downloadUrl' => 'http://example.test/dl', 'mimeUrl' => 'http://example.test/mime']]; $this->cache->expects(self::once())->method('get')->willReturn($cached); $this->mailManager->expects(self::never())->method('getImapMessage'); // Act - $result = $this->service->getAttachmentNames($account, $mailbox, $message, $client); + $result = $this->service->getAttachmentNames($account, $mailbox, $message); // Assert $this->assertSame($cached, $result); @@ -653,12 +652,11 @@ public function testGetAttachmentNamesEarlyExitNoAttachments(): void { $message->setUid(3); $message->setStructureAnalyzed(true); $message->setFlagAttachments(false); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $this->cache->expects(self::never())->method('get'); $this->mailManager->expects(self::never())->method('getImapMessage'); // Act - $result = $this->service->getAttachmentNames($account, $mailbox, $message, $client); + $result = $this->service->getAttachmentNames($account, $mailbox, $message); // Assert $this->assertSame([], $result); @@ -671,12 +669,11 @@ public function testGetAttachmentNamesCacheHitEmptyArray(): void { $mailbox->setId(2); $message = new Message(); $message->setUid(3); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $this->cache->expects(self::once())->method('get')->willReturn([]); $this->mailManager->expects(self::never())->method('getImapMessage'); // Act - $result = $this->service->getAttachmentNames($account, $mailbox, $message, $client); + $result = $this->service->getAttachmentNames($account, $mailbox, $message); // Assert $this->assertSame([], $result); @@ -690,12 +687,11 @@ public function testGetAttachmentNamesCacheMissWithAttachments(): void { $message = new Message(); $message->setUid(3); $message->setId(99); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $imapMessage = $this->createMock(IMAPMessage::class); $this->cache->expects(self::once())->method('get')->willReturn(null); $this->mailManager->expects(self::once()) ->method('getImapMessage') - ->with($client, $account, $mailbox, 3, true) + ->with($account, $mailbox, $message, true) ->willReturn($imapMessage); $imapMessage->expects(self::once()) ->method('getAttachments') @@ -705,7 +701,7 @@ public function testGetAttachmentNamesCacheMissWithAttachments(): void { $this->cache->expects(self::once())->method('set'); // Act - $result = $this->service->getAttachmentNames($account, $mailbox, $message, $client); + $result = $this->service->getAttachmentNames($account, $mailbox, $message); // Assert $this->assertCount(1, $result); @@ -720,7 +716,6 @@ public function testGetAttachmentNamesCacheMissNoAttachments(): void { $mailbox->setId(2); $message = new Message(); $message->setUid(3); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $imapMessage = $this->createMock(IMAPMessage::class); $this->cache->expects(self::once())->method('get')->willReturn(null); $this->mailManager->expects(self::once())->method('getImapMessage')->willReturn($imapMessage); @@ -728,7 +723,7 @@ public function testGetAttachmentNamesCacheMissNoAttachments(): void { $this->cache->expects(self::once())->method('set')->with(self::anything(), []); // Act - $result = $this->service->getAttachmentNames($account, $mailbox, $message, $client); + $result = $this->service->getAttachmentNames($account, $mailbox, $message); // Assert $this->assertSame([], $result); @@ -741,7 +736,6 @@ public function testGetAttachmentNamesSmimeDecryptException(): void { $mailbox->setId(2); $message = new Message(); $message->setUid(3); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $this->cache->expects(self::once())->method('get')->willReturn(null); $this->mailManager->expects(self::once()) ->method('getImapMessage') @@ -750,7 +744,7 @@ public function testGetAttachmentNamesSmimeDecryptException(): void { $this->cache->expects(self::once())->method('set')->with(self::anything(), []); // Act - $result = $this->service->getAttachmentNames($account, $mailbox, $message, $client); + $result = $this->service->getAttachmentNames($account, $mailbox, $message); // Assert $this->assertSame([], $result); @@ -763,7 +757,6 @@ public function testGetAttachmentNamesServiceException(): void { $mailbox->setId(2); $message = new Message(); $message->setUid(3); - $client = $this->createStub(Horde_Imap_Client_Socket::class); $this->cache->expects(self::once())->method('get')->willReturn(null); $this->mailManager->expects(self::once()) ->method('getImapMessage') @@ -772,7 +765,7 @@ public function testGetAttachmentNamesServiceException(): void { $this->cache->expects(self::once())->method('set')->with(self::anything(), []); // Act - $result = $this->service->getAttachmentNames($account, $mailbox, $message, $client); + $result = $this->service->getAttachmentNames($account, $mailbox, $message); // Assert $this->assertSame([], $result); diff --git a/tests/Unit/Service/DkimServiceTest.php b/tests/Unit/Service/DkimServiceTest.php index e9f46101e3..44b0c4de23 100644 --- a/tests/Unit/Service/DkimServiceTest.php +++ b/tests/Unit/Service/DkimServiceTest.php @@ -13,18 +13,19 @@ use OC\Memcache\ArrayCache; use OCA\Mail\Account; use OCA\Mail\Contracts\IDkimValidator; +use OCA\Mail\Contracts\IMessageConnector; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\Message; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\DkimService; use OCP\ICache; use OCP\ICacheFactory; class DkimServiceTest extends TestCase { - private IMAPClientFactory $imapClientFactory; - private MessageMapper $messageMapper; + private ProtocolFactory $protocolFactory; + private IMessageConnector $messageConnector; private ICache $cache; private IDkimValidator $dkimValidator; private DkimService $dkimService; @@ -32,18 +33,19 @@ class DkimServiceTest extends TestCase { protected function setUp(): void { parent::setUp(); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); - $this->messageMapper = $this->createMock(MessageMapper::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); + $this->messageConnector = $this->createMock(IMessageConnector::class); $cacheFactory = $this->createMock(ICacheFactory::class); $this->cache = new ArrayCache('dkim_test'); $this->dkimValidator = $this->createMock(IDkimValidator::class); $cacheFactory->method('createLocal') ->willReturn($this->cache); + $this->protocolFactory->method('messageConnector') + ->willReturn($this->messageConnector); $this->dkimService = new DkimService( - $this->imapClientFactory, - $this->messageMapper, + $this->protocolFactory, $cacheFactory, $this->dkimValidator, ); @@ -99,10 +101,14 @@ public function testValidateFetchMessageFails(): void { $mailbox = new Mailbox(); $mailbox->setName('FooBar'); + $message = new Message(); + $message->setId(3); + $message->setUid(3); + $this->dkimService->validate( $account, $mailbox, - 3 + $message ); } @@ -115,8 +121,12 @@ public function testValidate(): void { $mailbox = new Mailbox(); $mailbox->setName('FooBar'); - $this->messageMapper - ->method('getFullText') + $message = new Message(); + $message->setId(4); + $message->setUid(4); + + $this->messageConnector + ->method('fetchMessageRaw') ->willReturn('FooBar'); $this->dkimValidator @@ -126,7 +136,7 @@ public function testValidate(): void { $result = $this->dkimService->validate( $account, $mailbox, - 4 + $message ); $this->assertTrue($result); diff --git a/tests/Unit/Service/DraftsServiceTest.php b/tests/Unit/Service/DraftsServiceTest.php index baf074ac9b..0734c5b00d 100644 --- a/tests/Unit/Service/DraftsServiceTest.php +++ b/tests/Unit/Service/DraftsServiceTest.php @@ -12,7 +12,6 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OC\EventDispatcher\EventDispatcher; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\LocalAttachment; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; @@ -21,10 +20,11 @@ use OCA\Mail\Db\Recipient; use OCA\Mail\Events\DraftMessageCreatedEvent; use OCA\Mail\Exception\ClientException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\DraftsService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\MailTransmission; use OCA\Mail\Service\OutboxService; use OCP\AppFramework\Db\DoesNotExistException; @@ -53,10 +53,10 @@ class DraftsServiceTest extends TestCase { /** @var AttachmentService|MockObject */ private $attachmentService; - /** @var IMAPClientFactory|MockObject */ - private $clientFactory; + /** @var ProtocolFactory|MockObject */ + private $protocolFactory; - /** @var IMailManager|MockObject */ + /** @var MailManager|MockObject */ private $mailManager; private IEventDispatcher $eventDispatcher; /** @var AccountService|MockObject */ @@ -74,8 +74,8 @@ protected function setUp(): void { $this->transmission = $this->createMock(MailTransmission::class); $this->mapper = $this->createMock(LocalMessageMapper::class); $this->attachmentService = $this->createMock(AttachmentService::class); - $this->clientFactory = $this->createMock(IMAPClientFactory::class); - $this->mailManager = $this->createMock(IMailManager::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); + $this->mailManager = $this->createMock(MailManager::class); $this->logger = $this->createMock(LoggerInterface::class); $this->eventDispatcher = $this->createMock(EventDispatcher::class); $this->accountService = $this->createMock(AccountService::class); @@ -85,7 +85,7 @@ protected function setUp(): void { $this->mapper, $this->attachmentService, $this->eventDispatcher, - $this->clientFactory, + $this->protocolFactory, $this->mailManager, $this->logger, $this->accountService, @@ -179,8 +179,8 @@ public function testSaveMessage(): void { ->method('saveWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->clientFactory->expects(self::once()) - ->method('getClient') + $this->protocolFactory->expects(self::once()) + ->method('imapClient') ->with($account) ->willReturn($client); $this->attachmentService->expects(self::once()) @@ -228,8 +228,8 @@ public function testSaveMessageNoAttachments(): void { ->method('saveWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->clientFactory->expects(self::never()) - ->method('getClient'); + $this->protocolFactory->expects(self::never()) + ->method('imapClient'); $this->attachmentService->expects(self::never()) ->method('handleAttachments'); $this->attachmentService->expects(self::never()) @@ -283,8 +283,8 @@ public function testUpdateMessage(): void { ->method('updateWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->clientFactory->expects(self::once()) - ->method('getClient') + $this->protocolFactory->expects(self::once()) + ->method('imapClient') ->with($account) ->willReturn($client); $this->attachmentService->expects(self::once()) @@ -341,8 +341,8 @@ public function testConvertToOutboxMessage(): void { ->method('updateWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->clientFactory->expects(self::once()) - ->method('getClient') + $this->protocolFactory->expects(self::once()) + ->method('imapClient') ->with($account) ->willReturn($client); $this->attachmentService->expects(self::once()) @@ -399,8 +399,8 @@ public function testUpdateMessageNoAttachments(): void { $this->attachmentService->expects(self::once()) ->method('updateLocalMessageAttachments') ->with($this->userId, $message2, $attachments); - $this->clientFactory->expects(self::never()) - ->method('getClient'); + $this->protocolFactory->expects(self::never()) + ->method('imapClient'); $this->attachmentService->expects(self::never()) ->method('handleAttachments'); $result = $this->draftsService->updateMessage($account, $message, $to, $cc, $bcc, $attachments); diff --git a/tests/Unit/Service/IMipServiceTest.php b/tests/Unit/Service/IMipServiceTest.php index a065359aa5..7e6285dfb6 100644 --- a/tests/Unit/Service/IMipServiceTest.php +++ b/tests/Unit/Service/IMipServiceTest.php @@ -198,8 +198,8 @@ public function testNoSchedulingInfo(): void { ->method('findById') ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getImapMessagesForScheduleProcessing') - ->with($account, $mailbox, [$message->getUid()]) + ->method('getImapMessages') + ->with($account, $mailbox, true, $message) ->willReturn([$imapMessage]); $this->calendarManager->expects(self::never()) ->method('handleIMipRequest'); @@ -243,8 +243,8 @@ public function testMessageNotAvailableOnImapServer(): void { ->method('findById') ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getImapMessagesForScheduleProcessing') - ->with($account, $mailbox, [$message->getUid()]) + ->method('getImapMessages') + ->with($account, $mailbox, true, $message) ->willReturn([]); $this->logger->expects(self::never()) ->method('warning'); @@ -288,7 +288,7 @@ public function testImapConnectionServiceException(): void { ->method('findById') ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getImapMessagesForScheduleProcessing') + ->method('getImapMessages') ->willThrowException(new ServiceException()); $this->logger->expects(self::once()) ->method('error'); @@ -332,8 +332,8 @@ public function testIsRequest(): void { ->method('findById') ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getImapMessagesForScheduleProcessing') - ->with($account, $mailbox, [$message->getUid()]) + ->method('getImapMessages') + ->with($account, $mailbox, true, $message) ->willReturn([$imapMessage]); $this->serverVersion->expects(self::once()) ->method('getMajorVersion') @@ -392,8 +392,8 @@ public function testIsReply(): void { ->method('findById') ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getImapMessagesForScheduleProcessing') - ->with($account, $mailbox, [$message->getUid()]) + ->method('getImapMessages') + ->with($account, $mailbox, true, $message) ->willReturn([$imapMessage]); $this->serverVersion->expects(self::once()) ->method('getMajorVersion') @@ -450,8 +450,8 @@ public function testIsCancel(): void { ->method('findById') ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getImapMessagesForScheduleProcessing') - ->with($account, $mailbox, [$message->getUid()]) + ->method('getImapMessages') + ->with($account, $mailbox, true, $message) ->willReturn([$imapMessage]); $this->serverVersion->expects(self::once()) ->method('getMajorVersion') @@ -516,8 +516,8 @@ public function testIsRequestServerVersion33(): void { ->method('findById') ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getImapMessagesForScheduleProcessing') - ->with($account, $mailbox, [$message->getUid()]) + ->method('getImapMessages') + ->with($account, $mailbox, true, $message) ->willReturn([$imapMessage]); $this->serverVersion->expects(self::once()) ->method('getMajorVersion') @@ -583,8 +583,8 @@ public function testIsReplyServerVersion33(): void { ->method('findById') ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getImapMessagesForScheduleProcessing') - ->with($account, $mailbox, [$message->getUid()]) + ->method('getImapMessages') + ->with($account, $mailbox, true, $message) ->willReturn([$imapMessage]); $this->serverVersion->expects(self::once()) ->method('getMajorVersion') @@ -650,8 +650,8 @@ public function testIsCancelServerVersion33(): void { ->method('findById') ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getImapMessagesForScheduleProcessing') - ->with($account, $mailbox, [$message->getUid()]) + ->method('getImapMessages') + ->with($account, $mailbox, true, $message) ->willReturn([$imapMessage]); $this->serverVersion->expects(self::once()) ->method('getMajorVersion') @@ -713,8 +713,8 @@ public function testHandleImipRequestThrowsException(): void { ->method('findById') ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getImapMessagesForScheduleProcessing') - ->with($account, $mailbox, [$message->getUid()]) + ->method('getImapMessages') + ->with($account, $mailbox, true, $message) ->willReturn([$imapMessage]); $imapMessage->expects(self::once()) ->method('getUid') @@ -778,8 +778,8 @@ public function testHandleImipReturnsFalse(): void { ->method('findById') ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getImapMessagesForScheduleProcessing') - ->with($account, $mailbox, [$message->getUid()]) + ->method('getImapMessages') + ->with($account, $mailbox, true, $message) ->willReturn([$imapMessage]); $this->serverVersion->expects(self::once()) ->method('getMajorVersion') @@ -849,8 +849,8 @@ public function testOneOfSeveralSchedulingObjectsIsNotProcessed(): void { ->method('findById') ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getImapMessagesForScheduleProcessing') - ->with($account, $mailbox, [$message->getUid()]) + ->method('getImapMessages') + ->with($account, $mailbox, true, $message) ->willReturn([$imapMessage]); $this->serverVersion->expects(self::once()) ->method('getMajorVersion') diff --git a/tests/Unit/Service/ItineraryServiceTest.php b/tests/Unit/Service/ItineraryServiceTest.php index f6d8e5ccb0..20e7a65714 100644 --- a/tests/Unit/Service/ItineraryServiceTest.php +++ b/tests/Unit/Service/ItineraryServiceTest.php @@ -10,27 +10,25 @@ namespace OCA\Mail\Tests\Service; use ChristophWurst\Nextcloud\Testing\TestCase; -use Horde_Imap_Client_Socket; use Nextcloud\KItinerary\Itinerary; use OC\Memcache\ArrayCache; use OCA\Mail\Account; +use OCA\Mail\Attachment; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; -use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Db\Message; use OCA\Mail\Integration\KItinerary\ItineraryExtractor; +use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Service\ItineraryService; +use OCA\Mail\Service\MailManager; use OCP\ICache; use OCP\ICacheFactory; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\NullLogger; class ItineraryServiceTest extends TestCase { - /** @var IMAPClientFactory|MockObject */ - private $imapClientFactory; - - /** @var MessageMapper|MockObject */ - private $messageMapper; + /** @var MailManager|MockObject */ + private $mailManager; /** @var ItineraryExtractor|MockObject */ private $itineraryExtractor; @@ -43,8 +41,7 @@ class ItineraryServiceTest extends TestCase { protected function setUp(): void { parent::setUp(); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); - $this->messageMapper = $this->createMock(MessageMapper::class); + $this->mailManager = $this->createMock(MailManager::class); $this->itineraryExtractor = $this->createMock(ItineraryExtractor::class); $cacheFactory = $this->createMock(ICacheFactory::class); $this->cache = new ArrayCache('itinerary_test'); @@ -53,8 +50,7 @@ protected function setUp(): void { ->willReturn($this->cache); $this->service = new ItineraryService( - $this->imapClientFactory, - $this->messageMapper, + $this->mailManager, $this->itineraryExtractor, $cacheFactory, new NullLogger(), @@ -70,11 +66,24 @@ public function testExtractNoBodyNoAttachments() { $mailbox = new Mailbox(); $mailbox->setName('INBOX'); + $message = new Message(); + $message->setId(13); + + $imapMessage = $this->createMock(IMAPMessage::class); + $imapMessage->htmlMessage = ''; + $this->mailManager->expects($this->once()) + ->method('getImapMessage') + ->with($account, $mailbox, $message, true) + ->willReturn($imapMessage); + $this->mailManager->expects($this->once()) + ->method('getMailAttachments') + ->with($account, $mailbox, $message) + ->willReturn([]); $this->itineraryExtractor->expects($this->once()) ->method('extract') ->willReturn(new Itinerary()); - $itinerary = $this->service->extract($account, $mailbox, 13); + $itinerary = $this->service->extract($account, $mailbox, $message); $this->assertEquals([], $itinerary->jsonSerialize()); } @@ -88,22 +97,26 @@ public function testExtractFromBody() { $mailbox = new Mailbox(); $mailbox->setName('INBOX'); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') - ->with($account) - ->willReturn($client); + $message = new Message(); + $message->setId(13); + $body = 'hello'; - $this->messageMapper->expects($this->once()) - ->method('getHtmlBody') - ->with($client, 'INBOX', 13) - ->willReturn($body); + $imapMessage = $this->createMock(IMAPMessage::class); + $imapMessage->htmlMessage = $body; + $this->mailManager->expects($this->once()) + ->method('getImapMessage') + ->with($account, $mailbox, $message, true) + ->willReturn($imapMessage); + $this->mailManager->expects($this->once()) + ->method('getMailAttachments') + ->with($account, $mailbox, $message) + ->willReturn([]); $this->itineraryExtractor->expects($this->exactly(2)) ->method('extract') ->withConsecutive([$body], ['["datafrombody"]']) ->willReturn(new Itinerary(['datafrombody'])); - $itinerary = $this->service->extract($account, $mailbox, 13); + $itinerary = $this->service->extract($account, $mailbox, $message); $this->assertEquals(['datafrombody'], $itinerary->jsonSerialize()); } @@ -117,22 +130,29 @@ public function testExtractFromAttachments() { $mailbox = new Mailbox(); $mailbox->setName('INBOX'); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') - ->with($account) - ->willReturn($client); + $message = new Message(); + $message->setId(13); + + $imapMessage = $this->createMock(IMAPMessage::class); + $imapMessage->htmlMessage = ''; $pdf = '%PDF-1.3.%'; - $this->messageMapper->expects($this->once()) - ->method('getRawAttachments') - ->with($client, 'INBOX', 13) - ->willReturn([$pdf]); + $attachment = $this->createMock(Attachment::class); + $attachment->method('getContent') + ->willReturn($pdf); + $this->mailManager->expects($this->once()) + ->method('getImapMessage') + ->with($account, $mailbox, $message, true) + ->willReturn($imapMessage); + $this->mailManager->expects($this->once()) + ->method('getMailAttachments') + ->with($account, $mailbox, $message) + ->willReturn([$attachment]); $this->itineraryExtractor->expects($this->exactly(2)) ->method('extract') ->withConsecutive([$pdf], ['["datafrompdf"]']) ->willReturn(new Itinerary(['datafrompdf'])); - $itinerary = $this->service->extract($account, $mailbox, 13); + $itinerary = $this->service->extract($account, $mailbox, $message); $this->assertEquals(['datafrompdf'], $itinerary->jsonSerialize()); } diff --git a/tests/Unit/Service/MailManagerTest.php b/tests/Unit/Service/MailManagerTest.php index cfcd6dd28c..844992f60b 100644 --- a/tests/Unit/Service/MailManagerTest.php +++ b/tests/Unit/Service/MailManagerTest.php @@ -10,26 +10,24 @@ namespace OCA\Mail\Tests\Unit\Service; use ChristophWurst\Nextcloud\Testing\TestCase; -use Horde_Imap_Client_Socket; use OCA\Mail\Account; use OCA\Mail\Attachment; +use OCA\Mail\Contracts\IMailboxConnector; +use OCA\Mail\Contracts\IMessageConnector; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; use OCA\Mail\Db\MessageMapper as DbMessageMapper; +use OCA\Mail\Db\MessageTags; use OCA\Mail\Db\MessageTagsMapper; use OCA\Mail\Db\Tag; use OCA\Mail\Db\TagMapper; use OCA\Mail\Db\ThreadMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Folder; -use OCA\Mail\IMAP\FolderMapper; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\ImapFlag; -use OCA\Mail\IMAP\MailboxSync; -use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; @@ -37,21 +35,12 @@ use Psr\Log\LoggerInterface; class MailManagerTest extends TestCase { - /** @var IMAPClientFactory|MockObject */ - private $imapClientFactory; + /** @var ProtocolFactory|MockObject */ + private $protocolFactory; /** @var MailboxMapper|MockObject */ private $mailboxMapper; - /** @var MailboxSync|MockObject */ - private $mailboxSync; - - /** @var FolderMapper|MockObject */ - private $folderMapper; - - /** @var ImapMessageMapper|MockObject */ - private $imapMessageMapper; - /** @var DbMessageMapper|MockObject */ private $dbMessageMapper; @@ -76,12 +65,9 @@ class MailManagerTest extends TestCase { protected function setUp(): void { parent::setUp(); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->mailboxMapper = $this->createMock(MailboxMapper::class); - $this->folderMapper = $this->createMock(FolderMapper::class); - $this->imapMessageMapper = $this->createMock(ImapMessageMapper::class); $this->dbMessageMapper = $this->createMock(DbMessageMapper::class); - $this->mailboxSync = $this->createMock(MailboxSync::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->logger = $this->createMock(LoggerInterface::class); $this->tagMapper = $this->createMock(TagMapper::class); @@ -89,18 +75,15 @@ protected function setUp(): void { $this->threadMapper = $this->createMock(ThreadMapper::class); $this->manager = new MailManager( - $this->imapClientFactory, $this->mailboxMapper, - $this->mailboxSync, - $this->folderMapper, - $this->imapMessageMapper, $this->dbMessageMapper, $this->eventDispatcher, $this->logger, $this->tagMapper, $this->messageTagsMapper, - $this->threadMapper, + $this->protocolFactory, new ImapFlag(), + $this->threadMapper, ); } @@ -111,9 +94,14 @@ public function testGetFolders() { $this->createMock(Mailbox::class), $this->createMock(Mailbox::class), ]; - $this->mailboxSync->expects($this->once()) - ->method('sync') - ->with($this->equalTo($account)); + $mailboxConnector = $this->createMock(IMailboxConnector::class); + $this->protocolFactory->expects($this->once()) + ->method('mailboxConnector') + ->with($account) + ->willReturn($mailboxConnector); + $mailboxConnector->expects($this->once()) + ->method('syncAll') + ->with($account, false); $this->mailboxMapper->expects($this->once()) ->method('findAll') ->with($this->equalTo($account)) @@ -125,26 +113,16 @@ public function testGetFolders() { } public function testCreateFolder() { - $client = $this->createStub(Horde_Imap_Client_Socket::class); $account = $this->createStub(Account::class); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') - ->willReturn($client); - $folder = $this->createStub(Folder::class); - $this->folderMapper->expects($this->once()) - ->method('createFolder') - ->with($this->equalTo($client), $this->equalTo('new')) - ->willReturn($folder); - $this->folderMapper->expects($this->once()) - ->method('fetchFolderAcls') - ->with($this->equalTo([$folder])); - $this->folderMapper->expects($this->once()) - ->method('detectFolderSpecialUse') - ->with($this->equalTo([$folder])); $mailbox = new Mailbox(); - $this->mailboxMapper->expects($this->once()) - ->method('find') - ->with($account, 'new') + $mailboxConnector = $this->createMock(IMailboxConnector::class); + $this->protocolFactory->expects($this->once()) + ->method('mailboxConnector') + ->with($account) + ->willReturn($mailboxConnector); + $mailboxConnector->expects($this->once()) + ->method('create') + ->with($account, 'new', []) ->willReturn($mailbox); $created = $this->manager->createMailbox($account, 'new'); @@ -152,38 +130,18 @@ public function testCreateFolder() { $this->assertEquals($mailbox, $created); } - public function testDeleteMessageSourceFolderNotFound(): void { - /** @var Account|MockObject $account */ - $account = $this->createStub(Account::class); - $this->eventDispatcher->expects($this->never()) - ->method('dispatchTyped'); - $this->mailboxMapper->expects($this->once()) - ->method('find') - ->with($account, 'INBOX') - ->willThrowException(new DoesNotExistException('')); - $this->expectException(ServiceException::class); - - $this->manager->deleteMessage( - $account, - 'INBOX', - 123 - ); - } - public function testDeleteMessageTrashMailboxNotFound(): void { /** @var Account|MockObject $account */ $account = $this->createMock(Account::class); $mailAccount = new MailAccount(); $mailAccount->setTrashMailboxId(123); + $account->method('getMailAccount')->willReturn($mailAccount); $mailbox = new Mailbox(); $mailbox->setName('INBOX'); - $account->method('getMailAccount')->willReturn($mailAccount); - $this->eventDispatcher->expects($this->once()) + $message = new Message(); + $message->setUid(123); + $this->eventDispatcher->expects($this->never()) ->method('dispatchTyped'); - $this->mailboxMapper->expects($this->once()) - ->method('find') - ->with($account, 'INBOX') - ->willReturn($mailbox); $this->mailboxMapper->expects($this->once()) ->method('findById') ->with(123) @@ -192,8 +150,8 @@ public function testDeleteMessageTrashMailboxNotFound(): void { $this->manager->deleteMessage( $account, - 'INBOX', - 123 + $mailbox, + $message ); } @@ -204,36 +162,35 @@ public function testDeleteMessage(): void { $mailAccount->setTrashMailboxId(123); $account->method('getMailAccount')->willReturn($mailAccount); $inbox = new Mailbox(); + $inbox->setId(1); $inbox->setName('INBOX'); $trash = new Mailbox(); + $trash->setId(123); $trash->setName('Trash'); + $message = new Message(); + $message->setUid(123); $this->eventDispatcher->expects($this->exactly(2)) ->method('dispatchTyped'); - $this->mailboxMapper->expects($this->once()) - ->method('find') - ->with($account, 'INBOX') - ->willReturn($inbox); $this->mailboxMapper->expects($this->once()) ->method('findById') ->with(123) ->willReturn($trash); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') - ->willReturn($client); - $this->imapMessageMapper->expects($this->once()) - ->method('move') - ->with( - $client, - 'INBOX', - 123, - 'Trash' - ); + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory->expects($this->once()) + ->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + $messageConnector->expects($this->once()) + ->method('moveMessages') + ->with($account, $trash, $inbox, $message) + ->willReturn([$message]); + $this->dbMessageMapper->expects($this->once()) + ->method('updateBulk'); $this->manager->deleteMessage( $account, - 'INBOX', - 123 + $inbox, + $message ); } @@ -244,273 +201,125 @@ public function testExpungeMessage(): void { $mailAccount->setTrashMailboxId(123); $account->method('getMailAccount')->willReturn($mailAccount); $source = new Mailbox(); + $source->setId(123); $source->setName('Trash'); $trash = new Mailbox(); + $trash->setId(123); $trash->setName('Trash'); + $message = new Message(); + $message->setUid(123); $this->eventDispatcher->expects($this->exactly(2)) ->method('dispatchTyped'); - $this->mailboxMapper->expects($this->once()) - ->method('find') - ->with($account, 'Trash') - ->willReturn($source); $this->mailboxMapper->expects($this->once()) ->method('findById') ->with(123) ->willReturn($trash); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') - ->willReturn($client); - $this->imapMessageMapper->expects($this->once()) - ->method('expunge') - ->with( - $client, - 'Trash', - 123 - ); + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory->expects($this->once()) + ->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + $messageConnector->expects($this->once()) + ->method('deleteMessages') + ->with($account, $source, $message) + ->willReturn([$message]); + $this->dbMessageMapper->expects($this->once()) + ->method('deleteByUid') + ->with($source, 123); $this->manager->deleteMessage( $account, - 'Trash', - 123 + $source, + $message ); } - public function testSetCustomFlagNoIMAPCapabilities(): void { - $client = $this->createStub(Horde_Imap_Client_Socket::class); - $account = $this->createStub(Account::class); - - $this->imapClientFactory->expects($this->any()) - ->method('getClient') - ->willReturn($client); - $this->imapMessageMapper->expects($this->never()) - ->method('addFlag'); - $this->imapMessageMapper->expects($this->never()) - ->method('removeFlag'); - - $this->manager->flagMessage($account, 'INBOX', 123, Tag::LABEL_IMPORTANT, true); - $this->manager->flagMessage($account, 'INBOX', 123, Tag::LABEL_IMPORTANT, false); - } - - public function testSetCustomFlagWithIMAPCapabilities(): void { - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $account = $this->createStub(Account::class); - - $this->imapClientFactory->expects($this->any()) - ->method('getClient') - ->willReturn($client); - $client->expects($this->once()) - ->method('status') - ->willReturn([ 'permflags' => [ '11' => "\*" ] ]); - $this->imapMessageMapper->expects($this->once()) - ->method('addFlag'); - - $this->manager->flagMessage($account, 'INBOX', 123, Tag::LABEL_IMPORTANT, true); - } - - public function testUnsetCustomFlagWithIMAPCapabilities(): void { - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $account = $this->createStub(Account::class); - - $this->imapClientFactory->expects($this->any()) - ->method('getClient') - ->willReturn($client); - $client->expects($this->once()) - ->method('status') - ->willReturn([ 'permflags' => [ '11' => "\*" ] ]); - $this->imapMessageMapper->expects($this->once()) - ->method('removeFlag'); - - $this->manager->flagMessage($account, 'INBOX', 123, Tag::LABEL_IMPORTANT, false); - } - - public function testFilterFlagsWithSystemFlags(): void { - $account = $this->createStub(Account::class); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - $flags = [ - 'seen' => [\Horde_Imap_Client::FLAG_SEEN], - 'answered' => [\Horde_Imap_Client::FLAG_ANSWERED], - 'flagged' => [\Horde_Imap_Client::FLAG_FLAGGED], - 'deleted' => [\Horde_Imap_Client::FLAG_DELETED], - 'draft' => [\Horde_Imap_Client::FLAG_DRAFT], - 'recent' => [\Horde_Imap_Client::FLAG_RECENT], - ]; - - // test all system flags - foreach ($flags as $k => $flag) { - $this->assertEquals($this->manager->filterFlags($client, $account, $k, 'INBOX'), $flags[$k]); - } - } - - public function testFilterFlagsWithDefinedKeyword() { + public function testFlagMessages(): void { $account = $this->createStub(Account::class); - $client = $this->createMock(Horde_Imap_Client_Socket::class); - - $client->expects($this->exactly(2)) - ->method('status') - ->willReturn(['permflags' => ['\seen', '$junk', '$notjunk']]); - - // test keyword supported - $this->assertEquals(['$junk'], $this->manager->filterFlags($client, $account, '$junk', 'INBOX')); - // test keyword unsupported - $this->assertEquals([], $this->manager->filterFlags($client, $account, '$autojunk', 'INBOX')); - } - - public function testFilterFlagsWithCustomKeyword() { - $account = $this->createStub(Account::class); - $client = $this->createMock(Horde_Imap_Client_Socket::class); - - $client->expects($this->exactly(2)) - ->method('status') - ->willReturnOnConsecutiveCalls( - ['permflags' => ['\seen', '$junk', '$notjunk', '\*']], - ['permflags' => ['\seen', '$junk', '$notjunk']], - ); - - // test custom keyword supported - $this->assertEquals([Tag::LABEL_IMPORTANT], $this->manager->filterFlags($client, $account, Tag::LABEL_IMPORTANT, 'INBOX')); - // test custom keyword unsupported - $this->assertEquals([], $this->manager->filterFlags($client, $account, Tag::LABEL_IMPORTANT, 'INBOX')); - } - - public function testFilterFlagsNoCapabilities() { - $account = $this->createStub(Account::class); - $client = $this->createStub(Horde_Imap_Client_Socket::class); - - $this->assertEquals([], $this->manager->filterFlags($client, $account, Tag::LABEL_IMPORTANT, 'INBOX')); - } - - public function testIsPermflagsEnabledTrue(): void { - $account = $this->createStub(Account::class); - $client = $this->createMock(Horde_Imap_Client_Socket::class); - - $client->expects($this->once()) - ->method('status') - ->willReturn(['permflags' => [ '11' => "\*"] ]); + $mailbox = new Mailbox(); + $mailbox->setName('INBOX'); + $message = new Message(); + $message->setUid(123); + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory->expects($this->once()) + ->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + $messageConnector->expects($this->once()) + ->method('flagMessages') + ->with($account, $mailbox, 'seen', true, $message) + ->willReturn([$message]); + $this->dbMessageMapper->expects($this->once()) + ->method('updateBulk'); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped'); - $this->assertTrue($this->manager->isPermflagsEnabled($client, $account, 'INBOX')); + $this->manager->flagMessages($account, $mailbox, 'seen', true, $message); } - public function testIsPermflagsEnabledFalse(): void { + public function testIsPermflagsEnabled(): void { $account = $this->createStub(Account::class); - $client = $this->createMock(Horde_Imap_Client_Socket::class); - - $client->expects($this->once()) - ->method('status') - ->willReturn([]); + $mailbox = new Mailbox(); + $mailbox->setName('INBOX'); + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory->expects($this->once()) + ->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + $messageConnector->expects($this->once()) + ->method('isPermflagsEnabled') + ->with($account, $mailbox) + ->willReturn(true); - $this->assertFalse($this->manager->isPermflagsEnabled($client, $account, 'INBOX')); - } - - public function testRemoveFlag(): void { - $client = $this->createStub(Horde_Imap_Client_Socket::class); - $account = $this->createStub(Account::class); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') - ->willReturn($client); - $mb = $this->createStub(Mailbox::class); - $this->mailboxMapper->expects($this->once()) - ->method('find') - ->with($account, 'INBOX') - ->willReturn($mb); - $this->imapMessageMapper->expects($this->never()) - ->method('addFlag'); - $this->imapMessageMapper->expects($this->once()) - ->method('removeFlag') - ->with($client, $mb, [123], '\\seen'); - - $this->manager->flagMessage($account, 'INBOX', 123, 'seen', false); + $this->assertTrue($this->manager->isPermflagsEnabled($account, $mailbox)); } public function testTagMessage(): void { - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $account = $this->createMock(Account::class); + $account = $this->createStub(Account::class); $tag = new Tag(); $tag->setImapLabel(Tag::LABEL_IMPORTANT); - $message = new \OCA\Mail\Db\Message(); + $message = new Message(); $message->setUid(123); $message->setMessageId(''); - $this->imapClientFactory->expects($this->any()) - ->method('getClient') - ->willReturn($client); - $mb = new Mailbox(); - $mb->setName('INBOX'); - $this->mailboxMapper->expects($this->once()) - ->method('find') - ->with($account, 'INBOX') - ->willReturn($mb); - $client->expects($this->once()) - ->method('status') - ->willReturn(['permflags' => [ '11' => "\*"] ]); - $this->imapMessageMapper->expects($this->once()) - ->method('addFlag') - ->with($client, $mb, [123], Tag::LABEL_IMPORTANT); - $account->expects($this->once()) - ->method('getUserId') - ->willReturn('test'); - $this->manager->tagMessage($account, 'INBOX', $message, $tag, true); + $mailbox = new Mailbox(); + $mailbox->setName('INBOX'); + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory->expects($this->once()) + ->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + $messageConnector->expects($this->once()) + ->method('tagMessages') + ->with($account, $mailbox, $tag, true, $message) + ->willReturn([$message]); + $this->dbMessageMapper->expects($this->once()) + ->method('updateBulk'); + + $this->manager->tagMessages($account, $mailbox, $tag, true, $message); } public function testUntagMessage(): void { - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $account = $this->createMock(Account::class); + $account = $this->createStub(Account::class); $tag = new Tag(); $tag->setImapLabel(Tag::LABEL_IMPORTANT); - $message = new \OCA\Mail\Db\Message(); - $message->setUid(123); - $message->setMessageId(''); - $this->imapClientFactory->expects($this->any()) - ->method('getClient') - ->willReturn($client); - $mb = new Mailbox(); - $mb->setName('INBOX'); - $this->mailboxMapper->expects($this->once()) - ->method('find') - ->with($account, 'INBOX') - ->willReturn($mb); - $client->expects($this->once()) - ->method('status') - ->willReturn(['permflags' => [ '11' => "\*"] ]); - $this->imapMessageMapper->expects($this->once()) - ->method('removeFlag') - ->with($client, $mb, [123], Tag::LABEL_IMPORTANT); - $this->imapMessageMapper->expects($this->never()) - ->method('addFlag'); - $account->expects($this->never()) - ->method('getUserId') - ->willReturn('test'); - $this->manager->tagMessage($account, 'INBOX', $message, $tag, false); - } - - public function testTagNoIMAPCapabilities(): void { - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $account = $this->createMock(Account::class); - $message = new \OCA\Mail\Db\Message(); + $message = new Message(); $message->setUid(123); $message->setMessageId(''); - $tag = new Tag(); - $tag->setImapLabel(Tag::LABEL_IMPORTANT); + $mailbox = new Mailbox(); + $mailbox->setName('INBOX'); + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory->expects($this->once()) + ->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + $messageConnector->expects($this->once()) + ->method('tagMessages') + ->with($account, $mailbox, $tag, false, $message) + ->willReturn([$message]); + $this->dbMessageMapper->expects($this->once()) + ->method('updateBulk'); - $this->imapClientFactory->expects($this->any()) - ->method('getClient') - ->willReturn($client); - $mb = new Mailbox(); - $mb->setName('INBOX'); - $this->mailboxMapper->expects($this->once()) - ->method('find') - ->with($account, 'INBOX') - ->willReturn($mb); - $client->expects($this->once()) - ->method('status') - ->willReturn([]); - $this->imapMessageMapper->expects($this->never()) - ->method('removeFlag'); - $this->imapMessageMapper->expects($this->never()) - ->method('addFlag'); - $account->expects($this->once()) - ->method('getUserId') - ->willReturn('test'); - $this->manager->tagMessage($account, 'INBOX', $message, $tag, true); + $this->manager->tagMessages($account, $mailbox, $tag, false, $message); } public function testGetThread(): void { @@ -525,10 +334,7 @@ public function testGetThread(): void { } public function testGetMailAttachments(): void { - $account = $this->createMock(Account::class); - $account->expects($this->once()) - ->method('getUserId') - ->willReturn('user'); + $account = $this->createStub(Account::class); $attachments = [ new Attachment( null, @@ -540,23 +346,22 @@ public function testGetMailAttachments(): void { null, ), ]; - $client = $this->createStub(Horde_Imap_Client_Socket::class); $mailbox = new Mailbox(); $mailbox->setName('Inbox'); $message = new Message(); $message->setUid(123); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory->expects($this->once()) + ->method('messageConnector') ->with($account) - ->willReturn($client); - $this->imapMessageMapper->expects($this->once()) - ->method('getAttachments') - ->with( - $client, - $mailbox->getName(), - $message->getUid() - )->willReturn($attachments); + ->willReturn($messageConnector); + $messageConnector->expects($this->once()) + ->method('fetchAttachments') + ->with($account, $mailbox, $message) + ->willReturn($attachments); + $result = $this->manager->getMailAttachments($account, $mailbox, $message); + $this->assertEquals($attachments, $result); } @@ -667,8 +472,16 @@ public function testMoveInbox(): void { $srcMailbox->setId($srcMailboxId); $srcMailbox->setAccountId($mailAccount->getId()); $srcMailbox->setName('INBOX'); + $dstMailbox = new Mailbox(); + $dstMailbox->setId($dstMailboxId); + $dstMailbox->setAccountId($mailAccount->getId()); + $dstMailbox->setName('Trash'); + $message1 = new Message(); + $message1->setUid(200); + $message2 = new Message(); + $message2->setUid(300); $this->mailboxMapper - ->expects(self::exactly(2)) + ->expects(self::once()) ->method('find') ->with($account, $srcMailbox->getName()) ->willReturn($srcMailbox); @@ -680,17 +493,25 @@ public function testMoveInbox(): void { ['messageUid' => 200, 'mailboxName' => 'INBOX'], ['messageUid' => 300, 'mailboxName' => 'INBOX'], ]); - $dstMailbox = new Mailbox(); - $dstMailbox->setId($dstMailboxId); - $dstMailbox->setAccountId($mailAccount->getId()); - $dstMailbox->setName('Trash'); - - $this->imapMessageMapper - ->expects(self::exactly(2)) - ->method('move'); - $this->eventDispatcher - ->expects(self::exactly(2)) - ->method('dispatch'); + $this->dbMessageMapper + ->expects(self::once()) + ->method('findByUids') + ->with($srcMailbox, [200, 300]) + ->willReturn([$message1, $message2]); + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory + ->expects(self::once()) + ->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + $messageConnector + ->expects(self::once()) + ->method('moveMessages') + ->with($account, $dstMailbox, $srcMailbox, $message1, $message2) + ->willReturn([$message1, $message2]); + $this->dbMessageMapper + ->expects(self::once()) + ->method('updateBulk'); $this->manager->moveThread( $account, @@ -713,8 +534,16 @@ public function testMoveTrash(): void { $srcMailbox->setId($srcMailboxId); $srcMailbox->setAccountId($mailAccount->getId()); $srcMailbox->setName('Trash'); + $dstMailbox = new Mailbox(); + $dstMailbox->setId($dstMailboxId); + $dstMailbox->setAccountId($mailAccount->getId()); + $dstMailbox->setName('INBOX'); + $message1 = new Message(); + $message1->setUid(200); + $message2 = new Message(); + $message2->setUid(300); $this->mailboxMapper - ->expects(self::exactly(2)) + ->expects(self::once()) ->method('find') ->with($account, $srcMailbox->getName()) ->willReturn($srcMailbox); @@ -726,17 +555,25 @@ public function testMoveTrash(): void { ['messageUid' => 200, 'mailboxName' => 'Trash'], ['messageUid' => 300, 'mailboxName' => 'Trash'], ]); - $dstMailbox = new Mailbox(); - $dstMailbox->setId($dstMailboxId); - $dstMailbox->setAccountId($mailAccount->getId()); - $dstMailbox->setName('INBOX'); - - $this->imapMessageMapper - ->expects(self::exactly(2)) - ->method('move'); - $this->eventDispatcher - ->expects(self::exactly(2)) - ->method('dispatch'); + $this->dbMessageMapper + ->expects(self::once()) + ->method('findByUids') + ->with($srcMailbox, [200, 300]) + ->willReturn([$message1, $message2]); + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory + ->expects(self::once()) + ->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + $messageConnector + ->expects(self::once()) + ->method('moveMessages') + ->with($account, $dstMailbox, $srcMailbox, $message1, $message2) + ->willReturn([$message1, $message2]); + $this->dbMessageMapper + ->expects(self::once()) + ->method('updateBulk'); $this->manager->moveThread( $account, @@ -759,8 +596,16 @@ public function testDeleteInbox(): void { $mailbox->setId($mailboxId); $mailbox->setAccountId($mailAccount->getId()); $mailbox->setName('INBOX'); + $trashMailbox = new Mailbox(); + $trashMailbox->setId($trashMailboxId); + $trashMailbox->setAccountId($mailAccount->getId()); + $trashMailbox->setName('Trash'); + $message1 = new Message(); + $message1->setUid(200); + $message2 = new Message(); + $message2->setUid(300); $this->mailboxMapper - ->expects(self::exactly(2)) + ->expects(self::once()) ->method('find') ->with($account, $mailbox->getName()) ->willReturn($mailbox); @@ -772,18 +617,30 @@ public function testDeleteInbox(): void { ['messageUid' => 200, 'mailboxName' => 'INBOX'], ['messageUid' => 300, 'mailboxName' => 'INBOX'], ]); - $trashMailbox = new Mailbox(); - $trashMailbox->setId($trashMailboxId); - $trashMailbox->setAccountId($mailAccount->getId()); - $trashMailbox->setName('Trash'); + $this->dbMessageMapper + ->expects(self::once()) + ->method('findByUids') + ->with($mailbox, [200, 300]) + ->willReturn([$message1, $message2]); $this->mailboxMapper - ->expects(self::exactly(2)) + ->expects(self::once()) ->method('findById') ->with($trashMailbox->getId()) ->willReturn($trashMailbox); - $this->imapMessageMapper - ->expects(self::exactly(2)) - ->method('move'); + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory + ->expects(self::once()) + ->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + $messageConnector + ->expects(self::once()) + ->method('moveMessages') + ->with($account, $trashMailbox, $mailbox, $message1, $message2) + ->willReturn([$message1, $message2]); + $this->dbMessageMapper + ->expects(self::once()) + ->method('updateBulk'); $this->eventDispatcher ->expects(self::exactly(4)) ->method('dispatchTyped'); @@ -806,13 +663,17 @@ public function testDeleteTrash(): void { $mailbox->setId($mailboxId); $mailbox->setAccountId($mailAccount->getId()); $mailbox->setName('Trash'); + $message1 = new Message(); + $message1->setUid(200); + $message2 = new Message(); + $message2->setUid(300); $this->mailboxMapper - ->expects(self::exactly(2)) + ->expects(self::once()) ->method('find') ->with($account, $mailbox->getName()) ->willReturn($mailbox); $this->mailboxMapper - ->expects(self::exactly(2)) + ->expects(self::once()) ->method('findById') ->with($mailbox->getId()) ->willReturn($mailbox); @@ -824,9 +685,26 @@ public function testDeleteTrash(): void { ['messageUid' => 200, 'mailboxName' => 'Trash'], ['messageUid' => 300, 'mailboxName' => 'Trash'], ]); - $this->imapMessageMapper - ->expects(self::exactly(2)) - ->method('expunge'); + $this->dbMessageMapper + ->expects(self::once()) + ->method('findByUids') + ->with($mailbox, [200, 300]) + ->willReturn([$message1, $message2]); + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory + ->expects(self::once()) + ->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + $messageConnector + ->expects(self::once()) + ->method('deleteMessages') + ->with($account, $mailbox, $message1, $message2) + ->willReturn([$message1, $message2]); + $this->dbMessageMapper + ->expects(self::once()) + ->method('deleteByUid') + ->with($mailbox, 200, 300); $this->eventDispatcher ->expects(self::exactly(4)) ->method('dispatchTyped'); @@ -837,4 +715,122 @@ public function testDeleteTrash(): void { $threadRootId ); } + + public function testClearMailboxWithoutMessagesDoesNothing(): void { + $account = $this->createMock(Account::class); + $mailbox = new Mailbox(); + $mailbox->setId(1); + $this->dbMessageMapper->expects(self::once()) + ->method('findAllUids') + ->with($mailbox) + ->willReturn([]); + $this->dbMessageMapper->expects(self::never()) + ->method('findByUids'); + $this->protocolFactory->expects(self::never()) + ->method('messageConnector'); + + $this->manager->clearMailbox($account, $mailbox); + } + + public function testClearMailboxMovesAllMessagesToTrash(): void { + $account = $this->createMock(Account::class); + $mailAccount = new MailAccount(); + $mailAccount->setTrashMailboxId(123); + $account->method('getMailAccount')->willReturn($mailAccount); + $inbox = new Mailbox(); + $inbox->setId(1); + $inbox->setName('INBOX'); + $trash = new Mailbox(); + $trash->setId(123); + $trash->setName('Trash'); + $message1 = new Message(); + $message1->setUid(11); + $message2 = new Message(); + $message2->setUid(12); + $this->dbMessageMapper->expects(self::once()) + ->method('findAllUids') + ->with($inbox) + ->willReturn([11, 12]); + $this->dbMessageMapper->expects(self::once()) + ->method('findByUids') + ->with($inbox, [11, 12]) + ->willReturn([$message1, $message2]); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->with(123) + ->willReturn($trash); + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory->expects(self::once()) + ->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + $messageConnector->expects(self::once()) + ->method('moveMessages') + ->with($account, $trash, $inbox, $message1, $message2) + ->willReturn([$message1, $message2]); + $this->dbMessageMapper->expects(self::once()) + ->method('updateBulk'); + + $this->manager->clearMailbox($account, $inbox); + } + + public function testDeleteTagUntagsMessagesGroupedByMailbox(): void { + $account = $this->createMock(Account::class); + $tag = new Tag(); + $tag->setImapLabel('$label1'); + $this->tagMapper->expects(self::once()) + ->method('getTagForUser') + ->with(5, 'user') + ->willReturn($tag); + $messageTag = new MessageTags(); + $messageTag->setImapMessageId('msg@id'); + $this->messageTagsMapper->expects(self::once()) + ->method('getMessagesByTag') + ->with(5) + ->willReturn([$messageTag]); + // two messages of the same tag living in different mailboxes + $messageInInbox = new Message(); + $messageInInbox->setUid(11); + $messageInInbox->setMailboxId(1); + $messageInArchive = new Message(); + $messageInArchive->setUid(22); + $messageInArchive->setMailboxId(2); + $this->dbMessageMapper->expects(self::once()) + ->method('findByMessageId') + ->with($account, 'msg@id') + ->willReturn([$messageInInbox, $messageInArchive]); + $inbox = new Mailbox(); + $inbox->setId(1); + $archive = new Mailbox(); + $archive->setId(2); + $this->mailboxMapper->method('findById') + ->willReturnMap([ + [1, $inbox], + [2, $archive], + ]); + $messageConnector = $this->createMock(IMessageConnector::class); + $this->protocolFactory->method('messageConnector') + ->with($account) + ->willReturn($messageConnector); + // the connector is asked to untag once per mailbox, with that mailbox's messages + $tagCalls = []; + $messageConnector->expects(self::exactly(2)) + ->method('tagMessages') + ->willReturnCallback(function (Account $a, Mailbox $mailbox, Tag $t, bool $value, Message ...$messages) use (&$tagCalls): array { + $tagCalls[$mailbox->getId()] = $messages; + return $messages; + }); + $this->messageTagsMapper->expects(self::once()) + ->method('delete') + ->with($messageTag); + $this->tagMapper->expects(self::once()) + ->method('delete') + ->with($tag) + ->willReturn($tag); + + $this->manager->deleteTag(5, 'user', [$account]); + + self::assertEquals([$messageInInbox], $tagCalls[1]); + self::assertEquals([$messageInArchive], $tagCalls[2]); + } } diff --git a/tests/Unit/Service/MailTransmissionTest.php b/tests/Unit/Service/MailTransmissionTest.php index c5965a380c..a17e8aaac2 100644 --- a/tests/Unit/Service/MailTransmissionTest.php +++ b/tests/Unit/Service/MailTransmissionTest.php @@ -15,7 +15,6 @@ use OCA\Mail\Account; use OCA\Mail\Address; use OCA\Mail\AddressList; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Alias; use OCA\Mail\Db\LocalAttachment; use OCA\Mail\Db\LocalMessage; @@ -25,11 +24,12 @@ use OCA\Mail\Db\Message as DbMessage; use OCA\Mail\Db\Recipient; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper; use OCA\Mail\Model\Message; use OCA\Mail\Model\NewMessageData; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AliasesService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\MailTransmission; use OCA\Mail\Service\TransmissionService; use OCA\Mail\SMTP\SmtpClientFactory; @@ -40,8 +40,8 @@ use Psr\Log\LoggerInterface; class MailTransmissionTest extends TestCase { - private IMAPClientFactory|MockObject $imapClientFactory; - private IMailManager|MockObject $mailManager; + private ProtocolFactory|MockObject $protocolFactory; + private MailManager|MockObject $mailManager; private SmtpClientFactory|MockObject $smtpClientFactory; private IEventDispatcher|MockObject $eventDispatcher; private MailboxMapper|MockObject $mailboxMapper; @@ -55,7 +55,7 @@ class MailTransmissionTest extends TestCase { protected function setUp(): void { parent::setUp(); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->smtpClientFactory = $this->createMock(SmtpClientFactory::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->mailboxMapper = $this->createMock(MailboxMapper::class); @@ -64,10 +64,10 @@ protected function setUp(): void { $this->performanceLogger = $this->createMock(PerformanceLogger::class); $this->aliasService = $this->createMock(AliasesService::class); $this->transmissionService = $this->createMock(TransmissionService::class); - $this->mailManager = $this->createMock(IMailManager::class); + $this->mailManager = $this->createMock(MailManager::class); $this->transmission = new MailTransmission( - $this->imapClientFactory, + $this->protocolFactory, $this->smtpClientFactory, $this->eventDispatcher, $this->mailboxMapper, @@ -347,8 +347,8 @@ public function testSaveDraft() { $message = new Message(); $client = $this->createStub(Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') + $this->protocolFactory->expects($this->once()) + ->method('imapClient') ->with($account) ->willReturn($client); $draftsMailbox = new DbMailbox(); diff --git a/tests/Unit/Service/OutboxServiceTest.php b/tests/Unit/Service/OutboxServiceTest.php index 5534f8bae5..48086feebd 100644 --- a/tests/Unit/Service/OutboxServiceTest.php +++ b/tests/Unit/Service/OutboxServiceTest.php @@ -12,16 +12,16 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OC\EventDispatcher\EventDispatcher; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\LocalAttachment; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\LocalMessageMapper; use OCA\Mail\Db\Recipient; use OCA\Mail\Exception\ClientException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Send\Chain; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\Attachment\AttachmentService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; @@ -45,10 +45,10 @@ class OutboxServiceTest extends TestCase { /** @var AttachmentService|MockObject */ private $attachmentService; - /** @var IMAPClientFactory|MockObject */ - private $clientFactory; + /** @var ProtocolFactory|MockObject */ + private $protocolFactory; - /** @var IMailManager|MockObject */ + /** @var MailManager|MockObject */ private $mailManager; /** @var AccountService|MockObject */ @@ -66,8 +66,8 @@ protected function setUp(): void { $this->mapper = $this->createMock(LocalMessageMapper::class); $this->attachmentService = $this->createMock(AttachmentService::class); - $this->clientFactory = $this->createMock(IMAPClientFactory::class); - $this->mailManager = $this->createMock(IMailManager::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); + $this->mailManager = $this->createMock(MailManager::class); $this->accountService = $this->createMock(AccountService::class); $this->timeFactory = $this->createMock(ITimeFactory::class); $this->logger = $this->createMock(LoggerInterface::class); @@ -76,7 +76,7 @@ protected function setUp(): void { $this->mapper, $this->attachmentService, $this->createMock(EventDispatcher::class), - $this->clientFactory, + $this->protocolFactory, $this->mailManager, $this->accountService, $this->timeFactory, @@ -217,8 +217,8 @@ public function testSaveMessage(): void { ->method('saveWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->clientFactory->expects(self::once()) - ->method('getClient') + $this->protocolFactory->expects(self::once()) + ->method('imapClient') ->with($account) ->willReturn($client); $this->attachmentService->expects(self::once()) @@ -265,8 +265,8 @@ public function testSaveMessageNoAttachments(): void { ->method('saveWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->clientFactory->expects(self::never()) - ->method('getClient'); + $this->protocolFactory->expects(self::never()) + ->method('imapClient'); $this->attachmentService->expects(self::never()) ->method('handleAttachments'); $this->attachmentService->expects(self::never()) @@ -319,8 +319,8 @@ public function testUpdateMessage(): void { ->method('updateWithRecipients') ->with($message, [$rTo], $cc, $bcc) ->willReturn($message2); - $this->clientFactory->expects(self::once()) - ->method('getClient') + $this->protocolFactory->expects(self::once()) + ->method('imapClient') ->with($account) ->willReturn($client); $this->attachmentService->expects(self::once()) @@ -377,8 +377,8 @@ public function testUpdateMessageNoAttachments(): void { $this->attachmentService->expects(self::once()) ->method('updateLocalMessageAttachments') ->with($this->userId, $message2, $attachments); - $this->clientFactory->expects(self::never()) - ->method('getClient'); + $this->protocolFactory->expects(self::never()) + ->method('imapClient'); $this->attachmentService->expects(self::never()) ->method('handleAttachments'); diff --git a/tests/Unit/Service/Search/MailSearchTest.php b/tests/Unit/Service/Search/MailSearchTest.php index 3f7ce83821..66f822a974 100644 --- a/tests/Unit/Service/Search/MailSearchTest.php +++ b/tests/Unit/Service/Search/MailSearchTest.php @@ -17,7 +17,7 @@ use OCA\Mail\Exception\MailboxLockedException; use OCA\Mail\Exception\MailboxNotCachedException; use OCA\Mail\IMAP\PreviewEnhancer; -use OCA\Mail\IMAP\Search\Provider; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\Search\FilterStringParser; use OCA\Mail\Service\Search\Flag; use OCA\Mail\Service\Search\MailSearch; @@ -32,8 +32,8 @@ class MailSearchTest extends TestCase { /** @var MailSearch */ private $search; - /** @var Provider|MockObject */ - private $imapSearchProvider; + /** @var MailManager|MockObject */ + private $mailManager; /** @var PreviewEnhancer|MockObject */ private $previewEnhancer; @@ -48,14 +48,14 @@ protected function setUp(): void { parent::setUp(); $this->filterStringParser = $this->createMock(FilterStringParser::class); - $this->imapSearchProvider = $this->createMock(Provider::class); + $this->mailManager = $this->createMock(MailManager::class); $this->messageMapper = $this->createMock(MessageMapper::class); $this->previewEnhancer = $this->createMock(PreviewEnhancer::class); $this->timeFactory = $this->createMock(ITimeFactory::class); $this->search = new MailSearch( $this->filterStringParser, - $this->imapSearchProvider, + $this->mailManager, $this->messageMapper, $this->previewEnhancer, $this->timeFactory @@ -144,8 +144,8 @@ public function testFindFlagsLocally() { $this->createMock(Message::class), $this->createMock(Message::class), ]); - $this->imapSearchProvider->expects($this->never()) - ->method('findMatches'); + $this->mailManager->expects($this->never()) + ->method('findMessages'); $this->previewEnhancer->expects($this->once()) ->method('process') ->willReturnArgument(2); @@ -180,8 +180,8 @@ public function testFindText() { ->method('parse') ->with('my search') ->willReturn($query); - $this->imapSearchProvider->expects($this->once()) - ->method('findMatches') + $this->mailManager->expects($this->once()) + ->method('findMessages') ->with($account, $mailbox, $query) ->willReturn([2, 3]); $this->messageMapper->expects($this->once()) diff --git a/tests/Unit/Service/SetupServiceTest.php b/tests/Unit/Service/SetupServiceTest.php index 7f47dd8a85..16b9f9a912 100644 --- a/tests/Unit/Service/SetupServiceTest.php +++ b/tests/Unit/Service/SetupServiceTest.php @@ -19,7 +19,7 @@ use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\TagMapper; use OCA\Mail\Exception\CouldNotConnectException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\SetupService; use OCA\Mail\SMTP\SmtpClientFactory; @@ -47,7 +47,7 @@ class SetupServiceTest extends TestCase { private AccountService&MockObject $accountService; private ICrypto&MockObject $crypto; private SmtpClientFactory&MockObject $smtpClientFactory; - private IMAPClientFactory&MockObject $imapClientFactory; + private ProtocolFactory&MockObject $protocolFactory; private LoggerInterface&MockObject $logger; private TagMapper&MockObject $tagMapper; private SetupService $setupService; @@ -58,7 +58,7 @@ protected function setUp(): void { $this->accountService = $this->createMock(AccountService::class); $this->crypto = $this->createMock(ICrypto::class); $this->smtpClientFactory = $this->createMock(SmtpClientFactory::class); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->logger = $this->createMock(LoggerInterface::class); $this->tagMapper = $this->createMock(TagMapper::class); @@ -66,7 +66,7 @@ protected function setUp(): void { $this->accountService, $this->crypto, $this->smtpClientFactory, - $this->imapClientFactory, + $this->protocolFactory, $this->logger, $this->tagMapper ); @@ -77,8 +77,8 @@ private function mockSuccessfulImapConnection(): Horde_Imap_Client_Socket&MockOb $imapClient->expects(self::once())->method('login'); $imapClient->expects(self::once())->method('logout'); - $this->imapClientFactory->expects(self::once()) - ->method('getClient') + $this->protocolFactory->expects(self::once()) + ->method('imapClient') ->willReturn($imapClient); return $imapClient; @@ -209,7 +209,7 @@ public function testCreateNewAccountWithOAuth2(): void { ->method('debug') ->with(self::stringContains('account created ')); - $this->imapClientFactory->expects(self::never())->method('getClient'); + $this->protocolFactory->expects(self::never())->method('imapClient'); $this->smtpClientFactory->expects(self::never())->method('create'); $this->accountService->expects(self::once()) @@ -272,8 +272,8 @@ public function testCreateNewAccountImapConnectionFailure(): void { $imapClient->expects(self::once()) ->method('logout'); - $this->imapClientFactory->expects(self::once()) - ->method('getClient') + $this->protocolFactory->expects(self::once()) + ->method('imapClient') ->willReturn($imapClient); $this->setupService->createNewAccount( diff --git a/tests/Unit/Service/Sync/ImapToDbSynchronizerTest.php b/tests/Unit/Service/Sync/ImapToDbSynchronizerTest.php index cc11db3ba6..ba800d6e2c 100644 --- a/tests/Unit/Service/Sync/ImapToDbSynchronizerTest.php +++ b/tests/Unit/Service/Sync/ImapToDbSynchronizerTest.php @@ -13,17 +13,17 @@ use Horde_Imap_Client_Data_Capability_Imap; use Horde_Imap_Client_Socket; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\MessageMapper as DatabaseMessageMapper; use OCA\Mail\Db\TagMapper; use OCA\Mail\Events\SynchronizationEvent; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; use OCA\Mail\IMAP\Sync\Synchronizer; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Classification\NewMessagesClassifier; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\Sync\ImapToDbSynchronizer; use OCA\Mail\Support\PerformanceLogger; use OCA\Mail\Support\PerformanceLoggerTask; @@ -33,7 +33,7 @@ class ImapToDbSynchronizerTest extends TestCase { private DatabaseMessageMapper&MockObject $dbMapper; - private IMAPClientFactory&MockObject $clientFactory; + private ProtocolFactory&MockObject $protocolFactory; private ImapMessageMapper&MockObject $imapMapper; private MailboxMapper&MockObject $mailboxMapper; private IEventDispatcher&MockObject $dispatcher; @@ -43,7 +43,7 @@ class ImapToDbSynchronizerTest extends TestCase { protected function setUp(): void { parent::setUp(); $this->dbMapper = $this->createMock(DatabaseMessageMapper::class); - $this->clientFactory = $this->createMock(IMAPClientFactory::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->imapMapper = $this->createMock(ImapMessageMapper::class); $this->mailboxMapper = $this->createMock(MailboxMapper::class); $this->dispatcher = $this->createMock(IEventDispatcher::class); @@ -52,7 +52,7 @@ protected function setUp(): void { ->willReturn($this->createStub(PerformanceLoggerTask::class)); $this->synchronizer = new ImapToDbSynchronizer( $this->dbMapper, - $this->clientFactory, + $this->protocolFactory, $this->imapMapper, $this->mailboxMapper, $this->createStub(DatabaseMessageMapper::class), @@ -60,7 +60,7 @@ protected function setUp(): void { $this->dispatcher, $this->performanceLogger, $this->createStub(LoggerInterface::class), - $this->createStub(IMailManager::class), + $this->createStub(MailManager::class), $this->createStub(TagMapper::class), $this->createStub(NewMessagesClassifier::class), ); @@ -85,8 +85,8 @@ public function testInitialSyncGetsSyncTokenFromCacheClient(): void { ->method('getSyncToken') ->with('INBOX') ->willReturn('dG9rZW5XaXRoSA=='); - $this->clientFactory->expects($this->once()) - ->method('getClient') + $this->protocolFactory->expects($this->once()) + ->method('imapClient') ->with($account, false) ->willReturn($noCacheClient); $this->dbMapper->method('findHighestUid')->willReturn(null); diff --git a/tests/Unit/Service/Sync/SyncServiceTest.php b/tests/Unit/Service/Sync/SyncServiceTest.php index c79d418a85..24c31b17d1 100644 --- a/tests/Unit/Service/Sync/SyncServiceTest.php +++ b/tests/Unit/Service/Sync/SyncServiceTest.php @@ -10,14 +10,16 @@ namespace OCA\Mail\Tests\Unit\Service\Sync; use OCA\Mail\Account; +use OCA\Mail\Contracts\IMailboxConnector; +use OCA\Mail\Contracts\IMessageConnector; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MessageMapper; use OCA\Mail\Exception\MailboxNotCachedException; -use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MailboxStats; use OCA\Mail\IMAP\MailboxSync; use OCA\Mail\IMAP\PreviewEnhancer; use OCA\Mail\IMAP\Sync\Response; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\Service\Search\FilterStringParser; use OCA\Mail\Service\Sync\ImapToDbSynchronizer; use OCA\Mail\Service\Sync\SyncService; @@ -26,7 +28,11 @@ final class SyncServiceTest extends TestCase { - private IMAPClientFactory&MockObject $clientFactory; + private ProtocolFactory&MockObject $protocolFactory; + + private IMailboxConnector&MockObject $mailboxConnector; + + private IMessageConnector&MockObject $messageConnector; /** @var ImapToDbSynchronizer */ private $synchronizer; @@ -43,13 +49,20 @@ final class SyncServiceTest extends TestCase { protected function setUp(): void { parent::setUp(); - $this->clientFactory = $this->createMock(IMAPClientFactory::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); + $this->mailboxConnector = $this->createMock(IMailboxConnector::class); + $this->messageConnector = $this->createMock(IMessageConnector::class); $this->synchronizer = $this->createMock(ImapToDbSynchronizer::class); $this->messageMapper = $this->createMock(MessageMapper::class); $this->mailboxSync = $this->createMock(MailboxSync::class); + $this->protocolFactory->method('mailboxConnector') + ->willReturn($this->mailboxConnector); + $this->protocolFactory->method('messageConnector') + ->willReturn($this->messageConnector); + $this->syncService = new SyncService( - $this->clientFactory, + $this->protocolFactory, $this->synchronizer, $this->createStub(FilterStringParser::class), $this->messageMapper, @@ -90,28 +103,24 @@ public function testSyncMailboxReturnsFolderStats(): void { [], new MailboxStats(42, 10, null) ); - $this->clientFactory - ->method('getClient') - ->with($account) - ->willReturn($this->createStub(\Horde_Imap_Client_Socket::class)); $this->messageMapper ->method('findUidsForIds') ->with($mailbox, []) ->willReturn([]); - $this->synchronizer->expects($this->once()) - ->method('sync') + $this->mailboxConnector->expects($this->once()) + ->method('syncOne') + ->with($account, $mailbox); + $this->messageConnector->expects($this->once()) + ->method('syncMailbox') ->with( $account, - $this->createStub(\Horde_Imap_Client_Socket::class), $mailbox, - $this->createStub(\Psr\Log\LoggerInterface::class), + $this->isInstanceOf(\Psr\Log\LoggerInterface::class), 0, [], true - ); - $this->mailboxSync->expects($this->once()) - ->method('syncStats') - ->with($this->createStub(\Horde_Imap_Client_Socket::class), $mailbox); + ) + ->willReturn(new \OCA\Mail\Protocol\SyncResult()); $response = $this->syncService->syncMailbox( $account, diff --git a/tests/Unit/SetupChecks/MailConnectionPerformanceTest.php b/tests/Unit/SetupChecks/MailConnectionPerformanceTest.php index 96a03a1049..9a3fd986dd 100644 --- a/tests/Unit/SetupChecks/MailConnectionPerformanceTest.php +++ b/tests/Unit/SetupChecks/MailConnectionPerformanceTest.php @@ -17,7 +17,7 @@ use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Db\ProvisioningMapper; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Protocol\ProtocolFactory; use OCA\Mail\SetupChecks\MailConnectionPerformance; use OCA\Mail\SetupChecks\MicroTime; use OCP\IL10N; @@ -31,7 +31,7 @@ class MailConnectionPerformanceTest extends TestCase { private TestLogger $logger; private ProvisioningMapper&MockObject $provisioningMapper; private MailAccountMapper&MockObject $accountMapper; - private IMAPClientFactory&MockObject $clientFactory; + private ProtocolFactory&MockObject $protocolFactory; private MicroTime&MockObject $microtime; private MailConnectionPerformance $check; @@ -42,7 +42,7 @@ protected function setUp(): void { $this->logger = new TestLogger(); $this->provisioningMapper = $this->createMock(ProvisioningMapper::class); $this->accountMapper = $this->createMock(MailAccountMapper::class); - $this->clientFactory = $this->createMock(IMAPClientFactory::class); + $this->protocolFactory = $this->createMock(ProtocolFactory::class); $this->microtime = $this->createMock(MicroTime::class); $this->check = new MailConnectionPerformance( @@ -50,7 +50,7 @@ protected function setUp(): void { $this->logger, $this->provisioningMapper, $this->accountMapper, - $this->clientFactory, + $this->protocolFactory, $this->microtime ); } @@ -116,8 +116,8 @@ public function testConnectionSuccess(): void { ->with('INBOX') ->willReturn([]); - $this->clientFactory->expects($this->once()) - ->method('getClient') + $this->protocolFactory->expects($this->once()) + ->method('imapClient') ->with(new Account($account)) ->willReturn($client); @@ -166,8 +166,8 @@ public function testConnectionWarning(): void { ->with('INBOX') ->willReturn([]); - $this->clientFactory->expects($this->once()) - ->method('getClient') + $this->protocolFactory->expects($this->once()) + ->method('imapClient') ->with(new Account($account)) ->willReturn($client); @@ -207,8 +207,8 @@ public function testConnectionFailure(): void { ->with(42) ->willReturn($account); - $this->clientFactory->expects($this->once()) - ->method('getClient') + $this->protocolFactory->expects($this->once()) + ->method('imapClient') ->with(new Account($account)) ->willReturn($client); @@ -244,8 +244,8 @@ public function testClientFailure(): void { ->method('findById') ->with(42) ->willReturn($account); - $this->clientFactory->expects($this->once()) - ->method('getClient') + $this->protocolFactory->expects($this->once()) + ->method('imapClient') ->with(new Account($account)) ->willThrowException(new ServiceException('Something about decryption'));