diff --git a/appinfo/routes.php b/appinfo/routes.php index d70a83e257..2976230c9c 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -226,7 +226,7 @@ 'verb' => 'GET' ], [ - 'name' => 'messages#getSource', + 'name' => 'messages#getRawMessage', 'url' => '/api/messages/{id}/source', 'verb' => 'GET' ], diff --git a/composer.json b/composer.json index d37061ebe6..970f83b124 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,12 @@ "optimize-autoloader": true, "autoloader-suffix": "Mail" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/sebastiankrupinski/jmap-client-php" + } + ], "require": { "php": ">=8.1 <=8.4", "ext-openssl": "*", diff --git a/composer.lock b/composer.lock index badbda65f7..60b7ae3c20 100644 --- a/composer.lock +++ b/composer.lock @@ -4647,7 +4647,9 @@ ], "minimum-stability": "stable", "stability-flags": { + "sebastiankrupinski/jmap-client-php": 20, "gravatarphp/gravatar": 20, + "sebastiankrupinski/jmap-client-php": 20, "roave/security-advisories": 20 }, "prefer-stable": false, diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 71b50051c4..1deb51fdec 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -16,7 +16,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; @@ -62,7 +61,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; @@ -120,7 +118,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 0f6ffabfe6..5eae1d7caf 100644 --- a/lib/BackgroundJob/ContextChat/SubmitContentJob.php +++ b/lib/BackgroundJob/ContextChat/SubmitContentJob.php @@ -15,7 +15,6 @@ use OCA\Mail\Db\MessageMapper; 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 +35,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, @@ -109,53 +107,41 @@ protected function run($argument): void { } - $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 $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 4856aa7d5d..d684334193 100644 --- a/lib/BackgroundJob/FollowUpClassifierJob.php +++ b/lib/BackgroundJob/FollowUpClassifierJob.php @@ -9,12 +9,12 @@ 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\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; @@ -30,7 +30,7 @@ public function __construct( ITimeFactory $time, private LoggerInterface $logger, private AccountService $accountService, - private IMailManager $mailManager, + private MailManager $mailManager, private AiIntegrationsService $aiService, private ThreadMapper $threadMapper, ) { @@ -54,7 +54,7 @@ public function run($argument): void { return; } - $messages = $this->mailManager->getByMessageId($account, $messageId); + $messages = $this->mailManager->getMessagesByMessageId($account, $messageId); $messages = array_filter( $messages, static fn (Message $message) => $message->getMailboxId() === $mailboxId, @@ -96,12 +96,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 57a14827f5..516dc1ef42 100644 --- a/lib/BackgroundJob/MigrateImportantJob.php +++ b/lib/BackgroundJob/MigrateImportantJob.php @@ -14,8 +14,8 @@ 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; @@ -28,7 +28,7 @@ class MigrateImportantJob extends QueuedJob { private MailManager $mailManager; private MigrateImportantFromImapAndDb $migration; private LoggerInterface $logger; - private IMAPClientFactory $imapClientFactory; + private ProtocolFactory $protocolFactory; public function __construct(MailboxMapper $mailboxMapper, MailAccountMapper $mailAccountMapper, @@ -36,7 +36,7 @@ public function __construct(MailboxMapper $mailboxMapper, MigrateImportantFromImapAndDb $migration, LoggerInterface $logger, ITimeFactory $timeFactory, - IMAPClientFactory $imapClientFactory, + ProtocolFactory $protocolFactory, ) { parent::__construct($timeFactory); $this->mailboxMapper = $mailboxMapper; @@ -44,7 +44,7 @@ public function __construct(MailboxMapper $mailboxMapper, $this->mailManager = $mailManager; $this->migration = $migration; $this->logger = $logger; - $this->imapClientFactory = $imapClientFactory; + $this->protocolFactory = $protocolFactory; } /** @@ -71,10 +71,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 a067f5057e..3ade90def8 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; @@ -22,7 +22,7 @@ class QuotaJob extends TimedJob { private IUserManager $userManager; private AccountService $accountService; - private IMailManager $mailManager; + private MailManager $mailManager; private LoggerInterface $logger; private IJobList $jobList; private IManager $notificationManager; @@ -30,7 +30,7 @@ class QuotaJob extends TimedJob { public function __construct(ITimeFactory $time, IUserManager $userManager, AccountService $accountService, - IMailManager $mailManager, + MailManager $mailManager, IManager $notificationManager, LoggerInterface $logger, IJobList $jobList) { diff --git a/lib/BackgroundJob/RepairSyncJob.php b/lib/BackgroundJob/RepairSyncJob.php index 4d843af8d3..b915f77e8c 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, @@ -65,6 +68,19 @@ protected function run($argument): void { return; } + $this->protocolFactory + ->mailboxConnector($account) + ->syncAll($account, true); + + 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; + } + $rebuildThreads = false; $trashMailboxId = $account->getMailAccount()->getTrashMailboxId(); $snoozeMailboxId = $account->getMailAccount()->getSnoozeMailboxId(); diff --git a/lib/BackgroundJob/SyncJob.php b/lib/BackgroundJob/SyncJob.php index a747ff2103..62f5035a67 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; @@ -31,8 +31,6 @@ class SyncJob extends TimedJob { private IUserManager $userManager; private AccountService $accountService; - private ImapToDbSynchronizer $syncService; - private MailboxSync $mailboxSync; private LoggerInterface $logger; private IJobList $jobList; private readonly bool $forcedSyncInterval; @@ -41,8 +39,7 @@ public function __construct( ITimeFactory $time, IUserManager $userManager, AccountService $accountService, - MailboxSync $mailboxSync, - ImapToDbSynchronizer $syncService, + private ProtocolFactory $protocolFactory, LoggerInterface $logger, IJobList $jobList, private readonly IConfig $config, @@ -51,8 +48,6 @@ public function __construct( $this->userManager = $userManager; $this->accountService = $accountService; - $this->syncService = $syncService; - $this->mailboxSync = $mailboxSync; $this->logger = $logger; $this->jobList = $jobList; @@ -83,7 +78,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; } @@ -124,8 +120,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, true); } 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 64f81fa27d..72d6ed2b52 100644 --- a/lib/Command/SyncAccount.php +++ b/lib/Command/SyncAccount.php @@ -12,9 +12,8 @@ use OCA\Mail\Account; 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 OCA\Mail\Support\ConsoleLoggerDecorator; use OCP\AppFramework\Db\DoesNotExistException; use Psr\Log\LoggerInterface; @@ -31,19 +30,16 @@ final class SyncAccount extends Command { public const OPTION_FORCE = 'force'; private AccountService $accountService; - private MailboxSync $mailboxSync; - private ImapToDbSynchronizer $syncService; + private ProtocolFactory $protocolFactory; private LoggerInterface $logger; public function __construct(AccountService $service, - MailboxSync $mailboxSync, - ImapToDbSynchronizer $messageSync, + ProtocolFactory $protocolFactory, LoggerInterface $logger) { parent::__construct(); $this->accountService = $service; - $this->mailboxSync = $mailboxSync; - $this->syncService = $messageSync; + $this->protocolFactory = $protocolFactory; $this->logger = $logger; } @@ -52,7 +48,7 @@ public function __construct(AccountService $service, */ 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); } @@ -84,8 +80,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; diff --git a/lib/Command/TestAccount.php b/lib/Command/TestAccount.php index 32abbba9dd..a2ca5e0e4e 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,75 +69,135 @@ 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(); @@ -112,42 +205,278 @@ private function testJmap(\OCA\Mail\Account $account, OutputInterface $output): $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), 'No recent messages found in INBOX.'); + } + + /** + * @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, 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(), + null, + [['attribute' => 'received', 'direction' => true]], + ['anchor' => 'absolute', 'position' => 0, 'tally' => $messageLimit] + ); + + /** @var list $messageList */ + $messageList = $messages['list'] ?? []; + $this->renderMessageTable($io, $this->buildJmapMessageRows($messageList), 'No recent messages found in INBOX.'); + } + + /** + * @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, string $emptyMessage): void { + if ($rows === []) { + $io->note($emptyMessage); + 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 @@ -mailManager = $mailManager; $this->accountService = $accountService; - $this->clientFactory = $clientFactory; $this->request = $request; $this->httpClientService = $httpClientService; $this->logger = $logger; @@ -66,13 +62,11 @@ public function unsubscribe(int $id): JsonResponse { return JsonResponse::fail(null, Http::STATUS_NOT_FOUND); } - $client = $this->clientFactory->getClient($account); try { $imapMessage = $this->mailManager->getImapMessage( - $client, $account, $mailbox, - $message->getUid(), + $message, true ); $unsubscribeUrl = $imapMessage->getUnsubscribeUrl(); @@ -91,8 +85,6 @@ public function unsubscribe(int $id): JsonResponse { 'exception' => $e, ]); return JsonResponse::error('Unknown error'); - } finally { - $client->logout(); } return JsonResponse::success(); diff --git a/lib/Controller/MailboxesApiController.php b/lib/Controller/MailboxesApiController.php index 106a8892e2..5adc0b91da 100644 --- a/lib/Controller/MailboxesApiController.php +++ b/lib/Controller/MailboxesApiController.php @@ -9,10 +9,10 @@ namespace OCA\Mail\Controller; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\ResponseDefinitions; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; @@ -30,7 +30,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, ) { diff --git a/lib/Controller/MailboxesController.php b/lib/Controller/MailboxesController.php index d391f2dee6..605d22b9a8 100644 --- a/lib/Controller/MailboxesController.php +++ b/lib/Controller/MailboxesController.php @@ -12,7 +12,6 @@ use Horde_Imap_Client; use OCA\Mail\AppInfo\Application; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\IncompleteSyncException; @@ -21,6 +20,7 @@ use OCA\Mail\Exception\ServiceException; use OCA\Mail\Http\TrapError; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\Sync\SyncService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -35,7 +35,7 @@ #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class MailboxesController extends Controller { private AccountService $accountService; - private IMailManager $mailManager; + private MailManager $mailManager; private SyncService $syncService; private ?string $currentUserId; @@ -44,7 +44,7 @@ public function __construct( IRequest $request, AccountService $accountService, ?string $userId, - IMailManager $mailManager, + MailManager $mailManager, SyncService $syncService, private readonly IConfig $config, private readonly ITimeFactory $timeFactory, @@ -120,10 +120,8 @@ public function patch(int $id, ); } if ($syncInBackground !== null) { - $mailbox = $this->mailManager->enableMailboxBackgroundSync( - $mailbox, - $syncInBackground - ); + $mailbox->setSyncInBackground($syncInBackground); + $this->mailboxMapper->update($mailbox); } return new JSONResponse($mailbox); diff --git a/lib/Controller/MessageApiController.php b/lib/Controller/MessageApiController.php index e0f96c0aec..fb68e1205f 100644 --- a/lib/Controller/MessageApiController.php +++ b/lib/Controller/MessageApiController.php @@ -14,7 +14,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, @@ -243,13 +241,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) { @@ -259,13 +255,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); @@ -334,19 +327,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, + $source = $this->mailManager->getRawMessage( $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 979d8c38d9..c1341b4c69 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,11 +24,11 @@ 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\ItineraryService; +use OCA\Mail\Service\MailManager; use OCA\Mail\Service\SmimeService; use OCA\Mail\Service\SnoozeService; use OCP\AppFramework\Controller; @@ -57,7 +56,7 @@ #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class MessagesController extends Controller { private AccountService $accountService; - private IMailManager $mailManager; + private MailManager $mailManager; private IMailSearch $mailSearch; private ItineraryService $itineraryService; private ?string $currentUserId; @@ -70,7 +69,6 @@ class MessagesController extends Controller { private ITrustedSenderService $trustedSenderService; private IMailTransmission $mailTransmission; private SmimeService $smimeService; - private IMAPClientFactory $clientFactory; private IDkimService $dkimService; private IUserPreferences $preferences; private SnoozeService $snoozeService; @@ -80,7 +78,7 @@ public function __construct( string $appName, IRequest $request, AccountService $accountService, - IMailManager $mailManager, + MailManager $mailManager, IMailSearch $mailSearch, ItineraryService $itineraryService, ?string $userId, @@ -93,7 +91,6 @@ public function __construct( ITrustedSenderService $trustedSenderService, IMailTransmission $mailTransmission, SmimeService $smimeService, - IMAPClientFactory $clientFactory, IDkimService $dkimService, IUserPreferences $preferences, SnoozeService $snoozeService, @@ -115,7 +112,6 @@ public function __construct( $this->trustedSenderService = $trustedSenderService; $this->mailTransmission = $mailTransmission; $this->smimeService = $smimeService; - $this->clientFactory = $clientFactory; $this->dkimService = $dkimService; $this->preferences = $preferences; $this->snoozeService = $snoozeService; @@ -237,24 +233,19 @@ 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); } + $json = $imapMessage->getFullMessage($id); + $itineraries = $this->itineraryService->getCached($account, $mailbox, $message->getUid()); if ($itineraries) { $json['itineraries'] = $itineraries; @@ -309,7 +300,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; } @@ -331,7 +322,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; } @@ -412,10 +403,10 @@ public function move(int $id, int $destFolderId): JSONResponse { $this->mailManager->moveMessage( $srcAccount, - $srcMailbox->getName(), - $message->getUid(), + $srcMailbox, + $message, $dstAccount, - $dstMailbox->getName() + $dstMailbox ); return new JSONResponse(); } @@ -505,7 +496,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; @@ -521,7 +512,7 @@ public function mdn(int $id): JSONResponse { * @throws ServiceException */ #[TrapError] - public function getSource(int $id): JSONResponse { + public function getRawMessage(int $id): JSONResponse { if ($this->currentUserId === null) { return new JSONResponse([], Http::STATUS_UNAUTHORIZED); } @@ -533,19 +524,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->getRawMessage( + $account, + $mailbox, + $message + ) + ]); // Enable caching $response->cacheFor(60 * 60, false, true); @@ -577,17 +562,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->getRawMessage( + $account, + $mailbox, + $message + ); return new AttachmentDownloadResponse( $source ?? '', @@ -873,7 +852,7 @@ public function setFlags(int $id, array $flags): JSONResponse { 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); } return new JSONResponse(); } @@ -903,12 +882,12 @@ public function setTag(int $id, string $imapLabel): JSONResponse { } try { - $tag = $this->mailManager->getTagByImapLabel($imapLabel, $this->currentUserId); + $tag = $this->mailManager->getTagByLabel($imapLabel, $this->currentUserId); } 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); return new JSONResponse($tag); } @@ -937,12 +916,12 @@ public function removeTag(int $id, string $imapLabel): JSONResponse { } try { - $tag = $this->mailManager->getTagByImapLabel($imapLabel, $this->currentUserId); + $tag = $this->mailManager->getTagByLabel($imapLabel, $this->currentUserId); } 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); return new JSONResponse($tag); } @@ -971,8 +950,8 @@ public function destroy(int $id): JSONResponse { $this->mailManager->deleteMessage( $account, - $mailbox->getName(), - $message->getUid() + $mailbox, + $message ); return new JSONResponse(); } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index cb84aae360..c963775517 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -12,7 +12,6 @@ use OCA\Contacts\Event\LoadContactsOcaApiEvent; use OCA\Mail\AppInfo\Application; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IUserPreferences; use OCA\Mail\Db\SmimeCertificate; use OCA\Mail\Db\TagMapper; @@ -22,6 +21,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; @@ -61,7 +61,7 @@ class PageController extends Controller { private ?string $currentUserId; private IUserSession $userSession; private IUserPreferences $preferences; - private IMailManager $mailManager; + private MailManager $mailManager; private TagMapper $tagMapper; private IInitialState $initialStateService; private LoggerInterface $logger; @@ -86,7 +86,7 @@ public function __construct( ?string $userId, IUserSession $userSession, IUserPreferences $preferences, - IMailManager $mailManager, + MailManager $mailManager, TagMapper $tagMapper, IInitialState $initialStateService, LoggerInterface $logger, diff --git a/lib/Controller/TagsController.php b/lib/Controller/TagsController.php index 78485d82c1..047eb3fc03 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; @@ -24,14 +24,14 @@ #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class TagsController extends Controller { private string $currentUserId; - private IMailManager $mailManager; + private MailManager $mailManager; private AccountService $accountService; public function __construct(IRequest $request, string $userId, - IMailManager $mailManager, + MailManager $mailManager, AccountService $accountService, ) { parent::__construct(Application::APP_ID, $request); diff --git a/lib/Controller/ThreadController.php b/lib/Controller/ThreadController.php index 89c90127c1..5b6d834649 100755 --- a/lib/Controller/ThreadController.php +++ b/lib/Controller/ThreadController.php @@ -9,12 +9,12 @@ 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\MailManager; use OCA\Mail\Service\SnoozeService; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; @@ -28,7 +28,7 @@ class ThreadController extends Controller { private string $currentUserId; private AccountService $accountService; - private IMailManager $mailManager; + private MailManager $mailManager; private SnoozeService $snoozeService; private AiIntegrationsService $aiIntergrationsService; private LoggerInterface $logger; @@ -38,7 +38,7 @@ public function __construct(string $appName, IRequest $request, string $userId, AccountService $accountService, - IMailManager $mailManager, + MailManager $mailManager, SnoozeService $snoozeService, AiIntegrationsService $aiIntergrationsService, LoggerInterface $logger) { diff --git a/lib/Db/Mailbox.php b/lib/Db/Mailbox.php index acbf4c929d..79f92ac9b3 100644 --- a/lib/Db/Mailbox.php +++ b/lib/Db/Mailbox.php @@ -127,7 +127,8 @@ public function isSpecialUse(string $specialUse): bool { public function isCached(): bool { return $this->getSyncNewToken() !== null && $this->getSyncChangedToken() !== null - && $this->getSyncVanishedToken() !== null; + && $this->getSyncVanishedToken() !== null + || $this->getState() !== null; } public function hasLocks(int $now): bool { diff --git a/lib/Db/MailboxMapper.php b/lib/Db/MailboxMapper.php index 36ebdfbbe3..363fc7c2ed 100644 --- a/lib/Db/MailboxMapper.php +++ b/lib/Db/MailboxMapper.php @@ -157,6 +157,38 @@ public function findByUid(int $id, string $uid): Mailbox { } } + /** + * @throws DoesNotExistException + * @throws ServiceException + */ + public function findByName(Account $account, string $name): Mailbox { + $qb = $this->db->getQueryBuilder(); + + $select = $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('account_id', $qb->createNamedParameter($account->getId())), + $qb->expr()->eq('name', $qb->createNamedParameter($name)) + ); + + try { + return $this->findEntity($select); + } catch (MultipleObjectsReturnedException $e) { + // Not possible due to DB constraints + throw new ServiceException('The impossible has happened', 42, $e); + } + } + + 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/MessageMapper.php b/lib/Db/MessageMapper.php index ce29c480ed..1f4699f471 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -262,6 +262,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')); @@ -289,6 +290,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); @@ -743,6 +745,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/Db/TagMapper.php b/lib/Db/TagMapper.php index 0c187d1441..d2729472b0 100644 --- a/lib/Db/TagMapper.php +++ b/lib/Db/TagMapper.php @@ -80,6 +80,7 @@ public function tagMessage(Tag $tag, string $messageId, string $userId): void { try { $tag = $this->getTagByImapLabel($tag->getImapLabel(), $userId); } catch (DoesNotExistException $e) { + $tag->setUserId($userId); $tag = $this->insert($tag); } diff --git a/lib/IMAP/ImapMailboxConnector.php b/lib/IMAP/ImapMailboxConnector.php new file mode 100644 index 0000000000..94450da1c9 --- /dev/null +++ b/lib/IMAP/ImapMailboxConnector.php @@ -0,0 +1,115 @@ +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); + } 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..10b63ba94d --- /dev/null +++ b/lib/IMAP/ImapMessageConnector.php @@ -0,0 +1,429 @@ +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, + ], + ); + } + + #[\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 + ); + } 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[] + */ + #[\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(), + ); + } finally { + $client->logout(); + } + } + + #[\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(), + ); + } 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 = []; + foreach ($messages as $message) { + try { + $newUid = $this->imapMessageMapper->move($client, $sourceMailbox->getName(), $message->getUid(), $targetMailbox->getName()); + $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(), + ]); + } + } + + $client->logout(); + + return $mutatedMessages; + } + + #[\Override] + public function deleteMessages(Account $account, Mailbox $mailbox, Message ...$messages): array { + if ($messages === []) { + return []; + } + $client = $this->protocolFactory->imapClient($account); + + $mutatedMessages = []; + 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(), + ]); + } + } + + $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); + } + + $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); + + if ($this->isPermflagsEnabledWithClient($client, $mailbox->getName()) === false) { + $this->logger->error('Cannot set message keyword, server does not support permanent flags', ['tag' => $tag->getName()]); + 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); + } + + 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; + } 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); + return $this->isPermflagsEnabledWithClient($client, $mailbox->getName()); + } + + 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()); + $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 d346b26304..2f9142efc8 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; @@ -48,9 +49,6 @@ class MailboxSync { /** @var MailAccountMapper */ private $mailAccountMapper; - /** @var IMAPClientFactory */ - private $imapClientFactory; - /** @var ITimeFactory */ private $timeFactory; @@ -58,17 +56,18 @@ class MailboxSync { private $dispatcher; private IDBConnection $dbConnection; - public function __construct(MailboxMapper $mailboxMapper, + public function __construct( + MailboxMapper $mailboxMapper, FolderMapper $folderMapper, MailAccountMapper $mailAccountMapper, - IMAPClientFactory $imapClientFactory, + private readonly ProtocolFactory $protocolFactory, ITimeFactory $timeFactory, IEventDispatcher $dispatcher, - IDBConnection $dbConnection) { + IDBConnection $dbConnection, + ) { $this->mailboxMapper = $mailboxMapper; $this->folderMapper = $folderMapper; $this->mailAccountMapper = $mailAccountMapper; - $this->imapClientFactory = $imapClientFactory; $this->timeFactory = $timeFactory; $this->dispatcher = $dispatcher; $this->dbConnection = $dbConnection; @@ -81,13 +80,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 36a1cc2d80..8e01cd1bb4 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; @@ -25,9 +27,6 @@ use function array_reduce; class PreviewEnhancer { - /** @var IMAPClientFactory */ - private $clientFactory; - /** @var ImapMapper */ private $imapMapper; @@ -41,14 +40,13 @@ class PreviewEnhancer { private $avatarService; public function __construct( - IMAPClientFactory $clientFactory, + private readonly ProtocolFactory $protocolFactory, ImapMapper $imapMapper, DbMapper $dbMapper, LoggerInterface $logger, AvatarService $avatarService, private AttachmentService $attachmentService, ) { - $this->clientFactory = $clientFactory; $this->imapMapper = $imapMapper; $this->mapper = $dbMapper; $this->logger = $logger; @@ -69,10 +67,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); } @@ -97,7 +94,19 @@ public function process(Account $account, Mailbox $mailbox, array $messages, boo return $messages; } + if ($account->getMailAccount()->getProtocol() !== MailAccount::PROTOCOL_IMAP) { + 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 35133f14d2..0000000000 --- a/lib/IMAP/Search/Provider.php +++ /dev/null @@ -1,68 +0,0 @@ -clientFactory = $clientFactory; - } - - /** - * @return int[] - * @throws ServiceException - */ - public function findMatches(Account $account, - Mailbox $mailbox, - SearchQuery $searchQuery): array { - $client = $this->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/JmapClientFactory.php b/lib/JMAP/JmapClientFactory.php index fe1624aaeb..e10a66617c 100644 --- a/lib/JMAP/JmapClientFactory.php +++ b/lib/JMAP/JmapClientFactory.php @@ -64,7 +64,7 @@ public function getClient(Account $account): JmapClient { } $client = new JmapClient(); - $client->configureTransportMode($secure ? 'https' : 'http'); + $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..c2e6031808 --- /dev/null +++ b/lib/JMAP/JmapMailboxAdapter.php @@ -0,0 +1,182 @@ +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', 'rights']; + 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); + } + if (in_array('rights', $properties, true)) { + $acls = $mailbox->getMyAcls(); + $request->rights(new MailboxRights( + readItems: str_contains($acls ?? '', 'l') || str_contains($acls ?? '', 'r') || str_contains($acls ?? '', 'a'), + addItems: str_contains($acls ?? '', 'i') || str_contains($acls ?? '', 'a'), + removeItems: str_contains($acls ?? '', 't') || str_contains($acls ?? '', 'e') || str_contains($acls ?? '', 'a'), + setSeen: str_contains($acls ?? '', 's') || str_contains($acls ?? '', 'a'), + setKeywords: str_contains($acls ?? '', 'w') || str_contains($acls ?? '', 'a'), + createChild: str_contains($acls ?? '', 'k') || str_contains($acls ?? '', 'a'), + rename: str_contains($acls ?? '', 'x') || str_contains($acls ?? '', 'a'), + delete: str_contains($acls ?? '', 'x') || str_contains($acls ?? '', 'a'), + submit: str_contains($acls ?? '', 'p') || str_contains($acls ?? '', 'a'), + )); + } + + 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; + } + + $acls = ''; + if ($rights->readItems() === true) { + $acls .= 'lr'; + } + if ($rights->addItems() === true) { + $acls .= 'i'; + } + if ($rights->removeItems() === true) { + $acls .= 'te'; + } + if ($rights->setSeen() === true) { + $acls .= 's'; + } + if ($rights->setKeywords() === true) { + $acls .= 'w'; + } + if ($rights->createChild() === true) { + $acls .= 'k'; + } + if ($rights->rename() === true || $rights->delete() === true) { + $acls .= 'x'; + } + if ($rights->submit() === true) { + $acls .= 'p'; + } + if ($rights->createChild() === true && $rights->rename() === true && $rights->delete() === true) { + $acls .= 'a'; + } + + return $acls === '' ? null : $acls; + } + +} diff --git a/lib/JMAP/JmapMailboxConnector.php b/lib/JMAP/JmapMailboxConnector.php new file mode 100644 index 0000000000..78d0460a27 --- /dev/null +++ b/lib/JMAP/JmapMailboxConnector.php @@ -0,0 +1,315 @@ +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, $this->logger); + + // create or update mailboxes locally that are present remotely + foreach ($remoteMailboxes as $remoteMailbox) { + $remoteMailboxName = $remoteMailboxNames[$remoteMailbox->getRemoteId()] ?? $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); + } else { + $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 { + if ($mailbox->getRemoteId() === null) { + throw new ServiceException('JMAP mailbox is missing a remote id'); + } + + $this->jmapOperationsService->connect($account); + + $remoteMailbox = $this->jmapOperationsService->collectionFetch($mailbox->getRemoteId()); + $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->findByName($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 = $this->mailboxMapper->insert($mailbox); + + return $mailbox; + } + + #[\Override] + public function rename(Account $account, Mailbox $mailbox, string $newName): Mailbox { + if ($mailbox->getRemoteId() === 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($mailbox->getRemoteId(), $mailbox, ['name']); + if ($mailbox === null) { + throw new ServiceException('JMAP mailbox rename failed'); + } + // update local store, with the full path name + try { + $mailbox->setName($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 { + if ($mailbox->getRemoteId() === null) { + throw new ServiceException('JMAP mailbox is missing a remote id'); + } + + $this->jmapOperationsService->connect($account); + + // delete from remote store + $result = $this->jmapOperationsService->collectionDestroy($mailbox->getRemoteId()); + 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 { + if ($mailbox->getRemoteId() === 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($mailbox->getRemoteId(), $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..67e7e106c7 --- /dev/null +++ b/lib/JMAP/JmapMessageAdapter.php @@ -0,0 +1,375 @@ +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(is_array($keywords) ? $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); + $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() ?? []), + (string)($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((string)($part->type() ?? '')); + $disposition = strtolower((string)($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' => (int)($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..b35b9a8040 --- /dev/null +++ b/lib/JMAP/JmapMessageConnector.php @@ -0,0 +1,437 @@ +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->syncMessages($account, $mailbox, $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(syncToken: $mailbox->getSyncChangedToken()); + } + + // 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 + foreach ($remoteMessages as $remoteId => $remoteMessage) { + $remoteMessages[$remoteId] = $this->jmapMessageAdapter->convertToModelMessage($remoteMessage, $messages[$remoteId]->getUid(), $loadBody); + } + return $remoteMessages; + } + + #[\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, + 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 { + if ($targetMailbox->getRemoteId() === 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($targetMailbox->getRemoteId(), ...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 ($trashMailbox->getRemoteId() === null) { + throw new ServiceException("Trash mailbox {$trashMailbox->getId()} does not have a remote id"); + } + $messages = $this->mapMessagesByRemoteId(...$messages); + // update remote store + $this->jmapOperationsService->connect($account); + $this->jmapOperationsService->entityDelete(...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 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->syncMessages($account, $mailbox, true); + } + + #[\Override] + public function isPermflagsEnabled(Account $account, Mailbox $mailbox): bool { + return true; + } + + private function convertSearchQueryToFilters(SearchQuery $searchQuery): array { + $filters = []; + foreach ($searchQuery->getBodies() as $textToken) { + $filters[] = [ + 'attribute' => 'body', + 'value' => $textToken, + ]; + } + + return $filters; + } + + 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 0c41a21c44..2a40e9695c 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; @@ -31,8 +31,8 @@ * @template-implements IEventListener */ class DeleteDraftListener implements IEventListener { - /** @var IMAPClientFactory */ - private $imapClientFactory; + /** @var ProtocolFactory */ + private $protocolFactory; /** @var MailboxMapper */ private $mailboxMapper; @@ -46,12 +46,12 @@ class DeleteDraftListener implements IEventListener { /** @var IEventDispatcher */ private $eventDispatcher; - public function __construct(IMAPClientFactory $imapClientFactory, + public function __construct(ProtocolFactory $protocolFactory, MailboxMapper $mailboxMapper, MessageMapper $messageMapper, LoggerInterface $logger, IEventDispatcher $eventDispatcher) { - $this->imapClientFactory = $imapClientFactory; + $this->protocolFactory = $protocolFactory; $this->mailboxMapper = $mailboxMapper; $this->messageMapper = $messageMapper; $this->logger = $logger; @@ -70,7 +70,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 3f1cd56456..93b4a08d77 100644 --- a/lib/Model/IMAPMessage.php +++ b/lib/Model/IMAPMessage.php @@ -494,6 +494,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..331052297c 100644 --- a/lib/Protocol/ProtocolFactory.php +++ b/lib/Protocol/ProtocolFactory.php @@ -18,7 +18,12 @@ 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\JmapClientFactory; +use OCA\Mail\JMAP\JmapMailboxConnector; +use OCA\Mail\JMAP\JmapMessageConnector; use Psr\Container\ContainerInterface; class ProtocolFactory { @@ -27,16 +32,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 +67,28 @@ public function jmapClient(Account $account): JmapClient { return $this->jmapClientFactory->getClient($account); } + /** + * @throws ServiceException + */ + 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 */ diff --git a/lib/Send/Chain.php b/lib/Send/Chain.php index a55aeebbee..43add7b222 100644 --- a/lib/Send/Chain.php +++ b/lib/Send/Chain.php @@ -11,7 +11,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; @@ -24,7 +24,7 @@ public function __construct( private FlagRepliedMessageHandler $flagRepliedMessageHandler, private AttachmentService $attachmentService, private LocalMessageMapper $localMessageMapper, - private IMAPClientFactory $clientFactory, + private ProtocolFactory $protocolFactory, ) { } @@ -49,7 +49,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 e54b99214f..6632f216d4 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -21,7 +21,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; @@ -46,21 +46,21 @@ class AccountService { /** @var IJobList */ private $jobList; - /** @var IMAPClientFactory */ - private $imapClientFactory; + /** @var ProtocolFactory */ + private $protocolFactory; public function __construct( MailAccountMapper $mapper, AliasesService $aliasesService, IJobList $jobList, - IMAPClientFactory $imapClientFactory, + ProtocolFactory $protocolFactory, private readonly IConfig $config, private readonly ITimeFactory $timeFactory, ) { $this->mapper = $mapper; $this->aliasesService = $aliasesService; $this->jobList = $jobList; - $this->imapClientFactory = $imapClientFactory; + $this->protocolFactory = $protocolFactory; } /** @@ -237,8 +237,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 49243344cc..d7054a9d2a 100644 --- a/lib/Service/AiIntegrations/AiIntegrationsService.php +++ b/lib/Service/AiIntegrations/AiIntegrationsService.php @@ -12,13 +12,12 @@ use JsonException; use OCA\Mail\Account; use OCA\Mail\AppInfo\Application; -use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; 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\IL10N; use OCP\IUserManager; @@ -49,8 +48,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 TextProcessingManager $textProcessingManager, private IL10N $l, @@ -76,51 +74,44 @@ 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; - } - // construct prompt and task - $messageBody = $message->getPlainBody(); - $prompt = "You are tasked with formulating a helpful summary of a email message. \r\n" - . 'The summary should be in the language of this language code ' . $language . ". \r\n" - . "The summary should be less than 160 characters. \r\n" - . "Output *ONLY* the summary itself, leave out any introduction. \r\n" - . "Here is the ***E-MAIL*** for which you must generate a helpful summary: \r\n" - . "***START_OF_E-MAIL***\r\n$messageBody\r\n***END_OF_E-MAIL***\r\n"; - $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; } - } finally { - $client->logout(); + // 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; + } + // construct prompt and task + $messageBody = $message->getPlainBody(); + $prompt = "You are tasked with formulating a helpful summary of a email message. \r\n" + . 'The summary should be in the language of this language code ' . $language . ". \r\n" + . "The summary should be less than 160 characters. \r\n" + . "Output *ONLY* the summary itself, leave out any introduction. \r\n" + . "Here is the ***E-MAIL*** for which you must generate a helpful summary: \r\n" + . "***START_OF_E-MAIL***\r\n$messageBody\r\n***END_OF_E-MAIL***\r\n"; + $task = new TaskProcessingTask( + TextToText::ID, + [ + 'max_tokens' => 1024, + 'input' => $prompt, + ], + Application::APP_ID, + $userId, + 'message:' . (string)$messageLocalId + ); + $this->taskProcessingManager->scheduleTask($task); } } @@ -141,22 +132,16 @@ 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) { - $mailbox = $this->mailManager->getMailbox($currentUserId, $message->getMailboxId()); - $imapMessage = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), true - ); - return $imapMessage->getPlainBody(); - }, $messages); - - } finally { - $client->logout(); - } + $messagesBodies = array_map(function ($message) use ($account, $currentUserId) { + $mailbox = $this->mailManager->getMailbox($currentUserId, $message->getMailboxId()); + $imapMessage = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message, + true + ); + return $imapMessage->getPlainBody(); + }, $messages); $taskPrompt = implode("\n", $messagesBodies); $summaryTask = new TextProcessingTask(SummaryTaskType::class, $taskPrompt, 'mail', $currentUserId, $threadId); @@ -178,21 +163,16 @@ public function generateEventData(Account $account, string $threadId, array $mes if (!in_array(FreePromptTaskType::class, $this->textProcessingManager->getAvailableTaskTypes(), true)) { return null; } - $client = $this->clientFactory->getClient($account); - try { - $messageBodies = array_map(function ($message) use ($client, $account, $currentUserId) { - $mailbox = $this->mailManager->getMailbox($currentUserId, $message->getMailboxId()); - $imapMessage = $this->mailManager->getImapMessage( - $client, - $account, - $mailbox, - $message->getUid(), true - ); - return $imapMessage->getPlainBody(); - }, $messages); - } finally { - $client->logout(); - } + $messageBodies = array_map(function ($message) use ($account, $currentUserId) { + $mailbox = $this->mailManager->getMailbox($currentUserId, $message->getMailboxId()); + $imapMessage = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message, + true + ); + return $imapMessage->getPlainBody(); + }, $messages); $task = new TextProcessingTask( FreePromptTaskType::class, @@ -225,22 +205,16 @@ 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 - ); - if (!$this->isPersonalEmail($imapMessage)) { - return []; - } - $messageBody = $imapMessage->getPlainBody(); - - } finally { - $client->logout(); + $imapMessage = $this->mailManager->getImapMessage( + $account, + $mailbox, + $message, + true + ); + if (!$this->isPersonalEmail($imapMessage)) { + return []; } + $messageBody = $imapMessage->getPlainBody(); $prompt = "You are tasked with formulating helpful replies or reply templates to e-mails provided that have been sent to me. If you don't know some relevant information for answering the e-mails (like my schedule) leave blanks in the text that can later be filled by me. You must write the replies from my point of view as replies to the original sender of the provided e-mail! Formulate two extremely succinct reply suggestions to the provided ***E-MAIL***. Please, do not invent any context for the replies but, rather, leave blanks for me to fill in with relevant information where necessary. Provide the output formatted as valid JSON with the keys 'reply1' and 'reply2' for the reply suggestions. @@ -286,18 +260,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; @@ -352,18 +320,12 @@ public function requiresTranslation( return $cachedValue === 'true' ? true : false; } - $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 3bbd51ecff..cf9f3c842e 100644 --- a/lib/Service/AntiSpamService.php +++ b/lib/Service/AntiSpamService.php @@ -19,9 +19,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; @@ -35,7 +35,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, @@ -119,7 +119,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, @@ -197,7 +197,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 779da3c6b9..37c557997d 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; @@ -44,7 +44,7 @@ class AttachmentService implements IAttachmentService { /** @var AttachmentStorage */ private $storage; /** - * @var IMailManager + * @var MailManager */ private $mailManager; /** @@ -68,7 +68,7 @@ public function __construct( $userFolder, LocalAttachmentMapper $mapper, AttachmentStorage $storage, - IMailManager $mailManager, + MailManager $mailManager, MessageMapper $imapMessageMapper, private ICacheFactory $cacheFactory, private IURLGenerator $urlGenerator, @@ -277,7 +277,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 []; @@ -301,10 +301,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 9dd7c04441..7b536e0017 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; @@ -23,11 +23,8 @@ class DkimService implements IDkimService { private const CACHE_PREFIX = 'mail_dkim'; private const CACHE_TTL = 7 * 24 * 3600; - /** @var IMAPClientFactory */ - private $clientFactory; - - /** @var MessageMapper */ - private $messageMapper; + /** @var ProtocolFactory */ + private $protocolFactory; /** @var ICache */ private $cache; @@ -35,44 +32,33 @@ class DkimService implements IDkimService { private IDkimValidator $dkimValidator; public function __construct( - IMAPClientFactory $clientFactory, - MessageMapper $messageMapper, + ProtocolFactory $protocolFactory, ICacheFactory $cacheFactory, IDkimValidator $dkimValidator, ) { - $this->clientFactory = $clientFactory; - $this->messageMapper = $messageMapper; + $this->protocolFactory = $protocolFactory; $this->cache = $cacheFactory->createLocal(self::CACHE_PREFIX); $this->dkimValidator = $dkimValidator; } #[\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, - ); - - if ($fullText === null) { - throw new ServiceException("Could not fetch message source for uid $id"); - } - } finally { - $client->logout(); + $fullText = $this->protocolFactory + ->messageConnector($account) + ->fetchMessageRaw($account, $mailbox, $message); + + 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 284c741160..c2052ee364 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; @@ -31,8 +30,8 @@ class DraftsService { private LocalMessageMapper $mapper; private AttachmentService $attachmentService; private IEventDispatcher $eventDispatcher; - private IMAPClientFactory $clientFactory; - private IMailManager $mailManager; + private ProtocolFactory $protocolFactory; + private MailManager $mailManager; private LoggerInterface $logger; private AccountService $accountService; private ITimeFactory $time; @@ -41,8 +40,8 @@ public function __construct(IMailTransmission $transmission, LocalMessageMapper $mapper, AttachmentService $attachmentService, IEventDispatcher $eventDispatcher, - IMAPClientFactory $clientFactory, - IMailManager $mailManager, + ProtocolFactory $protocolFactory, + MailManager $mailManager, LoggerInterface $logger, AccountService $accountService, ITimeFactory $time) { @@ -50,7 +49,7 @@ public function __construct(IMailTransmission $transmission, $this->mapper = $mapper; $this->attachmentService = $attachmentService; $this->eventDispatcher = $eventDispatcher; - $this->clientFactory = $clientFactory; + $this->protocolFactory = $protocolFactory; $this->mailManager = $mailManager; $this->logger = $logger; $this->accountService = $accountService; @@ -113,7 +112,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 { @@ -146,7 +145,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 028a2b8a54..90dab8ff6f 100644 --- a/lib/Service/IMipService.php +++ b/lib/Service/IMipService.php @@ -113,7 +113,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 b19492d112..ca760e40ad 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; @@ -26,12 +26,6 @@ class ItineraryService { private const CACHE_PREFIX = 'mail_itinerary'; private const CACHE_TTL = 7 * 24 * 3600; - /** @var IMAPClientFactory */ - private $clientFactory; - - /** @var MessageMapper */ - private $messageMapper; - /** @var ItineraryExtractor */ private $extractor; @@ -41,13 +35,12 @@ class ItineraryService { /** @var LoggerInterface */ private $logger; - public function __construct(IMAPClientFactory $clientFactory, - MessageMapper $messageMapper, + public function __construct( + private readonly MailManager $mailManager, ItineraryExtractor $extractor, ICacheFactory $cacheFactory, - LoggerInterface $logger) { - $this->clientFactory = $clientFactory; - $this->messageMapper = $messageMapper; + LoggerInterface $logger, + ) { $this->extractor = $extractor; $this->cache = $cacheFactory->createLocal(self::CACHE_PREFIX); $this->logger = $logger; @@ -65,30 +58,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); @@ -101,7 +92,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..7d18609918 --- /dev/null +++ b/lib/Service/JMAP/JmapOperationsService.php @@ -0,0 +1,1052 @@ +dataStore = $this->protocolFactory->jmapClient($account); + // evaluate if client was already connected + if (!$this->dataStore->sessionStatus()) { + $this->dataStore->connect(); + } + // determine account + if ($this->dataAccount === null) { + $this->dataAccount = $this->dataStore->sessionAccountDefault('mail')->id(); + } + // determine if blob support is available + if ($this->dataStore->sessionCapable('blob')) { + $this->supportsBlob = true; + } + + return true; + } + + /** + * List of collections in remote storage + * + * @param string|null $location optional location constraint (e.g. parent collection id) + * @param array|null $filter optional filter conditions + * @param array|null $sort optional sort conditions + * + * @return Mailbox[] + */ + public function collectionList(?string $location = null, ?array $filter = null, ?array $sort = null): array { + // construct request + $r0 = new MailboxQuery($this->dataAccount); + // define location + if (!empty($location)) { + $r0->filter()->in($location); + } + // define filter + if ($filter !== null) { + 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 + if ($sort !== null) { + foreach ($sort as $condition) { + $direction = $condition['direction']; + match($condition['attribute']) { + 'name' => $r0->sort()->name($direction), + 'order' => $r0->sort()->order($direction), + default => null + }; + } + } + // construct request + $r1 = new MailboxGet($this->dataAccount); + $r1->targetFromRequest($r0, '/ids'); + // transceive + $bundle = $this->dataStore->perform([$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 Exception($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->dataAccount); + $r0->target(...$identifiers); + $r0->property('id'); + // transceive + $bundle = $this->dataStore->perform([$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 Exception($response->type() . ': ' . $response->description(), 1); + } + } + // construct map of extant collection identifiers + foreach ($response->objects() as $so) { + if (!$so instanceof MailboxParametersResponse) { + continue; + } + $extant[$so->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->dataAccount); + $r0->target($identifier); + // transceive + $bundle = $this->dataStore->perform([$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 Exception($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->dataAccount); + $r0->create($id, $to); + // transceive + $bundle = $this->dataStore->perform([$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 Exception($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 Exception("$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): ?Mailbox { + // convert entity + $to = $this->jmapMailboxAdapter->convertFromMailbox($mailbox); + // construct request + $r0 = new MailboxSet($this->dataAccount); + $r0->update($identifier, $to); + // transceive + $bundle = $this->dataStore->perform([$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 Exception($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 Exception("$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->dataAccount); + $r0->delete($identifier); + if ($force) { + $r0->destroyContents(true); + } + // transceive + $bundle = $this->dataStore->perform([$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 Exception($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 Exception("$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 array|null $filter optional filter conditions + * @param array|null $sort optional sort conditions + * @param array|null $range optional range conditions + * @param string|null $granularity optional granularity level + * + * @return array{state:string, list:array} + */ + public function entityList(?string $location = null, ?array $filter = null, ?array $sort = null, ?array $range = null, ?string $granularity = null): array { + // construct first request + $r0 = new MailQuery($this->dataAccount); + // define location + if (!empty($location)) { + $r0->filter()->in($location); + } + // define filter + if ($filter !== null) { + 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 + if ($sort !== null) { + 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->dataAccount); + $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->dataStore->perform([$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->dataAccount); + $r0->target(...$identifiers); + $r0->property('id'); + // transmit request and receive response + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->first(); + // construct map of extant collection identifiers + foreach ($response->objects() as $so) { + if (!$so instanceof MailParametersResponse) { + continue; + } + $extant[$so->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, null, null, 'B'); + $delta = [ + 'state' => $results['state'], + 'additions' => [], + 'modifications' => [], + 'deletions' => [], + ]; + foreach ($results['list'] as $entry) { + $delta['additions'][] = $entry->getRemoteId(); + } + 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->dataAccount); + // 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->dataStore->perform([$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 Exception($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->dataAccount); + if (!empty($state)) { + $r0->state($state); + } else { + $r0->state(''); + } + // transceive + $bundle = $this->dataStore->perform([$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 Exception($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->dataAccount); + $r0->target(...$identifiers); + // select properties to return + $r0->property(...$this->entityPropertiesDefault); + $r0->bodyAll(true); + // transceive + $bundle = $this->dataStore->perform([$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(); + $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->dataStore->download($this->dataAccount, $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->dataAccount); + $r0->create($id, $to); + // transceive + $bundle = $this->dataStore->perform([$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 Exception($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 Exception("$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->dataAccount); + $r0->update($id, $to); + // transceive + $bundle = $this->dataStore->perform([$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 Exception($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->dataAccount); + foreach ($identifiers as $id) { + $r0->patch($id, $patch); + } + // transceive + $bundle = $this->dataStore->perform([$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 Exception($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->dataAccount); + foreach ($identifiers as $id) { + $r0->delete($id); + } + // transceive + $bundle = $this->dataStore->perform([$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 Exception($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->dataAccount); + foreach ($identifiers as $id) { + $r0->update($id)->in($target); + } + // transceive + $bundle = $this->dataStore->perform([$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 Exception($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; + } + + /** + * send entity + */ + public function entitySend(string $identity, MailMessageObject $message, ?string $presendLocation = null, ?string $postsendLocation = null): string { + // determine if pre-send location is present + if ($presendLocation === null || empty($presendLocation)) { + throw new Exception('Pre-Send Location is missing', 1); + } + // determine if post-send location is present + if ($postsendLocation === null || empty($postsendLocation)) { + throw new Exception('Post-Send Location is missing', 1); + } + // determine if we have the basic required data and fail otherwise + if (empty($message->getFrom())) { + throw new Exception('Missing Requirements: Message MUST have a From address', 1); + } + if (empty($message->getTo())) { + throw new Exception('Missing Requirements: Message MUST have a To address(es)', 1); + } + // determine if message has attachments + if (count($message->getAttachments()) > 0) { + // process attachments first + $message = $this->depositAttachmentsFromMessage($message); + } + // convert from address object to string + $from = $message->getFrom()->getAddress(); + // convert to, cc and bcc address object arrays to single strings array + $to = array_map( + function ($entry) { return $entry->getAddress(); }, + array_merge($message->getTo(), $message->getCc(), $message->getBcc()) + ); + unset($cc, $bcc); + // construct set request + $r0 = new MailSet($this->dataAccount); + $r0->create('1', $message)->in($presendLocation); + // construct set request + $r1 = new MailSubmissionSet($this->dataAccount); + // construct envelope + $e1 = $r1->create('2'); + $e1->identity($identity); + $e1->message('#1'); + $e1->from($from); + $e1->to($to); + // transceive + $bundle = $this->dataStore->perform([$r0, $r1]); + // extract response + $response = $bundle->response(1); + // return collection information + return (string)$response->created()['2']['id']; + } + + 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 $key => $attachment) { + if ($blobIds === null || in_array($attachment->blob(), $blobIds, true)) { + $content = null; + $this->dataStore->download($this->dataAccount, $attachment->blob(), $content); + + $attachment = new Attachment( + $attachment->blob(), + $attachment->name() ?? 'unknown.file', + $attachment->type() ?? 'application/octet-stream', + $content, + $attachment->size() ?? 0, + ); + $attachments[] = $attachment; + } + + } + + return $attachments; + } + + /** + * retrieve collection entity attachment from remote storage + */ + public function depositAttachmentsFromMessage(MailMessageObject $message): MailMessageObject { + + $parameters = $message->toJmap(); + $attachments = $message->getAttachments(); + $matches = []; + + $this->findAttachmentParts($parameters['bodyStructure'], $matches); + + foreach ($attachments as $attachment) { + $part = $attachment->toJmap(); + if (isset($matches[$part->getId()])) { + // deposit attachment in data store + $response = $this->blobDeposit($account, $part->getType(), $attachment->getContents()); + // transfer blobId and size to mail part + $matches[$part->getId()]->blobId = $response['blobId']; + $matches[$part->getId()]->size = $response['size']; + unset($matches[$part->getId()]->partId); + } + } + + return (new MailMessageObject())->fromJmap($parameters); + + } + + protected function findAttachmentParts(object &$part, array &$matches) { + + if ($part->disposition === 'attachment' || $part->disposition === 'inline') { + $matches[$part->partId] = $part; + } + + foreach ($part->subParts as $entry) { + $this->findAttachmentParts($entry, $matches); + } + + } + + /** + * retrieve identity from remote storage + * + * + */ + public function identityFetch(?string $account = null): array { + if ($account === null) { + $account = $this->dataAccount; + } + // construct set request + $r0 = new MailIdentityGet($this->dataAccount); + // transmit request and receive response + $bundle = $this->dataStore->perform([$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 1b94bf8cf0..bbf6f879e8 100644 --- a/lib/Service/MailManager.php +++ b/lib/Service/MailManager.php @@ -12,11 +12,9 @@ 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; @@ -24,101 +22,40 @@ use OCA\Mail\Db\MessageTagsMapper; use OCA\Mail\Db\Tag; use OCA\Mail\Db\TagMapper; -use OCA\Mail\Db\ThreadMapper; use OCA\Mail\Events\BeforeMessageDeletedEvent; use OCA\Mail\Events\MessageDeletedEvent; use OCA\Mail\Events\MessageFlaggedEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ImapFlagEncodingException; use OCA\Mail\Exception\ServiceException; -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 IMAPClientFactory */ - private $imapClientFactory; - - /** @var MailboxSync */ - private $mailboxSync; - - /** @var MailboxMapper */ - private $mailboxMapper; - - /** @var FolderMapper */ - private $folderMapper; - - /** @var ImapMessageMapper */ - private $imapMessageMapper; - - /** @var DbMessageMapper */ - private $dbMessageMapper; - - /** @var IEventDispatcher */ - private $eventDispatcher; - - /** @var LoggerInterface */ - private $logger; - - /** @var TagMapper */ - private $tagMapper; - - /** @var MessageTagsMapper */ - private $messageTagsMapper; - - /** @var ThreadMapper */ - private $threadMapper; +class MailManager { public function __construct( - IMAPClientFactory $imapClientFactory, - MailboxMapper $mailboxMapper, - MailboxSync $mailboxSync, - FolderMapper $folderMapper, - ImapMessageMapper $messageMapper, - DbMessageMapper $dbMessageMapper, - IEventDispatcher $eventDispatcher, - LoggerInterface $logger, - TagMapper $tagMapper, - MessageTagsMapper $messageTagsMapper, - ThreadMapper $threadMapper, - private ImapFlag $imapFlag, + private readonly MailboxMapper $mailboxMapper, + private readonly ImapMessageMapper $imapMessageMapper, + 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, ) { - $this->imapClientFactory = $imapClientFactory; - $this->mailboxMapper = $mailboxMapper; - $this->mailboxSync = $mailboxSync; - $this->folderMapper = $folderMapper; - $this->imapMessageMapper = $messageMapper; - $this->dbMessageMapper = $dbMessageMapper; - $this->eventDispatcher = $eventDispatcher; - $this->logger = $logger; - $this->tagMapper = $tagMapper; - $this->messageTagsMapper = $messageTagsMapper; - $this->threadMapper = $threadMapper; - } - - #[\Override] + } + + //** ============================ Mailbox Operations ============================ */ + public function getMailbox(string $uid, int $id): Mailbox { try { return $this->mailboxMapper->findByUid($id, $uid); @@ -134,55 +71,59 @@ 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] 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); + } - return $this->mailboxMapper->find($account, $name); + public function renameMailbox(Account $account, Mailbox $mailbox, string $name): Mailbox { + return $this->protocolFactory + ->mailboxConnector($account) + ->rename($account, $mailbox, $name); } - #[\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 - ); + public function deleteMailbox(Account $account, Mailbox $mailbox): void { + $this->protocolFactory + ->mailboxConnector($account) + ->delete($account, $mailbox); + } + + public function updateSubscription(Account $account, Mailbox $mailbox, bool $subscribed): Mailbox { + return $this->protocolFactory + ->mailboxConnector($account) + ->subscribe($account, $mailbox, $subscribed); + } + + public function clearMailbox(Account $account, Mailbox $mailbox): void { + $this->protocolFactory + ->messageConnector($account) + ->clearMailbox($account, $mailbox); + } + + //** ============================ Message Operations ============================ */ + + public function getMessage(string $uid, int $id): Message { + return $this->dbMessageMapper->findByUserId($uid, $id); + } + + 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); } /** @@ -192,46 +133,13 @@ public function getImapMessage(Horde_Imap_Client_Socket $client, * @return IMAPMessage[] * @throws ServiceException */ - 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(); - } - } - - #[\Override] - public function getThread(Account $account, string $threadRootId): array { - return $this->dbMessageMapper->findThread($account, $threadRootId); - } - - #[\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); + public function getImapMessages(Account $account, Mailbox $mailbox, bool $loadBody = false, Message ...$messages): array { + return $this->protocolFactory + ->messageConnector($account) + ->fetchMessages($account, $mailbox, $loadBody, ...$messages); } /** - * @param Horde_Imap_Client_Socket $client * @param Account $account * @param string $mailbox * @param int $uid @@ -240,24 +148,35 @@ public function getMessage(string $uid, int $id): Message { * * @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 getRawMessage(Account $account, Mailbox $mailbox, Message $message): ?string { + $message = $this->protocolFactory + ->messageConnector($account) + ->fetchMessageRaw($account, $mailbox, $message); + if ($message === null) { + throw new ClientException('Message not found on remote server'); } } + public function getMessageIdForUid(Mailbox $mailbox, $uid): ?int { + return $this->dbMessageMapper->getIdForUid($mailbox, $uid); + } + + /** + * @return Message[] + */ + public function getMessagesByMessageId(Account $account, string $messageId): array { + return $this->dbMessageMapper->findByMessageId($account, $messageId); + } + + /** + * @return int[] + */ + 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 @@ -268,79 +187,49 @@ public function getSource(Horde_Imap_Client_Socket $client, * @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 []; } + // update remote store + $mutatedMessages = $this->protocolFactory + ->messageConnector($account) + ->moveMessages($account, $targetMailbox, $sourceMailbox, ...$messages); - $client = $this->imapClientFactory->getClient($account); - try { - $this->deleteMessageWithClient($account, $sourceMailbox, $messageUid, $client); - } finally { - $client->logout(); + // update local store + $this->dbMessageMapper->updateBulk($account, false, ...$mutatedMessages); + + $mutatedUids = []; + foreach ($mutatedMessages as $mutatedMessage) { + $mutatedUids[] = $mutatedMessage->getUid(); } + + 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->getName(), $messageUid) - ); + public function deleteMessage(Account $account, Mailbox $mailbox, Message $message): void { + $this->deleteMessages($account, $mailbox, ...[$message]); + } + public function deleteMessages(Account $account, Mailbox $sourceMailbox, Message ...$messages): void { + if ($messages === []) { + return; + } try { $trashMailboxId = $account->getMailAccount()->getTrashMailboxId(); if ($trashMailboxId === null) { @@ -350,339 +239,148 @@ 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->getName(), $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), + default => throw new ServiceException('Invalid operation'), + }; - $this->eventDispatcher->dispatchTyped( - new MessageDeletedEvent($account, $mailbox, $messageUid) - ); - } - - /** - * @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(); + // update local store + if ($operation === 'move') { + $this->dbMessageMapper->updateBulk($account, false, ...$mutatedMessages); } - } - - #[\Override] - public function markFolderAsRead(Account $account, Mailbox $mailbox): void { - $client = $this->imapClientFactory->getClient($account); - try { - $this->imapMessageMapper->markAllRead($client, $mailbox->getName()); - } finally { - $client->logout(); + if ($operation === 'delete') { + $mutatedUids = array_map(static fn (Message $message): int => $message->getUid(), $mutatedMessages); + $this->dbMessageMapper->deleteByUid($sourceMailbox, ...$mutatedUids); } - } - #[\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 $mutatedMessage) { + $this->eventDispatcher->dispatchTyped(new MessageDeletedEvent($account, $sourceMailbox, $mappedUids[spl_object_id($mutatedMessage)])); } - - /** - * 3. Return the updated object - */ - return $this->mailboxMapper->find($account, $mailbox->getName()); } - #[\Override] - public function enableMailboxBackgroundSync(Mailbox $mailbox, - bool $syncInBackground): Mailbox { - $mailbox->setSyncInBackground($syncInBackground); + 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); - return $this->mailboxMapper->update($mailbox); - } + // update local store + $this->dbMessageMapper->updateBulk($account, true, ...$mutatedMessages); - #[\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); + // dispatch events + foreach ($mutatedMessages as $message) { + $this->eventDispatcher->dispatchTyped(new MessageFlaggedEvent($account, $mailbox, $message->getUid(), $flag, $value)); } + } - $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(); + 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); - $this->eventDispatcher->dispatch( - MessageFlaggedEvent::class, - new MessageFlaggedEvent( - $account, - $mb, - $uid, - $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 - ); - } - } - - 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 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); } - /** - * Tag (flag) a message on IMAP - * - * @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 - */ - #[\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); - } - $client = $this->imapClientFactory->getClient($account); - try { - $this->tagMessagesWithClient($client, $account, $mb, [$message], $tag, $value); - } finally { - $client->logout(); - } + public function getThread(Account $account, string $threadRootId): array { + return $this->dbMessageMapper->findThread($account, $threadRootId); } /** - * @param Account $account + * Finds all messages in the thread of the given thread root id * - * @return Quota|null - * @see https://tools.ietf.org/html/rfc2087 + * @return array array of messages in the thread, keyed by remote id */ - #[\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 fetchThread(Account $account, Mailbox $mailbox, string $threadRootId): array { + $mailAccount = $account->getMailAccount(); + $messageInTrash = $mailbox->getId() === $mailAccount->getTrashMailboxId(); + $threadMessages = $this->threadMapper->findMessageUidsAndMailboxNamesByAccountAndThreadRoot( + $mailAccount, + $threadRootId, + $messageInTrash, + ); - /** - * 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; + // group message uids by mailbox + $uids = []; + foreach ($threadMessages as $threadMessage) { + $uids[$threadMessage['mailboxName']][] = $threadMessage['messageUid']; } + unset($threadMessages); - /** - * Deduplicate identical quota roots - */ - $storage = array_merge(...array_values($storageQuotas)); + // retrieve messages from local store + $messages = []; + 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 new Quota( - 1024 * (int)($storage['usage'] ?? 0), - 1024 * (int)($storage['limit'] ?? 0) - ); + return $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(); + 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'); } - /** - * 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($srcAccount, $srcMailbox, $threadRootId); + if ($messages === []) { + return []; } + + $this->moveMessages($srcAccount, $dstMailbox, $srcMailbox, ...$messages); + + return $mutatedUids; } /** - * @param Account $account - * @param Mailbox $mailbox - * + * @throws ClientException * @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(); + public function deleteThread(Account $account, Mailbox $mailbox, string $threadRootId): void { + if ($$account->getMailAccount()->getTrashMailboxId() === null) { + throw new TrashMailboxNotSetException(); } - $this->mailboxMapper->delete($mailbox); - } - /** - * Clear messages in folder - * - * @param Account $account - * @param Mailbox $mailbox - * - * @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(); + $messages = $this->fetchThread($account, $sourceMailbox, $threadRootId); + if ($messages === []) { + return; } + + $this->deleteMessages($account, $sourceMailbox, ...$messages); } /** @@ -691,19 +389,10 @@ public function clearMailbox(Account $account, * @param Message $message * @return Attachment[] */ - #[\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(); - } + return $this->protocolFactory + ->messageConnector($account) + ->fetchAttachments($account, $mailbox, $message); } /** @@ -719,96 +408,51 @@ public function getMailAttachments(Account $account, Mailbox $mailbox, Message $ * @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 getMailAttachment(Account $account, Mailbox $mailbox, Message $message, string $attachmentId): Attachment { + return $this->protocolFactory + ->messageConnector($account) + ->fetchAttachment($account, $mailbox, $message, $attachmentId); } /** - * @param string $imapLabel - * @param string $userId - * @return Tag - * @throws ClientException - */ - #[\Override] - public function getTagByImapLabel(string $imapLabel, string $userId): Tag { - try { - return $this->tagMapper->getTagByImapLabel($imapLabel, $userId); - } catch (DoesNotExistException $e) { - throw new ClientException('Unknown Tag', 0, $e); - } - } - - /** - * Filter out IMAP flags that aren't supported by the client server + * @param Account $account * - * @param string $flag - * @param string $mailbox - * @return array + * @return Quota|null + * @see https://tools.ietf.org/html/rfc2087 */ - 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 []; + public function getQuota(Account $account): ?Quota { + return $this->protocolFactory + ->messageConnector($account) + ->getQuota($account); } /** * Check IMAP server for support for PERMANENTFLAGS * * @param Account $account - * @param string $mailbox + * @param Mailbox $mailbox * @return boolean */ - #[\Override] - public function isPermflagsEnabled(Horde_Imap_Client_Socket $client, Account $account, string $mailbox): bool { + public function isPermflagsEnabled(Account $account, Mailbox $mailbox): bool { + return $this->protocolFactory + ->messageConnector($account) + ->isPermflagsEnabled($account, $mailbox); + } + + /** + * @param string $imapLabel + * @param string $userId + * @return Tag + * @throws ClientException + */ + public function getTagByLabel(string $imapLabel, string $userId): Tag { 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 $this->tagMapper->getTagByImapLabel($imapLabel, $userId); + } catch (DoesNotExistException $e) { + throw new ClientException('Unknown Tag', 0, $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); @@ -817,7 +461,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. } @@ -832,7 +480,6 @@ public function createTag(string $displayName, string $color, string $userId): T return $this->tagMapper->insert($tag); } - #[\Override] public function updateTag(int $id, string $displayName, string $color, string $userId): Tag { try { $tag = $this->tagMapper->getTagForUser($id, $userId); @@ -846,8 +493,12 @@ 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 + */ + public function deleteTag(int $id, string $userId, array $accounts): Tag { try { $tag = $this->tagMapper->getTagForUser($id, $userId); } catch (DoesNotExistException $e) { @@ -855,111 +506,38 @@ public function deleteTag(int $id, string $userId, array $accounts) :Tag { } 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); + // find all messages with this tag + try { + $messageTags = $this->messageTagsMapper->getMessagesByTag($id); + $messages = array_merge(... array_map(fn ($messageTag) => $this->getMessagesByMessageId($account, $messageTag->getImapMessageId()), array_values($messageTags))); + } catch (DoesNotExistException $e) { + throw new ClientException('Messages not found', 0, $e); + } + if ($messages === []) { + continue; + } + + $this->protocolFactory + ->messageConnector($account) + ->tagMessages($account, $tag, false, ...$messages); } - - $client = $this->imapClientFactory->getClient($account); - + + // update the local store 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]; - } - } - try { - foreach ($groupedMessages as $mailboxId => $messages) { - $mailbox = $this->getMailbox($userId, $mailboxId); - $this->tagMessagesWithClient($client, $account, $mailbox, $messages, $tag, false); - } - } 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; - } - } - return $newUids; + return $this->tagMapper->delete($tag); } - /** - * @throws ClientException - * @throws ServiceException - */ - #[\Override] - public function deleteThread(Account $account, Mailbox $mailbox, string $threadRootId): void { - $mailAccount = $account->getMailAccount(); - $messageInTrash = $mailbox->getId() === $mailAccount->getTrashMailboxId(); + // ============================ Helpers ============================ */ - $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'] - ); + private function mapMailboxesById(array $mailboxes): array { + $mailboxesById = []; + foreach ($mailboxes as $mailbox) { + $mailboxesById[$mailbox->getId()] = $mailbox; } + return $mailboxesById; } - /** - * @return Message[] - */ - #[\Override] - public function getByMessageId(Account $account, string $messageId): array { - return $this->dbMessageMapper->findByMessageId($account, $messageId); - } } diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php index ad5a4d2a1b..f5d8b4245c 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, ) { } @@ -231,7 +230,7 @@ public function saveLocalDraft(Account $account, LocalMessage $message): void { $perfLogger->step('build local draft message'); // 'Send' the message - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $transport = new Horde_Mail_Transport_Null(); $mail->send($transport, false, false); @@ -312,7 +311,7 @@ public function saveDraft(NewMessageData $message, ?Message $previousDraft = nul $perfLogger->step('build draft message'); // 'Send' the message - $client = $this->imapClientFactory->getClient($account); + $client = $this->protocolFactory->imapClient($account); try { $transport = new Horde_Mail_Transport_Null(); $mail->send($transport, false, false); @@ -385,7 +384,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 0e81ae325a..4cd6522dbd 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,10 +38,10 @@ class OutboxService { /** @var IEventDispatcher */ private $eventDispatcher; - /** @var IMAPClientFactory */ - private $clientFactory; + /** @var ProtocolFactory */ + private $protocolFactory; - /** @var IMailManager */ + /** @var MailManager */ private $mailManager; /** @var AccountService */ @@ -58,8 +57,8 @@ public function __construct( LocalMessageMapper $mapper, AttachmentService $attachmentService, IEventDispatcher $eventDispatcher, - IMAPClientFactory $clientFactory, - IMailManager $mailManager, + ProtocolFactory $protocolFactory, + MailManager $mailManager, AccountService $accountService, ITimeFactory $timeFactory, LoggerInterface $logger, @@ -68,7 +67,7 @@ public function __construct( $this->mapper = $mapper; $this->attachmentService = $attachmentService; $this->eventDispatcher = $eventDispatcher; - $this->clientFactory = $clientFactory; + $this->protocolFactory = $protocolFactory; $this->mailManager = $mailManager; $this->timeFactory = $timeFactory; $this->logger = $logger; @@ -143,7 +142,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 { @@ -175,7 +174,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 16f8ada442..e540289aa5 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; @@ -29,8 +29,8 @@ class MailSearch implements IMailSearch { /** @var FilterStringParser */ private $filterStringParser; - /** @var ImapSearchProvider */ - private $imapSearchProvider; + /** @var MailManager */ + private $mailManager; /** @var MessageMapper */ private $messageMapper; @@ -42,12 +42,12 @@ class MailSearch implements IMailSearch { private $timeFactory; public function __construct(FilterStringParser $filterStringParser, - ImapSearchProvider $imapSearchProvider, + MailManager $mailManager, MessageMapper $messageMapper, PreviewEnhancer $previewEnhancer, ITimeFactory $timeFactory) { $this->filterStringParser = $filterStringParser; - $this->imapSearchProvider = $imapSearchProvider; + $this->mailManager = $mailManager; $this->messageMapper = $messageMapper; $this->previewEnhancer = $previewEnhancer; $this->timeFactory = $timeFactory; @@ -154,7 +154,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 20775b51ec..738f1c186f 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; @@ -34,8 +34,8 @@ class SetupService { /** @var SmtpClientFactory */ private $smtpClientFactory; - /** @var IMAPClientFactory */ - private $imapClientFactory; + /** @var ProtocolFactory */ + private $protocolFactory; /** @var LoggerInterface */ private $logger; @@ -46,13 +46,13 @@ class SetupService { public function __construct(AccountService $accountService, ICrypto $crypto, SmtpClientFactory $smtpClientFactory, - IMAPClientFactory $imapClientFactory, + ProtocolFactory $protocolFactory, LoggerInterface $logger, TagMapper $tagMapper) { $this->accountService = $accountService; $this->crypto = $crypto; $this->smtpClientFactory = $smtpClientFactory; - $this->imapClientFactory = $imapClientFactory; + $this->protocolFactory = $protocolFactory; $this->logger = $logger; $this->tagMapper = $tagMapper; } @@ -127,7 +127,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 ae45e8bcd8..95ec913c94 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! @@ -130,7 +127,6 @@ public function unSnoozeMessage( if ($originalMailboxId !== null) { try { $originalMailbox = $this->mailboxMapper->findById($originalMailboxId); - $originalMailboxName = $originalMailbox->getName(); } catch (DoesNotExistException $e) { // Could not find mailbox, moving back to INBOX } @@ -138,10 +134,10 @@ public function unSnoozeMessage( $this->mailManager->moveMessage( $srcAccount, - $snoozedMailbox->getName(), - $message->getUid(), + $snoozedMailbox, + $message, $srcAccount, - $originalMailboxName + $originalMailbox ); $this->messageSnoozeMapper->deleteByMailboxIdAndUid( @@ -207,7 +203,6 @@ public function unSnoozeThread( if ($originalMailboxId !== null) { try { $originalMailbox = $this->mailboxMapper->findById($originalMailboxId); - $originalMailboxName = $originalMailbox->getName(); } catch (DoesNotExistException $e) { // Could not find mailbox, moving back to INBOX } @@ -218,10 +213,10 @@ public function unSnoozeThread( foreach ($messages as $message) { $this->mailManager->moveMessage( $srcAccount, - $snoozedMailbox->getName(), - $message->getUid(), + $snoozedMailbox, + $message, $srcAccount, - $originalMailboxName + $originalMailbox ); $this->messageSnoozeMapper->deleteByMailboxIdAndUid( @@ -302,42 +297,36 @@ 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'; + + if ($srcMailboxId !== null) { + try { + $srcMailbox = $this->mailboxMapper->findById($srcMailboxId); + } catch (DoesNotExistException $e) { + // Could not find mailbox, moving back to INBOX } + } - $this->mailManager->flagMessage($account, $snoozeMailbox->getName(), $message->getUid(), 'seen', false); + $this->mailManager->flagMessages($account, $mailbox, '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 a48805dd41..a6e0ab88c4 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; @@ -53,8 +53,8 @@ class ImapToDbSynchronizer { /** @var DatabaseMessageMapper */ private $dbMapper; - /** @var IMAPClientFactory */ - private $clientFactory; + /** @var ProtocolFactory */ + private $protocolFactory; /** @var ImapMessageMapper */ private $imapMapper; @@ -74,14 +74,14 @@ class ImapToDbSynchronizer { /** @var LoggerInterface */ private $logger; - /** @var IMailManager */ + /** @var MailManager */ private $mailManager; private TagMapper $tagMapper; private NewMessagesClassifier $newMessagesClassifier; public function __construct(DatabaseMessageMapper $dbMapper, - IMAPClientFactory $clientFactory, + ProtocolFactory $protocolFactory, ImapMessageMapper $imapMapper, MailboxMapper $mailboxMapper, DatabaseMessageMapper $messageMapper, @@ -89,11 +89,11 @@ public function __construct(DatabaseMessageMapper $dbMapper, IEventDispatcher $dispatcher, PerformanceLogger $performanceLogger, LoggerInterface $logger, - IMailManager $mailManager, + MailManager $mailManager, TagMapper $tagMapper, NewMessagesClassifier $newMessagesClassifier) { $this->dbMapper = $dbMapper; - $this->clientFactory = $clientFactory; + $this->protocolFactory = $protocolFactory; $this->imapMapper = $imapMapper; $this->mailboxMapper = $mailboxMapper; $this->synchronizer = $synchronizer; @@ -119,7 +119,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; @@ -337,7 +337,7 @@ private function runInitialSync( // Need a client without a cache $client->logout(); - $client = $this->clientFactory->getClient($account, false); + $client = $this->protocolFactory->imapClient($account, false); $highestKnownUid = $this->dbMapper->findHighestUid($mailbox); try { @@ -380,7 +380,7 @@ private function runInitialSync( // Horde's "no MODSEQ" fallback that resolves ALL UIDs, causing OOM on // large mailboxes. $client->logout(); - $cacheClient = $this->clientFactory->getClient($account); + $cacheClient = $this->protocolFactory->imapClient($account); try { $syncToken = $cacheClient->getSyncToken($mailbox->getName()); } finally { @@ -502,7 +502,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)); @@ -572,7 +572,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 0cff18572e..6fd25b6ce1 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; @@ -30,7 +30,7 @@ class SyncService { - private IMAPClientFactory $clientFactory; + private ProtocolFactory $protocolFactory; /** @var ImapToDbSynchronizer */ private $synchronizer; @@ -51,14 +51,14 @@ class SyncService { private $mailboxSync; public function __construct( - IMAPClientFactory $clientFactory, + ProtocolFactory $protocolFactory, ImapToDbSynchronizer $synchronizer, FilterStringParser $filterStringParser, MessageMapper $messageMapper, PreviewEnhancer $previewEnhancer, LoggerInterface $logger, MailboxSync $mailboxSync) { - $this->clientFactory = $clientFactory; + $this->protocolFactory = $protocolFactory; $this->synchronizer = $synchronizer; $this->filterStringParser = $filterStringParser; $this->messageMapper = $messageMapper; @@ -74,9 +74,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); } /** @@ -86,7 +85,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, $this->logger); } /** @@ -110,26 +109,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/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/MailboxSynchronizationTest.php b/tests/Integration/MailboxSynchronizationTest.php index aa93fdf616..052dd92c15 100644 --- a/tests/Integration/MailboxSynchronizationTest.php +++ b/tests/Integration/MailboxSynchronizationTest.php @@ -278,7 +278,7 @@ public function testUnsolicitedVanishedMessage() { // Receive unsolicited vanished uid $client = $this->getClient($this->account); - $mailManager->getSource( + $mailManager->getRawMessage( $client, new Account($this->account), $mailbox, diff --git a/tests/Integration/Protocol/ProtocolFactoryTest.php b/tests/Integration/Protocol/ProtocolFactoryTest.php new file mode 100644 index 0000000000..fe31916457 --- /dev/null +++ b/tests/Integration/Protocol/ProtocolFactoryTest.php @@ -0,0 +1,55 @@ +protocolFactory = Server::get(ProtocolFactory::class); + } + + public function testImapClientConnection(): void { + $account = $this->createTestAccount(); + + $client = $this->protocolFactory->imapClient($account); + + $this->assertInstanceOf(Horde_Imap_Client_Socket::class, $client); + $client->login(); + $client->logout(); + } + + public function testJmapClientConnection(): void { + $account = $this->createTestAccount(); + + $client = $this->protocolFactory->jmapClient($account); + + $this->assertInstanceOf(JmapClient::class, $client); + + $session = $client->connect(); + + $this->assertTrue($client->sessionStatus(), 'JMAP session should be established'); + $this->assertNotEmpty($session->username(), 'Session should report a username'); + $this->assertNotEmpty($session->commandUrl(), 'Session should provide an API URL'); + } +} diff --git a/tests/Unit/Controller/MessagesControllerTest.php b/tests/Unit/Controller/MessagesControllerTest.php index 83837b63c6..cee42f17a1 100644 --- a/tests/Unit/Controller/MessagesControllerTest.php +++ b/tests/Unit/Controller/MessagesControllerTest.php @@ -1112,7 +1112,7 @@ public function testExport() { $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') + ->method('getRawMessage') ->with($client, $this->account, $folderId, 123) ->willReturn($source); $this->clientFactory->expects($this->once()) diff --git a/tests/Unit/Job/FollowUpClassifierJobTest.php b/tests/Unit/Job/FollowUpClassifierJobTest.php index d16f6a7331..b8c7c7fdd0 100644 --- a/tests/Unit/Job/FollowUpClassifierJobTest.php +++ b/tests/Unit/Job/FollowUpClassifierJobTest.php @@ -98,7 +98,7 @@ public function testRun(): void { ->with('user', 100) ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getByMessageId') + ->method('getMessagesByMessageId') ->with($account, '') ->willReturn($messages); $this->threadMapper->expects(self::once()) @@ -141,7 +141,7 @@ public function testRunLlmProcessingDisabled(): void { $this->accountService->expects(self::never()) ->method('find'); $this->mailManager->expects(self::never()) - ->method('getByMessageId'); + ->method('getMessagesByMessageId'); $this->threadMapper->expects(self::never()) ->method('findNewerMessageIdsInThread'); $this->aiService->expects(self::never()) @@ -181,7 +181,7 @@ public function testRunNoMessages(): void { ->with('user', 100) ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getByMessageId') + ->method('getMessagesByMessageId') ->with($account, '') ->willReturn($messages); $this->threadMapper->expects(self::never()) @@ -231,7 +231,7 @@ public function testRunMultipleMessages(): void { ->with('user', 100) ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getByMessageId') + ->method('getMessagesByMessageId') ->with($account, '') ->willReturn($messages); $this->threadMapper->expects(self::once()) @@ -285,7 +285,7 @@ public function testRunCreateTag(): void { ->with('user', 100) ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getByMessageId') + ->method('getMessagesByMessageId') ->with($account, '') ->willReturn($messages); $this->threadMapper->expects(self::once()) @@ -339,7 +339,7 @@ public function testRunNoFollowUp(): void { ->with('user', 100) ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getByMessageId') + ->method('getMessagesByMessageId') ->with($account, '') ->willReturn($messages); $this->threadMapper->expects(self::once()) @@ -390,7 +390,7 @@ public function testRunFollowedUp(): void { ->with('user', 100) ->willReturn($account); $this->mailManager->expects(self::once()) - ->method('getByMessageId') + ->method('getMessagesByMessageId') ->with($account, '') ->willReturn($messages); $this->threadMapper->expects(self::once()) 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())