From c287787f8df599b567f399f6022ffc88db3a0582 Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Wed, 2 Oct 2019 11:47:00 +0200 Subject: [PATCH] Add a cache for IMAP message in the database Co-authored-by: Roeland Jago Douma Signed-off-by: Christoph Wurst Signed-off-by: Roeland Jago Douma --- appinfo/info.xml | 4 +- lib/Address.php | 18 +- lib/AddressList.php | 8 +- lib/AppInfo/BootstrapSingleton.php | 2 +- lib/BackgroundJob/SyncJob.php | 78 ++++ lib/Command/SyncAccount.php | 73 ++++ lib/Contracts/IMailManager.php | 11 - lib/Controller/FoldersController.php | 35 +- lib/Controller/MessagesController.php | 11 + lib/Db/MailAccountMapper.php | 11 +- lib/Db/Mailbox.php | 25 +- lib/Db/MailboxMapper.php | 97 ++++- lib/Db/Message.php | 186 +++++++++ lib/Db/MessageMapper.php | 378 +++++++++++++++++ .../ConcurrentSyncException.php} | 12 +- lib/Exception/MailboxNotCachedException.php | 7 + lib/Exception/UidValidityChangedException.php | 30 ++ lib/Folder.php | 14 - lib/IMAP/FolderMapper.php | 7 - lib/IMAP/MailboxSync.php | 2 - lib/IMAP/MessageMapper.php | 29 +- lib/IMAP/Search/FullScanSearchStrategy.php | 86 ---- lib/IMAP/Search/ImapSortSearchStrategy.php | 109 ----- lib/IMAP/Search/Provider.php | 82 ++++ lib/IMAP/Sync/ISyncStrategy.php | 4 +- lib/IMAP/Sync/Response.php | 48 ++- lib/IMAP/Sync/SimpleMailboxSync.php | 4 +- lib/IMAP/Sync/Synchronizer.php | 28 +- lib/Migration/FixAccountSyncs.php | 64 +++ .../Version1020Date20191002091034.php | 33 ++ .../Version1020Date20191002091035.php | 157 +++++++ lib/Model/IMAPMessage.php | 43 +- lib/Service/AccountService.php | 34 +- .../AutoCompletion/AddressCollector.php | 2 +- lib/Service/MailManager.php | 31 -- lib/Service/MailSearch.php | 139 ------- .../Search/FilterStringParser.php} | 30 +- lib/Service/Search/MailSearch.php | 130 ++++++ lib/Service/Search/SearchQuery.php | 143 +++++++ lib/Service/SyncService.php | 393 ++++++++++++++++++ .../PerformanceLogger.php} | 45 +- lib/Support/PerformanceLoggerTask.php | 72 ++++ src/components/FolderContent.vue | 6 +- src/components/Loading.vue | 35 +- src/service/MessageService.js | 3 +- src/store/actions.js | 7 +- src/store/mutations.js | 3 - tests/Integration/Db/MailboxMapperTest.php | 20 +- .../Integration/FolderSynchronizationTest.php | 80 ++-- .../Integration/Framework/ImapTestAccount.php | 10 +- .../Unit/Controller/FoldersControllerTest.php | 25 +- .../Controller/MessagesControllerTest.php | 6 + .../Search/SearchFilterStringParserTest.php | 60 --- .../IMAP/Search/SearchStrategyFactoryTest.php | 152 ------- tests/Unit/IMAP/Sync/ResponseTest.php | 4 +- .../Unit/IMAP/Sync/SimpleMailboxSyncTest.php | 2 +- tests/Unit/IMAP/Sync/SynchronizerTest.php | 20 +- tests/Unit/Service/AccountServiceTest.php | 14 +- tests/Unit/Service/MailManagerTest.php | 23 +- tests/Unit/Service/MailSearchTest.php | 43 +- 60 files changed, 2398 insertions(+), 830 deletions(-) create mode 100644 lib/BackgroundJob/SyncJob.php create mode 100644 lib/Command/SyncAccount.php create mode 100644 lib/Db/Message.php create mode 100644 lib/Db/MessageMapper.php rename lib/{IMAP/Search/ISearchStrategy.php => Exception/ConcurrentSyncException.php} (77%) create mode 100644 lib/Exception/MailboxNotCachedException.php create mode 100644 lib/Exception/UidValidityChangedException.php delete mode 100644 lib/IMAP/Search/FullScanSearchStrategy.php delete mode 100644 lib/IMAP/Search/ImapSortSearchStrategy.php create mode 100644 lib/IMAP/Search/Provider.php create mode 100644 lib/Migration/FixAccountSyncs.php create mode 100644 lib/Migration/Version1020Date20191002091034.php create mode 100644 lib/Migration/Version1020Date20191002091035.php delete mode 100644 lib/Service/MailSearch.php rename lib/{IMAP/Search/SearchFilterStringParser.php => Service/Search/FilterStringParser.php} (74%) create mode 100644 lib/Service/Search/MailSearch.php create mode 100644 lib/Service/Search/SearchQuery.php create mode 100644 lib/Service/SyncService.php rename lib/{IMAP/Search/SearchStrategyFactory.php => Support/PerformanceLogger.php} (60%) create mode 100644 lib/Support/PerformanceLoggerTask.php delete mode 100644 tests/Unit/IMAP/Search/SearchFilterStringParserTest.php delete mode 100644 tests/Unit/IMAP/Search/SearchStrategyFactoryTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 4f66b13dda..9979486c74 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -12,7 +12,7 @@ - **🙈 We’re not reinventing the wheel!** Based on the great [Horde](http://horde.org) libraries. - **📬 Want to host your own mail server?** We don’t have to reimplement this as you could set up [Mail-in-a-Box](https://mailinabox.email)! ]]> - 1.1.2 + 1.2.0 agpl Christoph Wurst Jan-Christoph Borchardt @@ -34,6 +34,7 @@ OCA\Mail\Migration\FixCollectedAddresses + OCA\Mail\Migration\FixAccountSyncs OCA\Mail\Migration\MakeItineraryExtractorExecutable OCA\Mail\Migration\MigrateProvisioningConfig OCA\Mail\Migration\ProvisionAccounts @@ -43,6 +44,7 @@ OCA\Mail\Command\CreateAccount OCA\Mail\Command\DiagnoseAccount OCA\Mail\Command\ExportAccount + OCA\Mail\Command\SyncAccount OCA\Mail\Settings\AdminSettings diff --git a/lib/Address.php b/lib/Address.php index 4eff928b35..c099893fd5 100644 --- a/lib/Address.php +++ b/lib/Address.php @@ -1,4 +1,5 @@ wrapped = new Horde_Mail_Rfc822_Address($email); // If no label is set we use the email - if ($label !== $email && !is_null($label)) { + if ($label !== $email && $label !== null) { $this->wrapped->personal = $label; } } /** - * @return string + * @return string|null */ - public function getLabel(): string { + public function getLabel(): ?string { $personal = $this->wrapped->personal; - if (is_null($personal)) { + if ($personal === null) { // Fallback return $this->getEmail(); } @@ -58,9 +64,9 @@ public function getLabel(): string { } /** - * @return string + * @return string|null */ - public function getEmail(): string { + public function getEmail(): ?string { return $this->wrapped->bare_address; } diff --git a/lib/AddressList.php b/lib/AddressList.php index 5b2c184ca0..619ffba378 100644 --- a/lib/AddressList.php +++ b/lib/AddressList.php @@ -1,4 +1,4 @@ - @@ -69,6 +69,12 @@ public static function fromHorde(Horde_Mail_Rfc822_List $hordeList) { return new AddressList($addresses); } + public static function fromRow(array $recipient): self { + return new self([ + new Address($recipient['label'], $recipient['email']) + ]); + } + /** * Get first element * diff --git a/lib/AppInfo/BootstrapSingleton.php b/lib/AppInfo/BootstrapSingleton.php index 3c6af088d6..f0451845b5 100644 --- a/lib/AppInfo/BootstrapSingleton.php +++ b/lib/AppInfo/BootstrapSingleton.php @@ -48,7 +48,7 @@ use OCA\Mail\Service\Group\NextcloudGroupService; use OCA\Mail\Service\Group\ContactsGroupService; use OCA\Mail\Service\MailManager; -use OCA\Mail\Service\MailSearch; +use OCA\Mail\Service\Search\MailSearch; use OCA\Mail\Service\MailTransmission; use OCA\Mail\Service\UserPreferenceSevice; use OCP\AppFramework\IAppContainer; diff --git a/lib/BackgroundJob/SyncJob.php b/lib/BackgroundJob/SyncJob.php new file mode 100644 index 0000000000..6dfab3d2ee --- /dev/null +++ b/lib/BackgroundJob/SyncJob.php @@ -0,0 +1,78 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\BackgroundJob; + +use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\SyncService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\TimedJob; +use OCP\ILogger; + +class SyncJob extends TimedJob { + + /** @var AccountService */ + private $accountService; + /** @var SyncService */ + private $syncService; + /** @var ILogger */ + private $logger; + /** @var IJobList */ + private $jobList; + + public function __construct(ITimeFactory $time, + AccountService $accountService, + SyncService $syncService, + ILogger $logger, + IJobList $jobList) { + parent::__construct($time); + + $this->accountService = $accountService; + $this->syncService = $syncService; + $this->logger = $logger; + + $this->setInterval(3600); + $this->jobList = $jobList; + } + + protected function run($argument) { + $accountId = (int)$argument['accountId']; + + try { + $account = $this->accountService->findById($accountId); + } catch (DoesNotExistException $e) { + $this->logger->debug('Could not find account <' . $accountId . '> removing from jobs'); + $this->jobList->remove(self::class, $argument); + return; + } + + try { + $this->syncService->syncAccount($account); + } catch (\Exception $e) { + $this->logger->logException($e); + } + } + +} diff --git a/lib/Command/SyncAccount.php b/lib/Command/SyncAccount.php new file mode 100644 index 0000000000..459d7e5d74 --- /dev/null +++ b/lib/Command/SyncAccount.php @@ -0,0 +1,73 @@ + + * + * @author 2019 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Command; + +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\SyncService; +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; + +class SyncAccount extends Command { + + const ARGUMENT_ACCOUNT_ID = 'account-id'; + const OPTION_FORCE = 'force'; + + /** @var AccountService */ + private $accountService; + + /** @var MailboxMapper */ + private $mailboxMapper; + + /** @var SyncService */ + private $syncService; + + public function __construct(AccountService $service, + MailboxMapper $mailboxMapper, + SyncService $syncService) { + parent::__construct(); + + $this->accountService = $service; + $this->mailboxMapper = $mailboxMapper; + $this->syncService = $syncService; + } + + protected function configure() { + $this->setName('mail:account:sync'); + $this->setDescription('Synchronize an IMAP account'); + $this->addArgument(self::ARGUMENT_ACCOUNT_ID, InputArgument::REQUIRED); + $this->addOption(self::OPTION_FORCE, 'f', InputOption::VALUE_NONE); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + $accountId = (int)$input->getArgument(self::ARGUMENT_ACCOUNT_ID); + $force = $input->getOption(self::OPTION_FORCE); + + $account = $this->accountService->findById($accountId); + $this->syncService->syncAccount($account, $force); + } +} diff --git a/lib/Contracts/IMailManager.php b/lib/Contracts/IMailManager.php index e82077d7a4..9575c6cfcf 100644 --- a/lib/Contracts/IMailManager.php +++ b/lib/Contracts/IMailManager.php @@ -68,17 +68,6 @@ public function getFolderStats(Account $account, string $folderId): FolderStats; */ public function getMessage(Account $account, string $mailbox, int $id, bool $loadBody = false): IMAPMessage; - /** - * @param Account - * @param SyncRequest $syncRequest - * - * @return SyncResponse - * - * @throws ClientException - * @throws ServiceException - */ - public function syncMessages(Account $account, SyncRequest $syncRequest): SyncResponse; - /** * @param Account $sourceAccount * @param string $sourceFolderId diff --git a/lib/Controller/FoldersController.php b/lib/Controller/FoldersController.php index 5316bfb1c8..ca1b10f930 100644 --- a/lib/Controller/FoldersController.php +++ b/lib/Controller/FoldersController.php @@ -25,6 +25,10 @@ namespace OCA\Mail\Controller; +use Horde_Imap_Client; +use OCA\Mail\Exception\MailboxNotCachedException; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Service\SyncService; use function base64_decode; use function is_array; use OCA\Mail\Contracts\IMailManager; @@ -47,6 +51,9 @@ class FoldersController extends Controller { /** @var IMailManager */ private $mailManager; + /** @var SyncService */ + private $syncService; + /** * @param string $appName * @param IRequest $request @@ -54,13 +61,18 @@ class FoldersController extends Controller { * @param string $UserId * @param IMailManager $mailManager */ - public function __construct(string $appName, IRequest $request, - AccountService $accountService, $UserId, IMailManager $mailManager) { + public function __construct(string $appName, + IRequest $request, + AccountService $accountService, + $UserId, + IMailManager $mailManager, + SyncService $syncService) { parent::__construct($appName, $request); $this->accountService = $accountService; $this->currentUserId = $UserId; $this->mailManager = $mailManager; + $this->syncService = $syncService; } /** @@ -91,15 +103,28 @@ public function index(int $accountId): JSONResponse { * @param string $syncToken * @param int[] $uids * @return JSONResponse + * @throws ServiceException */ - public function sync(int $accountId, string $folderId, string $syncToken, array $uids = []): JSONResponse { + public function sync(int $accountId, string $folderId, array $uids): JSONResponse { $account = $this->accountService->find($this->currentUserId, $accountId); - if (empty($accountId) || empty($folderId) || empty($syncToken) || !is_array($uids)) { + if (empty($accountId) || empty($folderId) || !is_array($uids)) { return new JSONResponse(null, Http::STATUS_BAD_REQUEST); } - $syncResponse = $this->mailManager->syncMessages($account, new SyncRequest(base64_decode($folderId), $syncToken, $uids)); + try { + $syncResponse = $this->syncService->syncMailbox( + $account, + base64_decode($folderId), + Horde_Imap_Client::SYNC_NEWMSGSUIDS | Horde_Imap_Client::SYNC_FLAGSUIDS | Horde_Imap_Client::SYNC_VANISHEDUIDS, + array_map(function($uid) { + return (int) $uid; + }, $uids), + true + ); + } catch (MailboxNotCachedException $e) { + return new JSONResponse(null, Http::STATUS_PRECONDITION_REQUIRED); + } return new JSONResponse($syncResponse); } diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php index 2764bbb3c4..7e075621e7 100755 --- a/lib/Controller/MessagesController.php +++ b/lib/Controller/MessagesController.php @@ -40,6 +40,7 @@ use OCA\Mail\Service\AccountService; use OCA\Mail\Service\IMailBox; use OCA\Mail\Service\ItineraryService; +use OCA\Mail\Service\SyncService; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -69,6 +70,9 @@ class MessagesController extends Controller { /** @var ItineraryService */ private $itineraryService; + /** @var SyncService */ + private $syncService; + /** @var string */ private $currentUserId; @@ -104,6 +108,7 @@ public function __construct(string $appName, IMailManager $mailManager, IMailSearch $mailSearch, ItineraryService $itineraryService, + SyncService $syncService, string $UserId, $userFolder, ILogger $logger, @@ -116,6 +121,7 @@ public function __construct(string $appName, $this->mailManager = $mailManager; $this->mailSearch = $mailSearch; $this->itineraryService = $itineraryService; + $this->syncService = $syncService; $this->currentUserId = $UserId; $this->userFolder = $userFolder; $this->logger = $logger; @@ -144,6 +150,11 @@ public function index(int $accountId, string $folderId, int $cursor = null, stri return new JSONResponse(null, Http::STATUS_FORBIDDEN); } + $this->syncService->ensurePopulated( + $account, + base64_decode($folderId) + ); + $this->logger->debug("loading messages of folder <$folderId>"); return new JSONResponse( diff --git a/lib/Db/MailAccountMapper.php b/lib/Db/MailAccountMapper.php index 3881912cc7..a483b5e784 100644 --- a/lib/Db/MailAccountMapper.php +++ b/lib/Db/MailAccountMapper.php @@ -60,7 +60,7 @@ public function find(string $userId, int $accountId): MailAccount { } /** - * @param int $id + * Finds an mail account by id * * @return MailAccount * @throws DoesNotExistException @@ -134,4 +134,13 @@ public function deleteProvisionedAccounts(): void { $delete->execute(); } + public function getAllAccounts(): array { + $qb = $this->db->getQueryBuilder(); + $query = $qb + ->select('*') + ->from($this->getTableName()); + + return $this->findEntities($query); + } + } diff --git a/lib/Db/Mailbox.php b/lib/Db/Mailbox.php index d5727bf7fc..c09b3981a4 100644 --- a/lib/Db/Mailbox.php +++ b/lib/Db/Mailbox.php @@ -37,8 +37,18 @@ * @method void setName(string $name) * @method int getAccountId() * @method void setAccountId(int $accountId) - * @method string|null getSyncToken() - * @method void setSyncToken(string|null $syncToken) + * @method string|null getSyncNewToken() + * @method void setSyncNewToken(string|null $syncNewToken) + * @method string|null getSyncChangedToken() + * @method void setSyncChangedToken(string|null $syncNewToken) + * @method string|null getSyncVanishedToken() + * @method void setSyncVanishedToken(string|null $syncNewToken) + * @method int|null getSyncNewLock() + * @method void setSyncNewLock(int|null $ts) + * @method int|null getSyncChangedLock() + * @method void setSyncChangedLock(int|null $ts) + * @method int|null getSyncVanishedLock() + * @method void setSyncVanishedLock(int|null $ts) * @method string getAttributes() * @method void setAttributes(string $attributes) * @method string getDelimiter() @@ -56,7 +66,12 @@ class Mailbox extends Entity { protected $name; protected $accountId; - protected $syncToken; + protected $syncNewToken; + protected $syncChangedToken; + protected $syncVanishedToken; + protected $syncNewLock; + protected $syncChangedLock; + protected $syncVanishedLock; protected $attributes; protected $delimiter; protected $messages; @@ -68,6 +83,9 @@ public function __construct() { $this->addType('accountId', 'integer'); $this->addType('messages', 'integer'); $this->addType('unseen', 'integer'); + $this->addType('syncNewLock', 'integer'); + $this->addType('syncChangedLock', 'integer'); + $this->addType('syncVanishedLock', 'integer'); $this->addType('selectable', 'boolean'); } @@ -78,7 +96,6 @@ public function toFolder(): Folder { json_decode($this->getAttributes() ?? '[]', true) ?? [], $this->delimiter ); - $folder->setSyncToken($this->getSyncToken()); foreach ($this->getSpecialUseParsed() as $use) { $folder->addSpecialUse($use); } diff --git a/lib/Db/MailboxMapper.php b/lib/Db/MailboxMapper.php index 4cf8e1baf5..3acbde53b1 100644 --- a/lib/Db/MailboxMapper.php +++ b/lib/Db/MailboxMapper.php @@ -24,16 +24,25 @@ namespace OCA\Mail\Db; use OCA\Mail\Account; +use OCA\Mail\Exception\ConcurrentSyncException; use OCA\Mail\Exception\ServiceException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\QBMapper; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; +use OCP\Security\ISecureRandom; class MailboxMapper extends QBMapper { - public function __construct(IDBConnection $db) { + /** @var ITimeFactory */ + private $timeFactory; + + public function __construct(IDBConnection $db, + ITimeFactory $timeFactory) { parent::__construct($db, 'mail_mailboxes'); + $this->timeFactory = $timeFactory; } /** @@ -99,4 +108,90 @@ public function findSpecial(Account $account, string $specialUse): Mailbox { throw new DoesNotExistException("Special mailbox $specialUse does not exist"); } + /** + * @throws ConcurrentSyncException + */ + private function lockForSync(Mailbox $mailbox, string $attr, ?int $lock): int { + $now = $this->timeFactory->getTime(); + + if ($lock !== null + && $lock > ($now - 5 * 60)) { + // Another process is syncing + throw new ConcurrentSyncException($mailbox->getId() . ' is already being synced'); + } + + $query = $this->db->getQueryBuilder(); + $query->update($this->getTableName()) + ->set($attr, $query->createNamedParameter($now, IQueryBuilder::PARAM_INT)) + ->where( + $query->expr()->eq('id', $query->createNamedParameter($mailbox->getId(), IQueryBuilder::PARAM_INT)), + $this->eqOrNull($query, $attr, $lock, IQueryBuilder::PARAM_INT) + ); + if ($query->execute() === 0) { + // Another process just started syncing + + throw new ConcurrentSyncException(); + } + + return $now; + } + + /** + * @throws ConcurrentSyncException + */ + public function lockForNewSync(Mailbox $mailbox): void { + $mailbox->setSyncNewLock( + $this->lockForSync($mailbox, 'sync_new_lock', $mailbox->getSyncNewLock()) + ); + } + + /** + * @throws ConcurrentSyncException + */ + public function lockForChangeSync(Mailbox $mailbox): void { + $mailbox->setSyncChangedLock( + $this->lockForSync($mailbox, 'sync_changed_lock', $mailbox->getSyncChangedLock()) + ); + } + + /** + * @throws ConcurrentSyncException + */ + public function lockForVanishedSync(Mailbox $mailbox): void { + $mailbox->setSyncVanishedLock( + $this->lockForSync($mailbox, 'sync_vanished_lock', $mailbox->getSyncVanishedLock()) + ); + } + + /** + * @param Mailbox $mailbox + * @param IQueryBuilder $query + * + * @return string + */ + private function eqOrNull(IQueryBuilder $query, string $column, $value, int $type): string { + if ($value === null) { + return $query->expr()->isNull($column); + } + return $query->expr()->eq($column, $query->createNamedParameter($value, $type)); + } + + public function unlockFromNewSync(Mailbox $mailbox): void { + $mailbox->setSyncNewLock(null); + + $this->update($mailbox); + } + + public function unlockFromChangedSync(Mailbox $mailbox): void { + $mailbox->setSyncChangedLock(null); + + $this->update($mailbox); + } + + public function unlockFromVanishedSync(Mailbox $mailbox): void { + $mailbox->setSyncVanishedLock(null); + + $this->update($mailbox); + } + } diff --git a/lib/Db/Message.php b/lib/Db/Message.php new file mode 100644 index 0000000000..9ed4391bd3 --- /dev/null +++ b/lib/Db/Message.php @@ -0,0 +1,186 @@ + + * + * @author 2019 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Db; + +use JsonSerializable; +use OCA\Mail\AddressList; +use OCP\AppFramework\Db\Entity; +use function json_encode; + +/** + * @method void setUid(int $uid) + * @method int getUid() + * @method void setMessageId(string $id) + * @method string getMessageId() + * @method void setMailboxId(int $mailbox) + * @method int getMailboxId() + * @method void setSubject(string $subject) + * @method string getSubject() + * @method void setSentAt(int $time) + * @method int getSentAt() + * @method void setFlagAnswered(bool $answered) + * @method bool getFlagAnswered() + * @method void setFlagDeleted(bool $deleted) + * @method bool getFlagDeleted() + * @method void setFlagDraft(bool $answered) + * @method bool getFlagDraft() + * @method void setFlagFlagged(bool $flagged) + * @method bool getFlagFlagged() + * @method void setFlagSeen(bool $seen) + * @method bool getFlagSeen() + * @method void setFlagForwarded(bool $forwarded) + * @method bool getFlagForwarded() + * @method void setFlagJunk(bool $junk) + * @method bool getFlagJunk() + * @method void setFlagNotjunk(bool $notjunk) + * @method bool getFlagNotjunk() + * @method void setUpdatedAt(int $time) + * @method int getUpdatedAt() + */ +class Message extends Entity implements JsonSerializable { + + protected $uid; + protected $messageId; + protected $mailboxId; + protected $subject; + protected $sentAt; + protected $flagAnswered; + protected $flagDeleted; + protected $flagDraft; + protected $flagFlagged; + protected $flagSeen; + protected $flagForwarded; + protected $flagJunk; + protected $flagNotjunk; + protected $updatedAt; + + /** @var AddressList */ + private $from; + + /** @var AddressList */ + private $to; + + /** @var AddressList */ + private $cc; + + /** @var AddressList */ + private $bcc; + + public function __construct() { + $this->from = new AddressList([]); + $this->to = new AddressList([]); + $this->cc = new AddressList([]); + $this->bcc = new AddressList([]); + + $this->addType('uid', 'integer'); + $this->addType('sentAt', 'integer'); + $this->addType('flagAnswered', 'bool'); + $this->addType('flagDeleted', 'bool'); + $this->addType('flagDraft', 'bool'); + $this->addType('flagFlagged', 'bool'); + $this->addType('flagSeen', 'bool'); + $this->addType('flagForwarded', 'bool'); + $this->addType('flagJunk', 'bool'); + $this->addType('flagNotjunk', 'bool'); + $this->addType('updatedAt', 'integer'); + } + + /** + * @return AddressList + */ + public function getFrom(): AddressList { + return $this->from; + } + + /** + * @param AddressList $from + */ + public function setFrom(AddressList $from): void { + $this->from = $from; + } + + /** + * @return AddressList + */ + public function getTo(): AddressList { + return $this->to; + } + + /** + * @param AddressList $to + */ + public function setTo(AddressList $to): void { + $this->to = $to; + } + + /** + * @return AddressList + */ + public function getCc(): AddressList { + return $this->cc; + } + + /** + * @param AddressList $cc + */ + public function setCc(AddressList $cc): void { + $this->cc = $cc; + } + + /** + * @return AddressList + */ + public function getBcc(): AddressList { + return $this->bcc; + } + + /** + * @param AddressList $bcc + */ + public function setBcc(AddressList $bcc): void { + $this->bcc = $bcc; + } + + public function jsonSerialize() { + return [ + 'id' => $this->getUid(), // Change to UID on front-end + 'subject' => $this->getSubject(), + 'dateInt' => $this->getSentAt(), + 'flags' => [ + 'unseen' => !$this->getFlagSeen(), + 'flagged' => $this->getFlagFlagged(), + 'answered' => $this->getFlagAnswered(), + 'deleted' => $this->getFlagDeleted(), + 'draft' => $this->getFlagDraft(), + 'forwarded' => $this->getFlagForwarded(), + 'hasAttachments' => false, // TODO + ], + 'from' => $this->getFrom()->jsonSerialize(), + 'to' => $this->getTo()->jsonSerialize(), + 'cc' => $this->getCc()->jsonSerialize(), + 'bcc' => $this->getBcc()->jsonSerialize(), + ]; + } + +} diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php new file mode 100644 index 0000000000..c3cb8b42b5 --- /dev/null +++ b/lib/Db/MessageMapper.php @@ -0,0 +1,378 @@ + + * + * @author 2019 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Db; + +use Horde_Imap_Client; +use OCA\Mail\Address; +use OCA\Mail\AddressList; +use OCA\Mail\Service\Search\SearchQuery; +use OCP\AppFramework\Db\QBMapper; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use function array_combine; +use function array_keys; +use function array_map; + +class MessageMapper extends QBMapper { + + /** @var ITimeFactory */ + private $timeFactory; + + public function __construct(IDBConnection $db, + ITimeFactory $timeFactory) { + parent::__construct($db, 'mail_messages'); + $this->timeFactory = $timeFactory; + } + + public function findAllUids(Mailbox $mailbox): array { + $query = $this->db->getQueryBuilder(); + + $query->select('uid') + ->from($this->getTableName()) + ->where($query->expr()->eq('mailbox_id', $query->createNamedParameter($mailbox->getId()))); + + $result = $query->execute(); + $uids = array_map(function (array $row) { + return (int) $row['uid']; + }, $result->fetchAll()); + $result->closeCursor(); + + return $uids; + } + + public function insertBulk(Message ...$messages): void { + $this->db->beginTransaction(); + + $qb1 = $this->db->getQueryBuilder(); + $qb1->insert($this->getTableName()); + $qb1->setValue('uid', $qb1->createParameter('uid')); + $qb1->setValue('message_id', $qb1->createParameter('message_id')); + $qb1->setValue('mailbox_id', $qb1->createParameter('mailbox_id')); + $qb1->setValue('subject', $qb1->createParameter('subject')); + $qb1->setValue('sent_at', $qb1->createParameter('sent_at')); + $qb1->setValue('flag_answered', $qb1->createParameter('flag_answered')); + $qb1->setValue('flag_deleted', $qb1->createParameter('flag_deleted')); + $qb1->setValue('flag_draft', $qb1->createParameter('flag_draft')); + $qb1->setValue('flag_flagged', $qb1->createParameter('flag_flagged')); + $qb1->setValue('flag_seen', $qb1->createParameter('flag_seen')); + $qb1->setValue('flag_forwarded', $qb1->createParameter('flag_forwarded')); + $qb1->setValue('flag_junk', $qb1->createParameter('flag_junk')); + $qb1->setValue('flag_notjunk', $qb1->createParameter('flag_notjunk')); + $qb2 = $this->db->getQueryBuilder(); + + $qb2->insert('mail_recipients') + ->setValue('message_id', $qb2->createParameter('message_id')) + ->setValue('type', $qb2->createParameter('type')) + ->setValue('label', $qb2->createParameter('label')) + ->setValue('email', $qb2->createParameter('email')); + + foreach ($messages as $message) { + $qb1->setParameter('uid', $message->getUid(), IQueryBuilder::PARAM_INT); + $qb1->setParameter('message_id', $message->getMessageId(), IQueryBuilder::PARAM_STR); + $qb1->setParameter('mailbox_id', $message->getMailboxId(), IQueryBuilder::PARAM_INT); + $qb1->setParameter('subject', $message->getSubject(), IQueryBuilder::PARAM_STR); + $qb1->setParameter('sent_at', $message->getSentAt(), IQueryBuilder::PARAM_INT); + $qb1->setParameter('flag_answered', $message->getFlagAnswered(), IQueryBuilder::PARAM_BOOL); + $qb1->setParameter('flag_deleted', $message->getFlagDeleted(), IQueryBuilder::PARAM_BOOL); + $qb1->setParameter('flag_draft', $message->getFlagDraft(), IQueryBuilder::PARAM_BOOL); + $qb1->setParameter('flag_flagged', $message->getFlagFlagged(), IQueryBuilder::PARAM_BOOL); + $qb1->setParameter('flag_seen', $message->getFlagSeen(), IQueryBuilder::PARAM_BOOL); + $qb1->setParameter('flag_forwarded', $message->getFlagForwarded(), IQueryBuilder::PARAM_BOOL); + $qb1->setParameter('flag_junk', $message->getFlagJunk(), IQueryBuilder::PARAM_BOOL); + $qb1->setParameter('flag_notjunk', $message->getFlagNotjunk(), IQueryBuilder::PARAM_BOOL); + + $qb1->execute(); + + $messageId = $qb1->getLastInsertId(); + $recipientTypes = [ + Address::TYPE_FROM => $message->getFrom(), + Address::TYPE_TO => $message->getTo(), + Address::TYPE_CC => $message->getCc(), + Address::TYPE_BCC => $message->getBcc(), + ]; + foreach ($recipientTypes as $type => $recipients) { + /** @var AddressList $recipients */ + foreach ($recipients->iterate() as $recipient) { + /** @var Address $recipient */ + if ($recipient->getEmail() === null) { + // If for some reason the e-mail is not set we should ignore this entry + continue; + } + + $qb2->setParameter('message_id', $messageId, IQueryBuilder::PARAM_INT); + $qb2->setParameter('type', $type, IQueryBuilder::PARAM_INT); + $qb2->setParameter('label', $recipient->getLabel(), IQueryBuilder::PARAM_STR); + $qb2->setParameter('email', $recipient->getEmail(), IQueryBuilder::PARAM_STR); + + $qb2->execute(); + } + } + } + + $this->db->commit(); + } + + public function updateBulk(Message ...$messages): void { + $this->db->beginTransaction(); + + $query = $this->db->getQueryBuilder(); + $query->update($this->getTableName()) + ->set('flag_answered', $query->createParameter('flag_answered')) + ->set('flag_deleted', $query->createParameter('flag_deleted')) + ->set('flag_draft', $query->createParameter('flag_draft')) + ->set('flag_flagged', $query->createParameter('flag_flagged')) + ->set('flag_seen', $query->createParameter('flag_seen')) + ->set('flag_forwarded', $query->createParameter('flag_forwarded')) + ->set('flag_junk', $query->createParameter('flag_junk')) + ->set('flag_notjunk', $query->createParameter('flag_notjunk')) + ->set('updated_at', $query->createNamedParameter($this->timeFactory->getTime())) + ->where($query->expr()->andX( + $query->expr()->eq('uid', $query->createParameter('uid')), + $query->expr()->eq('mailbox_id', $query->createParameter('mailbox_id')) + )); + + foreach ($messages as $message) { + $query->setParameter('uid', $message->getUid(), IQueryBuilder::PARAM_INT); + $query->setParameter('mailbox_id', $message->getMailboxId(), IQueryBuilder::PARAM_INT); + $query->setParameter('flag_answered', $message->getFlagAnswered(), IQueryBuilder::PARAM_BOOL); + $query->setParameter('flag_deleted', $message->getFlagDeleted(), IQueryBuilder::PARAM_BOOL); + $query->setParameter('flag_draft', $message->getFlagDraft(), IQueryBuilder::PARAM_BOOL); + $query->setParameter('flag_flagged', $message->getFlagFlagged(), IQueryBuilder::PARAM_BOOL); + $query->setParameter('flag_seen', $message->getFlagSeen(), IQueryBuilder::PARAM_BOOL); + $query->setParameter('flag_forwarded', $message->getFlagForwarded(), IQueryBuilder::PARAM_BOOL); + $query->setParameter('flag_junk', $message->getFlagJunk(), IQueryBuilder::PARAM_BOOL); + $query->setParameter('flag_notjunk', $message->getFlagNotjunk(), IQueryBuilder::PARAM_BOOL); + + $query->execute(); + } + + $this->db->commit(); + } + + public function deleteAll(Mailbox $mailbox): void { + $query = $this->db->getQueryBuilder(); + + $query->delete($this->getTableName()) + ->where($query->expr()->eq('mailbox_id', $query->createNamedParameter($mailbox->getId()))); + + $query->execute(); + } + + public function deleteByUid(Mailbox $mailbox, int ...$uids): void { + $query = $this->db->getQueryBuilder(); + + $query->delete($this->getTableName()) + ->where( + $query->expr()->eq('mailbox_id', $query->createNamedParameter($mailbox->getId())), + $query->expr()->in('uid', $query->createNamedParameter($uids, IQueryBuilder::PARAM_INT_ARRAY)) + ); + + $query->execute(); + } + + /** + * @param Mailbox $mailbox + * @param SearchQuery $query + * + * @return int[] + */ + public function findUidsByQuery(Mailbox $mailbox, SearchQuery $query): array { + $qb = $this->db->getQueryBuilder(); + + $select = $qb + ->selectDistinct('m.uid') + ->from($this->getTableName(), 'm'); + + if (!empty($query->getFrom())) { + $select->innerJoin('m', 'mail_recipients', 'r0', 'm.id = r0.message_id'); + } + if (!empty($query->getTo())) { + $select->innerJoin('m', 'mail_recipients', 'r1', 'm.id = r1.message_id'); + } + if (!empty($query->getCc())) { + $select->innerJoin('m', 'mail_recipients', 'r2', 'm.id = r2.message_id'); + } + if (!empty($query->getBcc())) { + $select->innerJoin('m', 'mail_recipients', 'r3', 'm.id = r3.message_id'); + } + + $select->where( + $qb->expr()->eq('mailbox_id', $qb->createNamedParameter($mailbox->getId()), IQueryBuilder::PARAM_INT) + ); + + if (!empty($query->getFrom())) { + $select->andWhere( + $qb->expr()->in('r0.email', $qb->createNamedParameter($query->getFrom(), IQueryBuilder::PARAM_STR_ARRAY)) + ); + } + if (!empty($query->getTo())) { + $select->andWhere( + $qb->expr()->in('r1.email', $qb->createNamedParameter($query->getTo(), IQueryBuilder::PARAM_STR_ARRAY)) + ); + } + if (!empty($query->getTo())) { + $select->andWhere( + $qb->expr()->in('r2.email', $qb->createNamedParameter($query->getCc(), IQueryBuilder::PARAM_STR_ARRAY)) + ); + } + if (!empty($query->getTo())) { + $select->andWhere( + $qb->expr()->in('r3.email', $qb->createNamedParameter($query->getBcc(), IQueryBuilder::PARAM_STR_ARRAY)) + ); + } + + if ($query->getCursor() !== null) { + $select->andWhere( + $qb->expr()->lt('sent_at', $qb->createNamedParameter($query->getCursor(), IQueryBuilder::PARAM_INT)) + ); + } + + $flags = $query->getFlags(); + $flagKeys = array_keys($flags); + foreach ([ + Horde_Imap_Client::FLAG_ANSWERED, + Horde_Imap_Client::FLAG_DELETED, + Horde_Imap_Client::FLAG_DRAFT, + Horde_Imap_Client::FLAG_FLAGGED, + Horde_Imap_Client::FLAG_RECENT, + Horde_Imap_Client::FLAG_SEEN, + Horde_Imap_Client::FLAG_FORWARDED, + Horde_Imap_Client::FLAG_JUNK, + Horde_Imap_Client::FLAG_NOTJUNK, + ] as $flag) { + if (in_array($flag, $flagKeys, true)) { + $key = ltrim($flag, '\\'); + $select->andWhere($qb->expr()->eq("flag_$key", $qb->createNamedParameter($flags[$flag], IQueryBuilder::PARAM_BOOL))); + } + } + + $select = $select + ->orderBy('sent_at', 'desc') + ->setMaxResults(20); + + return array_map(function (Message $message) { + return $message->getUid(); + }, $this->findEntities($select)); + } + + /** + * @param Mailbox $mailbox + * @param int[] $uids + * + * @return Message[] + */ + public function findByUids(Mailbox $mailbox, array $uids): array { + $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('uid', $qb->createNamedParameter($uids, IQueryBuilder::PARAM_INT_ARRAY)) + ) + ->orderBy('sent_at', 'desc'); + + return $this->findRecipients($this->findEntities($select)); + } + + /** + * @param Message[] $messages + * @return Message[] + */ + private function findRecipients(array $messages): array { + /** @var Message[] $indexedMessages */ + $indexedMessages = array_combine( + array_map(function (Message $msg) { + return $msg->getId(); + }, $messages), + $messages + ); + $qb2 = $this->db->getQueryBuilder(); + $qb2->select('label', 'email', 'type', 'message_id') + ->from('mail_recipients') + ->where( + $qb2->expr()->in('message_id', $qb2->createNamedParameter(array_keys($indexedMessages), IQueryBuilder::PARAM_INT_ARRAY)) + ); + $recipientsResult = $qb2->execute(); + foreach ($recipientsResult->fetchAll() as $recipient) { + $message = $indexedMessages[(int)$recipient['message_id']]; + switch ($recipient['type']) { + case Address::TYPE_FROM: + $message->setFrom( + $message->getFrom()->merge(AddressList::fromRow($recipient)) + ); + break; + case Address::TYPE_TO: + $message->setTo( + $message->getTo()->merge(AddressList::fromRow($recipient)) + ); + break; + case Address::TYPE_CC: + $message->setCc( + $message->getCc()->merge(AddressList::fromRow($recipient)) + ); + break; + case Address::TYPE_BCC: + $message->setFrom( + $message->getFrom()->merge(AddressList::fromRow($recipient)) + ); + break; + } + } + $recipientsResult->closeCursor(); + + return $messages; + } + + public function findNew(Mailbox $mailbox, int $highest): array { + $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()->gt('uid', $qb->createNamedParameter($highest, IQueryBuilder::PARAM_INT)) + ); + + return $this->findRecipients($this->findEntities($select)); + } + + public function findChanged(Mailbox $mailbox, int $since): array { + $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()->gt('updated_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT)) + ); + + return $this->findRecipients($this->findEntities($select)); + } + +} diff --git a/lib/IMAP/Search/ISearchStrategy.php b/lib/Exception/ConcurrentSyncException.php similarity index 77% rename from lib/IMAP/Search/ISearchStrategy.php rename to lib/Exception/ConcurrentSyncException.php index 264a0f33ce..7cc91136dd 100644 --- a/lib/IMAP/Search/ISearchStrategy.php +++ b/lib/Exception/ConcurrentSyncException.php @@ -21,16 +21,10 @@ * along with this program. If not, see . */ -namespace OCA\Mail\IMAP\Search; +namespace OCA\Mail\Exception; -use Horde_Imap_Client_Exception; -use Horde_Imap_Client_Ids; +use Exception; -interface ISearchStrategy { - - /** - * @throws Horde_Imap_Client_Exception - */ - public function getIds(int $maxResults, array $flags = []): Horde_Imap_Client_Ids; +class ConcurrentSyncException extends Exception { } diff --git a/lib/Exception/MailboxNotCachedException.php b/lib/Exception/MailboxNotCachedException.php new file mode 100644 index 0000000000..37242b1ada --- /dev/null +++ b/lib/Exception/MailboxNotCachedException.php @@ -0,0 +1,7 @@ + + * + * @author 2019 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\mail\lib\Exception; + +use OCA\Mail\Exception\ServiceException; + +class UidValidityChangedException extends ServiceException { + +} diff --git a/lib/Folder.php b/lib/Folder.php index 486c21c4a7..9048bd68d4 100644 --- a/lib/Folder.php +++ b/lib/Folder.php @@ -48,8 +48,6 @@ class Folder implements JsonSerializable { private $specialUse; /** @var string */ - private $syncToken; - /** * @param Account $account * @param Horde_Imap_Client_Mailbox $mailbox @@ -129,17 +127,6 @@ public function isSearchable() { return !in_array('\noselect', $this->getAttributes()); } - /** - * @param string $syncToken - */ - public function setSyncToken($syncToken) { - $this->syncToken = $syncToken; - } - - public function getSyncToken(): ?string { - return $this->syncToken; - } - /** * @return array */ @@ -161,7 +148,6 @@ public function jsonSerialize() { 'folders' => array_values($folders), 'specialUse' => $this->specialUse, 'specialRole' => empty($this->specialUse) ? null : $this->specialUse[0], - 'syncToken' => $this->syncToken, ]; } diff --git a/lib/IMAP/FolderMapper.php b/lib/IMAP/FolderMapper.php index 87ce287220..0a6bd8f7a5 100644 --- a/lib/IMAP/FolderMapper.php +++ b/lib/IMAP/FolderMapper.php @@ -76,16 +76,9 @@ public function getFolders(Account $account, Horde_Imap_Client_Socket $client, $mailbox['delimiter'] ); - if ($folder->isSearchable()) { - $folder->setSyncToken($client->getSyncToken($folder->getMailbox())); - } - $folders[] = $folder; if ($mailbox['mailbox']->utf8 === 'INBOX') { $searchFolder = new SearchFolder($account->getId(), $mailbox['mailbox'], $mailbox['attributes'], $mailbox['delimiter']); - if ($folder->isSearchable()) { - $searchFolder->setSyncToken($client->getSyncToken($folder->getMailbox())); - } $folders[] = $searchFolder; } } diff --git a/lib/IMAP/MailboxSync.php b/lib/IMAP/MailboxSync.php index bbd19e7967..c631b41561 100644 --- a/lib/IMAP/MailboxSync.php +++ b/lib/IMAP/MailboxSync.php @@ -115,7 +115,6 @@ private function persist(Account $account, array $folders, array $existing): voi private function updateMailboxFromFolder(Folder $folder, Mailbox $mailbox): void { $mailbox->setDelimiter($folder->getDelimiter()); - $mailbox->setSyncToken($folder->getSyncToken()); $mailbox->setAttributes(json_encode($folder->getAttributes())); $mailbox->setDelimiter($folder->getDelimiter()); $mailbox->setMessages(0); // TODO @@ -129,7 +128,6 @@ private function createMailboxFromFolder(Account $account, Folder $folder): void $mailbox = new Mailbox(); $mailbox->setName($folder->getMailbox()); $mailbox->setAccountId($account->getId()); - $mailbox->setSyncToken($folder->getSyncToken()); $mailbox->setAttributes(json_encode($folder->getAttributes())); $mailbox->setDelimiter($folder->getDelimiter()); $mailbox->setMessages(0); // TODO diff --git a/lib/IMAP/MessageMapper.php b/lib/IMAP/MessageMapper.php index f42f8bc358..d0366912a2 100644 --- a/lib/IMAP/MessageMapper.php +++ b/lib/IMAP/MessageMapper.php @@ -66,6 +66,33 @@ public function find(Horde_Imap_Client_Base $client, return $result[0]; } + /** + * @param Horde_Imap_Client_Socket $client + * @param Mailbox $mailbox + * + * @return IMAPMessage[] + * @throws Horde_Imap_Client_Exception + */ + public function findAll(Horde_Imap_Client_Socket $client, Mailbox $mailbox): array { + $query = new Horde_Imap_Client_Fetch_Query(); + $query->uid(); + + return $this->findByIds( + $client, + $mailbox->getMailbox(), + array_map( + function(Horde_Imap_Client_Data_Fetch $data) { + return $data->getUid(); + }, + iterator_to_array($client->fetch( + $mailbox->getMailbox(), + $query, + [] + )) + ) + ); + } + /** * @return IMAPMessage[] * @throws Horde_Imap_Client_Exception @@ -77,10 +104,8 @@ public function findByIds(Horde_Imap_Client_Base $client, $query = new Horde_Imap_Client_Fetch_Query(); $query->envelope(); $query->flags(); - $query->size(); $query->uid(); $query->imapDate(); - $query->structure(); $fetchResults = iterator_to_array($client->fetch($mailbox, $query, [ 'ids' => new Horde_Imap_Client_Ids($ids), diff --git a/lib/IMAP/Search/FullScanSearchStrategy.php b/lib/IMAP/Search/FullScanSearchStrategy.php deleted file mode 100644 index 758dde1be9..0000000000 --- a/lib/IMAP/Search/FullScanSearchStrategy.php +++ /dev/null @@ -1,86 +0,0 @@ - - * - * @author 2019 Christoph Wurst - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -namespace OCA\Mail\IMAP\Search; - -use Horde_Imap_Client_Exception; -use Horde_Imap_Client_Fetch_Query; -use Horde_Imap_Client_Ids; -use Horde_Imap_Client_Socket; -use function array_keys; -use function array_slice; -use function uasort; - -class FullScanSearchStrategy implements ISearchStrategy { - - /** @var Horde_Imap_Client_Socket */ - private $client; - - /** @var string */ - private $mailbox; - - /** @var int|null */ - private $cursor; - - public function __construct(Horde_Imap_Client_Socket $client, - string $mailbox, - ?int $cursor) { - $this->client = $client; - $this->mailbox = $mailbox; - $this->cursor = $cursor; - } - - /** - * Scan all messages of a mailbox and filter out matching ones - * - * This is slow, but some IMAP server don't support the SORT capability. - * - * @throws Horde_Imap_Client_Exception - */ - public function getIds(int $maxResults, array $flags = []): Horde_Imap_Client_Ids { - $query = new Horde_Imap_Client_Fetch_Query(); - $query->uid(); - $query->imapDate(); - - $result = $this->client->fetch($this->mailbox, $query); - $uidMap = []; - foreach ($result as $r) { - $ts = $r->getImapDate()->getTimeStamp(); - if ($this->cursor === null || $ts < $this->cursor) { - $uidMap[$r->getUid()] = $ts; - } - } - // sort by time - uasort($uidMap, function ($a, $b) { - return $a < $b; - }); - return new Horde_Imap_Client_Ids( - array_slice( - array_keys($uidMap), - 0, - $maxResults - ) - ); - } - -} diff --git a/lib/IMAP/Search/ImapSortSearchStrategy.php b/lib/IMAP/Search/ImapSortSearchStrategy.php deleted file mode 100644 index 7c61de35df..0000000000 --- a/lib/IMAP/Search/ImapSortSearchStrategy.php +++ /dev/null @@ -1,109 +0,0 @@ - - * - * @author 2019 Christoph Wurst - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -namespace OCA\Mail\IMAP\Search; - -use DateTime; -use Horde_Imap_Client; -use Horde_Imap_Client_Exception; -use Horde_Imap_Client_Ids; -use Horde_Imap_Client_Search_Query; -use Horde_Imap_Client_Socket; -use OCA\Mail\IMAP\Search\SearchFilterStringParser; -use function array_reverse; -use function array_slice; - -class ImapSortSearchStrategy implements ISearchStrategy { - - /** @var Horde_Imap_Client_Socket */ - private $client; - - /** @var string */ - private $mailbox; - - /** @var Horde_Imap_Client_Search_Query */ - private $query; - - /** @var int|null */ - private $cursor; - - /** @var ISearchStrategy */ - private $fallback; - - public function __construct(Horde_Imap_Client_Socket $client, - string $mailbox, - Horde_Imap_Client_Search_Query $query, - ?int $cursor, - ISearchStrategy $fallback) { - $this->client = $client; - $this->mailbox = $mailbox; - $this->query = $query; - $this->cursor = $cursor; - $this->fallback = $fallback; - } - - /** - * @param int $maxResults - * @param array $flags - * - * @return Horde_Imap_Client_Ids - * @throws Horde_Imap_Client_Exception - */ - public function getIds(int $maxResults, array $flags = []): Horde_Imap_Client_Ids { - $query = clone $this->query; - - if ($this->cursor !== null) { - $query->dateTimeSearch( - DateTime::createFromFormat("U", (string) $this->cursor), - Horde_Imap_Client_Search_Query::DATE_BEFORE - ); - } - - try { - $result = $this->client->search( - $this->mailbox, - $query, - [ - 'sort' => [ - Horde_Imap_Client::SORT_REVERSE, - Horde_Imap_Client::SORT_DATE - ], - ] - ); - } catch (Horde_Imap_Client_Exception $e) { - // maybe the server's advertisement of SORT was a fake - // see https://github.com/nextcloud/mail/issues/50 - // try again without SORT - return $this->fallback->getIds($maxResults, $flags); - } - - return new Horde_Imap_Client_Ids( - array_slice( - $result['match']->ids, - 0, - $maxResults - ) - ); - } - -} diff --git a/lib/IMAP/Search/Provider.php b/lib/IMAP/Search/Provider.php new file mode 100644 index 0000000000..4cccc9fa83 --- /dev/null +++ b/lib/IMAP/Search/Provider.php @@ -0,0 +1,82 @@ + + * + * @author 2019 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\IMAP\Search; + +use Horde_Imap_Client_Exception; +use Horde_Imap_Client_Search_Query; +use OCA\Mail\Account; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Service\Search\SearchQuery; +use OCP\ILogger; + +class Provider { + + /** @var IMAPClientFactory */ + private $clientFactory; + + /** @var ILogger */ + private $logger; + + public function __construct(IMAPClientFactory $clientFactory, + ILogger $logger) { + $this->clientFactory = $clientFactory; + $this->logger = $logger; + } + + /** + * @return int[] + * @throws ServiceException + */ + public function findMatches(Account $account, + Mailbox $mailbox, + SearchQuery $searchQuery): array { + $client = $this->clientFactory->getClient($account); + + try { + $fetchResult = $client->search( + $mailbox->getMailbox(), + $this->convertMailQueryToHordeQuery($searchQuery) + ); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException('Could not get message IDs: ' . $e->getMessage(), 0, $e); + } + + return $fetchResult['match']->ids; + } + + private function convertMailQueryToHordeQuery(SearchQuery $searchQuery): Horde_Imap_Client_Search_Query { + $query = new Horde_Imap_Client_Search_Query(); + + foreach ($searchQuery->getFlags() as $flag => $set) { + $query->flag($flag, $set); + } + + // TODO: text, header text + + return $query; + } + +} diff --git a/lib/IMAP/Sync/ISyncStrategy.php b/lib/IMAP/Sync/ISyncStrategy.php index e8e69504df..028d419a9f 100644 --- a/lib/IMAP/Sync/ISyncStrategy.php +++ b/lib/IMAP/Sync/ISyncStrategy.php @@ -57,6 +57,6 @@ public function getChangedMessages(Horde_Imap_Client_Base $imapClient, * @param Horde_Imap_Client_Data_Sync $hordeSync * @return int[] */ - public function getVanishedMessages(Horde_Imap_Client_Base $imapClient, - Request $syncRequest, Horde_Imap_Client_Data_Sync $hordeSync): array; + public function getVanishedMessageUids(Horde_Imap_Client_Base $imapClient, + Request $syncRequest, Horde_Imap_Client_Data_Sync $hordeSync): array; } diff --git a/lib/IMAP/Sync/Response.php b/lib/IMAP/Sync/Response.php index 00276f13c0..1ae46b5952 100644 --- a/lib/IMAP/Sync/Response.php +++ b/lib/IMAP/Sync/Response.php @@ -28,39 +28,63 @@ class Response implements JsonSerializable { - /** @var string */ - private $syncToken; - /** @var IMAPMessage[] */ private $newMessages; /** @var IMAPMessage[] */ private $changedMessages; - /** @var array */ - private $vanishedMessages; + /** @var int[] */ + private $vanishedMessageUids; /** * @param string $syncToken * @param IMAPMessage[] $newMessages * @param IMAPMessage[] $changedMessages - * @param array $vanishedMessages + * @param int[] $vanishedMessageUids */ - public function __construct(string $syncToken, array $newMessages = [], array $changedMessages = [], - array $vanishedMessages = []) { - $this->syncToken = $syncToken; + public function __construct(array $newMessages = [], array $changedMessages = [], + array $vanishedMessageUids = []) { $this->newMessages = $newMessages; $this->changedMessages = $changedMessages; - $this->vanishedMessages = $vanishedMessages; + $this->vanishedMessageUids = $vanishedMessageUids; + } + + /** + * @return IMAPMessage[] + */ + public function getNewMessages(): array { + return $this->newMessages; + } + + /** + * @return IMAPMessage[] + */ + public function getChangedMessages(): array { + return $this->changedMessages; + } + + /** + * @return int[] + */ + public function getVanishedMessageUids(): array { + return $this->vanishedMessageUids; } public function jsonSerialize(): array { return [ 'newMessages' => $this->newMessages, 'changedMessages' => $this->changedMessages, - 'vanishedMessages' => $this->vanishedMessages, - 'token' => $this->syncToken, + 'vanishedMessages' => $this->vanishedMessageUids, ]; } + public function merge(Response $other): self { + return new self( + array_merge($this->getNewMessages(), $other->getNewMessages()), + array_merge($this->getChangedMessages(), $other->getChangedMessages()), + array_merge($this->getVanishedMessageUids(), $other->getVanishedMessageUids()) + ); + } + } diff --git a/lib/IMAP/Sync/SimpleMailboxSync.php b/lib/IMAP/Sync/SimpleMailboxSync.php index 002c49c2bf..919ea7367f 100644 --- a/lib/IMAP/Sync/SimpleMailboxSync.php +++ b/lib/IMAP/Sync/SimpleMailboxSync.php @@ -69,8 +69,8 @@ public function getChangedMessages(Horde_Imap_Client_Base $imapClient, * @param Horde_Imap_Client_Data_Sync $hordeSync * @return IMAPMessage[] */ - public function getVanishedMessages(Horde_Imap_Client_Base $imapClient, - Request $syncRequest, Horde_Imap_Client_Data_Sync $hordeSync): array { + public function getVanishedMessageUids(Horde_Imap_Client_Base $imapClient, + Request $syncRequest, Horde_Imap_Client_Data_Sync $hordeSync): array { return $hordeSync->vanisheduids->ids; } diff --git a/lib/IMAP/Sync/Synchronizer.php b/lib/IMAP/Sync/Synchronizer.php index 54db5128b1..d286f33ecf 100644 --- a/lib/IMAP/Sync/Synchronizer.php +++ b/lib/IMAP/Sync/Synchronizer.php @@ -23,11 +23,13 @@ namespace OCA\Mail\IMAP\Sync; +use Horde_Imap_Client; use Horde_Imap_Client_Base; use Horde_Imap_Client_Exception; use Horde_Imap_Client_Exception_Sync; use Horde_Imap_Client_Ids; use Horde_Imap_Client_Mailbox; +use OCA\mail\lib\Exception\UidValidityChangedException; class Synchronizer { @@ -50,24 +52,36 @@ public function __construct(SimpleMailboxSync $simpleSync, /** * @param Horde_Imap_Client_Base $imapClient * @param Request $request + * @param int $criteria + * * @return Response * @throws Horde_Imap_Client_Exception * @throws Horde_Imap_Client_Exception_Sync + * @throws UidValidityChangedException */ - public function sync(Horde_Imap_Client_Base $imapClient, Request $request): Response { + public function sync(Horde_Imap_Client_Base $imapClient, + Request $request, + int $criteria = Horde_Imap_Client::SYNC_NEWMSGSUIDS|Horde_Imap_Client::SYNC_FLAGSUIDS|Horde_Imap_Client::SYNC_VANISHEDUIDS): Response { $mailbox = new Horde_Imap_Client_Mailbox($request->getMailbox()); $ids = new Horde_Imap_Client_Ids($request->getUids()); - $hordeSync = $imapClient->sync($mailbox, $request->getToken(), [ - 'ids' => $ids - ]); + try { + $hordeSync = $imapClient->sync($mailbox, $request->getToken(), [ + 'criteria' => $criteria, + 'ids' => $ids + ]); + } catch (Horde_Imap_Client_Exception_Sync $e) { + if ($e->getCode() === Horde_Imap_Client_Exception_Sync::UIDVALIDITY_CHANGED) { + throw new UidValidityChangedException(); + } + throw $e; + } $syncStrategy = $this->getSyncStrategy($request); $newMessages = $syncStrategy->getNewMessages($imapClient, $request, $hordeSync); $changedMessages = $syncStrategy->getChangedMessages($imapClient, $request, $hordeSync); - $vanishedMessages = $syncStrategy->getVanishedMessages($imapClient, $request, $hordeSync); + $vanishedMessageUids = $syncStrategy->getVanishedMessageUids($imapClient, $request, $hordeSync); - $newSyncToken = $imapClient->getSyncToken($request->getMailbox()); - return new Response($newSyncToken, $newMessages, $changedMessages, $vanishedMessages); + return new Response($newMessages, $changedMessages, $vanishedMessageUids); } /** diff --git a/lib/Migration/FixAccountSyncs.php b/lib/Migration/FixAccountSyncs.php new file mode 100644 index 0000000000..319e0cb4e3 --- /dev/null +++ b/lib/Migration/FixAccountSyncs.php @@ -0,0 +1,64 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Migration; + +use OCA\Mail\BackgroundJob\SyncJob; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\MailAccountMapper; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class FixAccountSyncs implements IRepairStep { + + /** @var IJobList */ + private $jobList; + /** @var MailAccountMapper */ + private $mapper; + + public function __construct(IJobList $jobList, MailAccountMapper $mapper) { + $this->jobList = $jobList; + $this->mapper = $mapper; + } + + public function getName(): string { + return 'Insert sync background job for all accounts'; + } + + public function run(IOutput $output) { + /** @var MailAccount[] $accounts */ + $accounts = $this->mapper->getAllAccounts(); + + $output->startProgress(count($accounts)); + + foreach ($accounts as $account) { + $this->jobList->add(SyncJob::class, ['accountId' => $account->getId()]); + $output->advance(); + } + + $output->finishProgress(); + } + +} diff --git a/lib/Migration/Version1020Date20191002091034.php b/lib/Migration/Version1020Date20191002091034.php new file mode 100644 index 0000000000..4020be6147 --- /dev/null +++ b/lib/Migration/Version1020Date20191002091034.php @@ -0,0 +1,33 @@ +getTable('mail_mailboxes'); + $mailboxTable->dropColumn('sync_token'); + + return $schema; + } + +} diff --git a/lib/Migration/Version1020Date20191002091035.php b/lib/Migration/Version1020Date20191002091035.php new file mode 100644 index 0000000000..d0cb3b0541 --- /dev/null +++ b/lib/Migration/Version1020Date20191002091035.php @@ -0,0 +1,157 @@ +connection = $connection; + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * + * @return ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $messagesTable = $schema->createTable('mail_messages'); + $messagesTable->addColumn('id', 'integer', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + ]); + $messagesTable->addColumn('uid', 'integer', [ + 'notnull' => true, + 'length' => 4, + ]); + $messagesTable->addColumn('message_id', 'string', [ + 'notnull' => false, + 'length' => 255, + ]); + $messagesTable->addColumn('mailbox_id', 'string', [ + 'notnull' => true, + 'length' => 4, + ]); + $messagesTable->addColumn('subject', 'string', [ + 'notnull' => true, + 'length' => 255, + 'default' => '', + ]); + $messagesTable->addColumn('sent_at', 'integer', [ + 'notnull' => true, + 'length' => 4, + ]); + $messagesTable->addColumn('flag_answered', 'boolean', [ + 'notnull' => true, + 'default' => false, + ]); + $messagesTable->addColumn('flag_deleted', 'boolean', [ + 'notnull' => true, + 'default' => false, + ]); + $messagesTable->addColumn('flag_draft', 'boolean', [ + 'notnull' => true, + 'default' => false, + ]); + $messagesTable->addColumn('flag_flagged', 'boolean', [ + 'notnull' => true, + 'default' => false, + ]); + $messagesTable->addColumn('flag_seen', 'boolean', [ + 'notnull' => true, + 'default' => false, + ]); + $messagesTable->addColumn('flag_forwarded', 'boolean', [ + 'notnull' => true, + 'default' => false, + ]); + $messagesTable->addColumn('flag_junk', 'boolean', [ + 'notnull' => true, + 'default' => false, + ]); + $messagesTable->addColumn('flag_notjunk', 'boolean', [ + 'notnull' => true, + 'default' => false, + ]); + $messagesTable->addColumn('updated_at', 'integer', [ + 'notnull' => false, + 'length' => 4, + ]); + $messagesTable->setPrimaryKey(['id']); + // We allow each UID just once + $messagesTable->addUniqueIndex([ + 'uid', + 'mailbox_id', + ]); + $messagesTable->addIndex(['sent_at'], 'mail_message_sent_idx'); + + $recipientsTable = $schema->createTable('mail_recipients'); + $recipientsTable->addColumn('id', 'integer', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + ]); + $recipientsTable->addColumn('message_id', 'integer', [ + 'notnull' => true, + 'length' => 20, + ]); + $recipientsTable->addColumn('type', 'integer', [ + 'notnull' => true, + 'length' => 2, + ]); + $recipientsTable->addColumn('label', 'string', [ + 'notnull' => false, + 'length' => 255, + ]); + $recipientsTable->addColumn('email', 'string', [ + 'notnull' => true, + 'length' => 255, + ]); + $recipientsTable->setPrimaryKey(['id']); + $recipientsTable->addIndex(['message_id'], 'mail_recipient_msg_id_idx'); + $recipientsTable->addIndex(['email'], 'mail_recipient_email_idx'); + + $mailboxTable = $schema->getTable('mail_mailboxes'); + $mailboxTable->addColumn('sync_new_lock', 'integer', [ + 'notnull' => false, + 'length' => 4, + ]); + $mailboxTable->addColumn('sync_changed_lock', 'integer', [ + 'notnull' => false, + 'length' => 4, + ]); + $mailboxTable->addColumn('sync_vanished_lock', 'integer', [ + 'notnull' => false, + 'length' => 4, + ]); + $mailboxTable->addColumn('sync_new_token', 'string', [ + 'notnull' => false, + 'length' => 255, + ]); + $mailboxTable->addColumn('sync_changed_token', 'string', [ + 'notnull' => false, + 'length' => 255, + ]); + $mailboxTable->addColumn('sync_vanished_token', 'string', [ + 'notnull' => false, + 'length' => 255, + ]); + + return $schema; + } + +} diff --git a/lib/Model/IMAPMessage.php b/lib/Model/IMAPMessage.php index 714342d6a2..be30a78ade 100644 --- a/lib/Model/IMAPMessage.php +++ b/lib/Model/IMAPMessage.php @@ -48,6 +48,7 @@ use OCP\Files\File; use OCP\Files\SimpleFS\ISimpleFile; use function base64_encode; +use function json_encode; use function mb_convert_encoding; class IMAPMessage implements IMessage, JsonSerializable { @@ -69,6 +70,7 @@ class IMAPMessage implements IMessage, JsonSerializable { * @param Horde_Imap_Client_Data_Fetch|null $fetch * @param bool $loadHtmlMessage * @param Html|null $htmlService + * * @throws DoesNotExistException */ public function __construct($conn, @@ -145,6 +147,7 @@ public function getFlags(): array { /** * @param array $flags + * * @throws Exception */ public function setFlags(array $flags) { @@ -168,6 +171,7 @@ public function getFrom(): AddressList { /** * @param AddressList $from + * * @throws Exception */ public function setFrom(AddressList $from) { @@ -183,6 +187,7 @@ public function getTo(): AddressList { /** * @param AddressList $to + * * @throws Exception */ public function setTo(AddressList $to) { @@ -198,6 +203,7 @@ public function getCC(): AddressList { /** * @param AddressList $cc + * * @throws Exception */ public function setCC(AddressList $cc) { @@ -213,6 +219,7 @@ public function getBCC(): AddressList { /** * @param AddressList $bcc + * * @throws Exception */ public function setBcc(AddressList $bcc) { @@ -237,6 +244,7 @@ public function getSubject(): string { /** * @param string $subject + * * @throws Exception */ public function setSubject(string $subject) { @@ -259,6 +267,7 @@ public function getSize(): int { /** * @param Horde_Mime_Part $part + * * @return bool */ private function hasAttachments($part) { @@ -319,7 +328,7 @@ private function loadMessageBodies() { } else { if (!is_null($structure->findBody())) { // get the body from the server - $partId = (int) $structure->findBody(); + $partId = (int)$structure->findBody(); $this->getPart($structure->getPart($partId), $partId); } } @@ -328,6 +337,7 @@ private function loadMessageBodies() { /** * @param Horde_Mime_Part $p * @param mixed $partNo + * * @throws DoesNotExistException */ private function getPart(Horde_Mime_Part $p, $partNo) { @@ -427,6 +437,7 @@ public function jsonSerialize(): array { * @param int $accountId * @param string $folderId * @param int $messageId + * * @return string */ public function getHtmlBody(int $accountId, string $folderId, int $messageId): string { @@ -457,6 +468,7 @@ public function getPlainBody(): string { /** * @param Horde_Mime_Part $part * @param mixed $partNo + * * @throws DoesNotExistException */ private function handleMultiPartMessage(Horde_Mime_Part $part, $partNo) { @@ -470,6 +482,7 @@ private function handleMultiPartMessage(Horde_Mime_Part $part, $partNo) { /** * @param Horde_Mime_Part $p * @param mixed $partNo + * * @throws DoesNotExistException */ private function handleTextMessage(Horde_Mime_Part $p, $partNo) { @@ -480,6 +493,7 @@ private function handleTextMessage(Horde_Mime_Part $p, $partNo) { /** * @param Horde_Mime_Part $p * @param mixed $partNo + * * @throws DoesNotExistException */ private function handleHtmlMessage(Horde_Mime_Part $p, $partNo) { @@ -493,6 +507,7 @@ private function handleHtmlMessage(Horde_Mime_Part $p, $partNo) { /** * @param Horde_Mime_Part $p * @param mixed $partNo + * * @return string * @throws DoesNotExistException * @throws Exception @@ -582,4 +597,30 @@ public function setInReplyTo(string $message) { throw new Exception('not implemented'); } + public function toDbMessage(int $mailboxId): \OCA\Mail\Db\Message { + $msg = new \OCA\Mail\Db\Message(); + + $msg->setUid($this->getUid()); + $msg->setMessageId($this->getMessageId()); + $msg->setMailboxId($mailboxId); + $msg->setFrom($this->getFrom()); + $msg->setTo($this->getTo()); + $msg->setCc($this->getCc()); + $msg->setBcc($this->getBcc()); + $msg->setSubject(mb_substr($this->getSubject(), 0, 255)); + $msg->setSentAt($this->getSentDate()->getTimestamp()); + + $flags = $this->fetch->getFlags(); + $msg->setFlagAnswered(in_array(Horde_Imap_Client::FLAG_ANSWERED, $flags, true)); + $msg->setFlagDeleted(in_array(Horde_Imap_Client::FLAG_DELETED, $flags, true)); + $msg->setFlagDraft(in_array(Horde_Imap_Client::FLAG_DRAFT, $flags, true)); + $msg->setFlagFlagged(in_array(Horde_Imap_Client::FLAG_FLAGGED, $flags, true)); + $msg->setFlagSeen(in_array(Horde_Imap_Client::FLAG_SEEN, $flags, true)); + $msg->setFlagForwarded(in_array(Horde_Imap_Client::FLAG_FORWARDED, $flags, true)); + $msg->setFlagJunk(in_array(Horde_Imap_Client::FLAG_JUNK, $flags, true)); + $msg->setFlagNotjunk(in_array(Horde_Imap_Client::FLAG_NOTJUNK, $flags, true)); + + return $msg; + } + } diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index 70472ba9b8..e7b7e9ed67 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -25,10 +25,12 @@ namespace OCA\Mail\Service; use OCA\Mail\Account; +use OCA\Mail\BackgroundJob\SyncJob; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Exception\ServiceException; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\BackgroundJob\IJobList; use function array_map; class AccountService { @@ -46,10 +48,15 @@ class AccountService { /** @var AliasesService */ private $aliasesService; + /** @var IJobList */ + private $jobList; + public function __construct(MailAccountMapper $mapper, - AliasesService $aliasesService) { + AliasesService $aliasesService, + IJobList $jobList) { $this->mapper = $mapper; $this->aliasesService = $aliasesService; + $this->jobList = $jobList; } /** @@ -66,6 +73,14 @@ public function findByUserId(string $currentUserId): array { return $this->accounts; } + /** + * @param string $id + * @return Account + */ + public function findById(int $id): Account { + return new Account($this->mapper->findById($id)); + } + /** * @param string $uid * @param int $accountId @@ -86,16 +101,6 @@ public function find(string $uid, int $accountId): Account { return new Account($this->mapper->find($uid, $accountId)); } - /** - * @param int $id - * - * @return Account - * @throws DoesNotExistException - */ - public function findById(int $id): Account { - return new Account($this->mapper->findById($id)); - } - /** * @param int $accountId */ @@ -110,7 +115,12 @@ public function delete(string $currentUserId, int $accountId): void { * @return MailAccount */ public function save(MailAccount $newAccount): MailAccount { - return $this->mapper->save($newAccount); + $newAccount = $this->mapper->save($newAccount); + + // Insert a background sync job for this account + $this->jobList->add(SyncJob::class, ['accountId' => $newAccount->getId()]); + + return $newAccount; } public function updateSignature(int $id, string $uid, string $signature = null): void { diff --git a/lib/Service/AutoCompletion/AddressCollector.php b/lib/Service/AutoCompletion/AddressCollector.php index 53131e74b9..4f22080e4b 100644 --- a/lib/Service/AutoCompletion/AddressCollector.php +++ b/lib/Service/AutoCompletion/AddressCollector.php @@ -76,7 +76,7 @@ private function saveAddress(Address $address) { $this->logger->debug("<$address> is not a valid RFC822 mail address"); return; } - if (!$this->mapper->exists($this->userId, $address->getEmail())) { + if ($address->getEmail() !== null && !$this->mapper->exists($this->userId, $address->getEmail())) { $this->logger->debug("saving new address <{$address->getEmail()}>"); $entity = new CollectedAddress(); diff --git a/lib/Service/MailManager.php b/lib/Service/MailManager.php index 9ccc6866dc..4c5359dc2d 100644 --- a/lib/Service/MailManager.php +++ b/lib/Service/MailManager.php @@ -24,13 +24,11 @@ namespace OCA\Mail\Service; use Horde_Imap_Client_Exception; -use Horde_Imap_Client_Exception_Sync; use OCA\Mail\Account; use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Events\BeforeMessageDeletedEvent; -use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Folder; use OCA\Mail\IMAP\FolderMapper; @@ -38,9 +36,6 @@ use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\IMAP\MailboxSync; use OCA\Mail\IMAP\MessageMapper; -use OCA\Mail\IMAP\Sync\Request; -use OCA\Mail\IMAP\Sync\Response; -use OCA\Mail\IMAP\Sync\Synchronizer; use OCA\Mail\Model\IMAPMessage; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; @@ -59,9 +54,6 @@ class MailManager implements IMailManager { /** @var FolderMapper */ private $folderMapper; - /** @var Synchronizer */ - private $synchronizer; - /** @var MessageMapper */ private $messageMapper; @@ -72,14 +64,12 @@ public function __construct(IMAPClientFactory $imapClientFactory, MailboxMapper $mailboxMapper, MailboxSync $mailboxSync, FolderMapper $folderMapper, - Synchronizer $synchronizer, MessageMapper $messageMapper, IEventDispatcher $eventDispatcher) { $this->imapClientFactory = $imapClientFactory; $this->mailboxMapper = $mailboxMapper; $this->mailboxSync = $mailboxSync; $this->folderMapper = $folderMapper; - $this->synchronizer = $synchronizer; $this->messageMapper = $messageMapper; $this->eventDispatcher = $eventDispatcher; } @@ -100,27 +90,6 @@ function (Mailbox $mb) { ); } - /** - * @param Account $account - * @param Request $syncRequest - * - * @return Response - * - * @throws ClientException - * @throws ServiceException - */ - public function syncMessages(Account $account, Request $syncRequest): Response { - $client = $this->imapClientFactory->getClient($account); - - try { - return $this->synchronizer->sync($client, $syncRequest); - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException("Could not sync messages", 0, $e); - } catch (Horde_Imap_Client_Exception_Sync $e) { - throw new ClientException("Sync failed because of an invalid sync token or UID validity changed", 0, $e); - } - } - /** * @param Account $account * @param string $name diff --git a/lib/Service/MailSearch.php b/lib/Service/MailSearch.php deleted file mode 100644 index a2729705b6..0000000000 --- a/lib/Service/MailSearch.php +++ /dev/null @@ -1,139 +0,0 @@ - - * - * @author 2019 Christoph Wurst - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -namespace OCA\Mail\Service; - - -use DateTime; -use Horde_Imap_Client; -use Horde_Imap_Client_Exception; -use Horde_Imap_Client_Exception_NoSupportExtension; -use Horde_Imap_Client_Fetch_Query; -use Horde_Imap_Client_Ids; -use Horde_Imap_Client_Search_Query; -use Horde_Imap_Client_Socket; -use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailSearch; -use OCA\Mail\Db\MailboxMapper; -use OCA\Mail\Exception\ServiceException; -use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\IMAP\Search\SearchStrategyFactory; -use OCA\Mail\Model\IMAPMessage; -use OCA\Mail\IMAP\Search\SearchFilterStringParser; -use OCP\AppFramework\Db\DoesNotExistException; -use OCP\ILogger; -use function array_keys; -use function array_reverse; -use function in_array; -use function uasort; - -class MailSearch implements IMailSearch { - - /** @var IMAPClientFactory */ - private $clientFactory; - - /** @var SearchStrategyFactory */ - private $searchStrategyFactory; - - /** @var SearchFilterStringParser */ - private $filterStringParser; - - /** @var MailboxMapper */ - private $mailboxMapper; - - /** @var ILogger */ - private $logger; - - public function __construct(IMAPClientFactory $clientFactory, - SearchStrategyFactory $searchStrategyFactory, - SearchFilterStringParser $filterStringParser, - MailboxMapper $mailboxMapper, - ILogger $logger) { - $this->clientFactory = $clientFactory; - $this->searchStrategyFactory = $searchStrategyFactory; - $this->filterStringParser = $filterStringParser; - $this->mailboxMapper = $mailboxMapper; - $this->logger = $logger; - } - - /** - * @param Account $account - * @param string $mailboxName - * @param string|null $filter - * @param string|null $cursor - * - * @return IMAPMessage[] - * @throws ServiceException - */ - public function findMessages(Account $account, string $mailboxName, ?string $filter, ?int $cursor): array { - $client = $this->clientFactory->getClient($account); - try { - $mailbox = $this->mailboxMapper->find($account, $mailboxName); - } catch (DoesNotExistException $e) { - throw new ServiceException('Mailbox does not exist', 0, $e); - } - - try { - $query = $this->filterStringParser->parse($filter); - - // In flagged we don't want anything but flagged messages - if ($mailbox->isSpecialUse(Horde_Imap_Client::SPECIALUSE_FLAGGED)) { - $query->flag(Horde_Imap_Client::FLAG_FLAGGED); - } - - // Don't show deleted messages unless for folders - if (!$mailbox->isSpecialUse(Horde_Imap_Client::SPECIALUSE_TRASH)) { - $query->flag(Horde_Imap_Client::FLAG_DELETED, false); - } - - $ids = $this->searchStrategyFactory - ->getStrategy($client, $mailbox->getMailbox(), $query, $cursor) - ->getIds(20); - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException('Could not get message IDs: ' . $e->getMessage(), 0, $e); - } - - try { - $fetchQuery = new Horde_Imap_Client_Fetch_Query(); - $fetchQuery->envelope(); - $fetchQuery->flags(); - $fetchQuery->size(); - $fetchQuery->uid(); - $fetchQuery->imapDate(); - $fetchQuery->structure(); - - $fetchResult = $client->fetch($mailbox->getMailbox(), $fetchQuery, ['ids' => $ids]); - } catch (Horde_Imap_Client_Exception $e) { - throw new ServiceException('Could not fetch messages', 0, $e); - } - - // TODO: do we still need this fix? - ob_start(); // fix for Horde warnings - $messages = array_map(function (int $messageId) use ($mailbox, $client, $fetchResult) { - $header = $fetchResult[$messageId]; - return new IMAPMessage($client, $mailbox->getMailbox(), $messageId, $header); - }, $fetchResult->ids()); - ob_get_clean(); - - return $messages; - } -} diff --git a/lib/IMAP/Search/SearchFilterStringParser.php b/lib/Service/Search/FilterStringParser.php similarity index 74% rename from lib/IMAP/Search/SearchFilterStringParser.php rename to lib/Service/Search/FilterStringParser.php index 665700a83a..d3779f5e83 100644 --- a/lib/IMAP/Search/SearchFilterStringParser.php +++ b/lib/Service/Search/FilterStringParser.php @@ -21,11 +21,9 @@ * along with this program. If not, see . */ -namespace OCA\Mail\IMAP\Search; +namespace OCA\Mail\Service\Search; -use Horde_Imap_Client_Search_Query; - -class SearchFilterStringParser { +class FilterStringParser { private const FLAG_MAP = [ 'read' => ['SEEN', true], @@ -33,26 +31,22 @@ class SearchFilterStringParser { 'answered' => ['ANSWERED', true], ]; - public function parse(?string $filter): Horde_Imap_Client_Search_Query { - $query = new Horde_Imap_Client_Search_Query(); + public function parse(?string $filter): SearchQuery { + $query = new SearchQuery(); if (empty($filter)) { return $query; } $tokens = explode(' ', $filter); - $textTokens = []; foreach ($tokens as $token) { if (!$this->parseFilterToken($query, $token)) { - $textTokens[] = $token; + $query->addTextToken($token); } } - if (count($textTokens)) { - $query->text(implode(' ', $textTokens), false); - } return $query; } - private function parseFilterToken(Horde_Imap_Client_Search_Query $query, $token): bool { + private function parseFilterToken(SearchQuery $query, $token): bool { if (strpos($token, ':') === false) { return false; } @@ -65,16 +59,24 @@ private function parseFilterToken(Horde_Imap_Client_Search_Query $query, $token) case 'not': if (array_key_exists($param, self::FLAG_MAP)) { $flag = self::FLAG_MAP[$param]; - $query->flag($flag[0], $type === 'is' ? $flag[1] : !$flag[1]); + $query->addFlag($flag[0], $type === 'is' ? $flag[1] : !$flag[1]); return true; } break; case 'from': + $query->addFrom($param); + return true; case 'to': + $query->addTo($param); + return true; case 'cc': + $query->addCc($param); + return true; case 'bcc': + $query->addBcc($param); + return true; case 'subject': - $query->headerText($type, $param); + $query->setSubject($param); return true; } diff --git a/lib/Service/Search/MailSearch.php b/lib/Service/Search/MailSearch.php new file mode 100644 index 0000000000..1927af9aa5 --- /dev/null +++ b/lib/Service/Search/MailSearch.php @@ -0,0 +1,130 @@ + + * + * @author 2019 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Service\Search; + +use Horde_Imap_Client; +use OCA\Mail\Account; +use OCA\Mail\Contracts\IMailSearch; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\Db\Message; +use OCA\Mail\Db\MessageMapper; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\IMAP\Search\Provider as ImapSearchProvider; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\ILogger; + +class MailSearch implements IMailSearch { + + /** @var FilterStringParser */ + private $filterStringParser; + + /** @var MailboxMapper */ + private $mailboxMapper; + + /** @var ImapSearchProvider */ + private $imapSearchProvider; + + /** @var MessageMapper */ + private $messageMapper; + + /** @var ILogger */ + private $logger; + + public function __construct(FilterStringParser $filterStringParser, + MailboxMapper $mailboxMapper, + ImapSearchProvider $imapSearchProvider, + MessageMapper $messageMapper, + ILogger $logger) { + $this->filterStringParser = $filterStringParser; + $this->mailboxMapper = $mailboxMapper; + $this->imapSearchProvider = $imapSearchProvider; + $this->messageMapper = $messageMapper; + $this->logger = $logger; + } + + /** + * @param Account $account + * @param string $mailboxName + * @param string|null $filter + * @param string|null $cursor + * + * @return Message[] + * @throws ServiceException + */ + public function findMessages(Account $account, + string $mailboxName, + ?string $filter, + ?int $cursor): array { + try { + $mailbox = $this->mailboxMapper->find($account, $mailboxName); + } catch (DoesNotExistException $e) { + throw new ServiceException('Mailbox does not exist', 0, $e); + } + + $query = $this->filterStringParser->parse($filter); + if ($cursor !== null) { + $query->setCursor($cursor); + } + // In flagged we don't want anything but flagged messages + if ($mailbox->isSpecialUse(Horde_Imap_Client::SPECIALUSE_FLAGGED)) { + $query->addFlag(Horde_Imap_Client::FLAG_FLAGGED); + } + // Don't show deleted messages except for trash folders + if (!$mailbox->isSpecialUse(Horde_Imap_Client::SPECIALUSE_TRASH)) { + $query->addFlag(Horde_Imap_Client::FLAG_DELETED, false); + } + + $uids = array_merge( + $this->getDbUids($mailbox, $query), + $this->getImapUids($account, $mailbox, $query) + ); + + return $this->messageMapper->findByUids($mailbox, $uids); + } + + private function getDbUids(Mailbox $mailbox, SearchQuery $query) { + return $this->messageMapper->findUidsByQuery($mailbox, $query); + } + + /** + * @param Account $account + * @param SearchQuery $query + * @param Mailbox $mailbox + * + * @throws ServiceException + */ + private function getImapUids(Account $account, Mailbox $mailbox, SearchQuery $query): array { + if (empty($query->getTextTokens())) { + return []; + } + + return $this->imapSearchProvider->findMatches( + $account, + $mailbox, + $query + ); + } + +} diff --git a/lib/Service/Search/SearchQuery.php b/lib/Service/Search/SearchQuery.php new file mode 100644 index 0000000000..560420d902 --- /dev/null +++ b/lib/Service/Search/SearchQuery.php @@ -0,0 +1,143 @@ + + * + * @author 2019 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Service\Search; + +class SearchQuery { + + /** @var int|null */ + private $cursor; + + /** @var bool[] */ + private $flags = []; + + /** @var string[] */ + private $to = []; + + /** @var string[] */ + private $from = []; + + /** @var string[] */ + private $cc = []; + + /** @var string[] */ + private $bcc = []; + + /** @var string|null */ + private $subject; + + /** @var string[] */ + private $textTokens = []; + + /** + * @return int|null + */ + public function getCursor(): ?int { + return $this->cursor; + } + + /** + * @param int $cursor + */ + public function setCursor(int $cursor): void { + $this->cursor = $cursor; + } + + /** + * @return bool[] + */ + public function getFlags(): array { + return $this->flags; + } + + public function addFlag(string $flag, bool $value = true): void { + $this->flags[$flag] = $value; + } + + /** + * @return string[] + */ + public function getTo(): array { + return $this->to; + } + + public function addTo(string $to): void { + $this->to[] = $to; + } + + /** + * @return string[] + */ + public function getFrom(): array { + return $this->from; + } + + public function addFrom(string $from): void { + $this->from[] = $from; + } + + /** + * @return string[] + */ + public function getCc(): array { + return $this->cc; + } + + public function addCc(string $cc): void { + $this->cc[] = $cc; + } + + /** + * @return string[] + */ + public function getBcc(): array { + return $this->bcc; + } + + public function addBcc(string $bcc): void { + $this->bcc[] = $bcc; + } + + /** + * @return string|null + */ + public function getSubject(): ?string { + return $this->subject; + } + + public function setSubject(?string $subject): void { + $this->subject = $subject; + } + + /** + * @return string[] + */ + public function getTextTokens(): array { + return $this->textTokens; + } + + public function addTextToken(string $textToken): void { + $this->textTokens[] = $textToken; + } + +} diff --git a/lib/Service/SyncService.php b/lib/Service/SyncService.php new file mode 100644 index 0000000000..6a084a5752 --- /dev/null +++ b/lib/Service/SyncService.php @@ -0,0 +1,393 @@ + + * + * @author 2019 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Service; + +use Horde_Imap_Client; +use Horde_Imap_Client_Exception; +use Horde_Imap_Client_Exception_Sync; +use OCA\Mail\Account; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\Db\Message; +use OCA\Mail\Db\MessageMapper; +use OCA\Mail\Db\MessageMapper as DatabaseMessageMapper; +use OCA\Mail\Exception\ConcurrentSyncException; +use OCA\Mail\Exception\MailboxNotCachedException; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; +use OCA\Mail\IMAP\Sync\Request; +use OCA\Mail\IMAP\Sync\Response; +use OCA\Mail\IMAP\Sync\Synchronizer; +use OCA\mail\lib\Exception\UidValidityChangedException; +use OCA\Mail\Model\IMAPMessage; +use OCA\Mail\Support\PerformanceLogger; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\ILogger; +use Throwable; +use function array_chunk; +use function array_map; + +class SyncService { + + /** @var DatabaseMessageMapper */ + private $dbMapper; + + /** @var IMAPClientFactory */ + private $clientFactory; + + /** @var ImapMessageMapper */ + private $imapMapper; + + /** @var MailboxMapper */ + private $mailboxMapper; + + /** @var DatabaseMessageMapper */ + private $messageMapper; + + /** @var Synchronizer */ + private $synchronizer; + + /** @var PerformanceLogger */ + private $performanceLogger; + + /** @var ILogger */ + private $logger; + + public function __construct(DatabaseMessageMapper $dbMapper, + IMAPClientFactory $clientFactory, + ImapMessageMapper $imapMapper, + MailboxMapper $mailboxMapper, + MessageMapper $messageMapper, + Synchronizer $synchronizer, + PerformanceLogger $performanceLogger, + ILogger $logger) { + $this->dbMapper = $dbMapper; + $this->clientFactory = $clientFactory; + $this->imapMapper = $imapMapper; + $this->mailboxMapper = $mailboxMapper; + $this->messageMapper = $messageMapper; + $this->synchronizer = $synchronizer; + $this->performanceLogger = $performanceLogger; + $this->logger = $logger; + } + + /** + * @throws ServiceException + */ + public function syncAccount(Account $account, + bool $force = false, + int $criteria = Horde_Imap_Client::SYNC_NEWMSGSUIDS | Horde_Imap_Client::SYNC_FLAGSUIDS | Horde_Imap_Client::SYNC_VANISHEDUIDS): void { + foreach ($this->mailboxMapper->findAll($account) as $mailbox) { + $this->sync( + $account, + $mailbox, + $criteria, + null, + $force + ); + } + } + + /** + * @param int[] $knownUids + * + * @throws ServiceException + */ + public function syncMailbox(Account $account, + string $mailbox, + int $criteria = Horde_Imap_Client::SYNC_NEWMSGSUIDS | Horde_Imap_Client::SYNC_FLAGSUIDS | Horde_Imap_Client::SYNC_VANISHEDUIDS, + array $knownUids = null, + bool $partialOnly = true): Response { + try { + $mb = $this->mailboxMapper->find($account, $mailbox); + + if ($partialOnly && $mb->getSyncNewToken() === null) { + throw new MailboxNotCachedException(); + } + + return $this->sync( + $account, + $mb, + $criteria, + $knownUids + ); + } catch (DoesNotExistException $e) { + throw new ServiceException('Mailbox to sync does not exist in the database', 0, $e); + } + } + + /** + * @throws ServiceException + */ + public function ensurePopulated(Account $account, string $mailbox): void { + try { + $mb = $this->mailboxMapper->find($account, $mailbox); + } catch (DoesNotExistException $e) { + throw new ServiceException('Mailbox does not exist', 0, $e); + } + + if ($mb->getSyncNewToken() !== null) { + return; + } + + try { + $this->mailboxMapper->lockForNewSync($mb); + $this->mailboxMapper->lockForChangeSync($mb); + $this->mailboxMapper->lockForVanishedSync($mb); + + $this->runInitialSync($account, $mb); + } catch (ConcurrentSyncException $e) { + // Fine, then we don't have to do it + } finally { + $this->mailboxMapper->unlockFromNewSync($mb); + $this->mailboxMapper->unlockFromChangedSync($mb); + $this->mailboxMapper->unlockFromVanishedSync($mb); + } + } + + /** + * @param int[] $knownUids + * + * @throws ServiceException + */ + private function sync(Account $account, + Mailbox $mailbox, + int $criteria, + array $knownUids = null, + bool $force = false): Response { + if ($mailbox->getSelectable() === false) { + return new Response(); + } + + try { + if ($criteria & Horde_Imap_Client::SYNC_NEWMSGSUIDS) { + $this->mailboxMapper->lockForNewSync($mailbox); + } + if ($criteria & Horde_Imap_Client::SYNC_FLAGSUIDS) { + $this->mailboxMapper->lockForChangeSync($mailbox); + } + if ($criteria & Horde_Imap_Client::SYNC_VANISHEDUIDS) { + $this->mailboxMapper->lockForVanishedSync($mailbox); + } + } catch (ConcurrentSyncException $e) { + throw new ServiceException('Another sync is in progress for ' . $mailbox->getId(), 0, $e); + } + + try { + if ($force + || $mailbox->getSyncNewToken() === null + || $mailbox->getSyncChangedToken() === null + || $mailbox->getSyncVanishedToken() === null) { + $response = $this->runInitialSync($account, $mailbox); + } else { + $response = $this->runPartialSync($account, $mailbox, $criteria, $knownUids); + } + } catch (Throwable $e) { + throw new ServiceException('Sync failed', 0, $e); + } finally { + if ($criteria & Horde_Imap_Client::SYNC_VANISHEDUIDS) { + $this->mailboxMapper->unlockFromVanishedSync($mailbox); + } + if ($criteria & Horde_Imap_Client::SYNC_FLAGSUIDS) { + $this->mailboxMapper->unlockFromChangedSync($mailbox); + } + if ($criteria & Horde_Imap_Client::SYNC_NEWMSGSUIDS) { + $this->mailboxMapper->unlockFromNewSync($mailbox); + } + } + + return $response; + } + + /** + * @throws ServiceException + */ + private function runInitialSync(Account $account, Mailbox $mailbox): Response { + $perf = $this->performanceLogger->start('Initial sync ' . $account->getId() . ':' . $mailbox->getName()); + + $client = $this->clientFactory->getClient($account); + try { + $imapMessages = $this->imapMapper->findAll($client, $mailbox); + $perf->step('fetch all messages from IMAP'); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException('Can not get messages from mailbox ' . $mailbox->getName() . ': ' . $e->getMessage(), 0, $e); + } + + // The sync token could be reset by a migration, hence there could be existing data + $this->dbMapper->deleteAll($mailbox); + $perf->step('delete existing messages'); + + foreach (array_chunk($imapMessages, 500) as $chunk) { + $this->dbMapper->insertBulk(...array_map(function (IMAPMessage $imapMessage) use ($mailbox) { + return $imapMessage->toDbMessage($mailbox->getId()); + }, $chunk)); + } + $perf->step('persist messages in database'); + + $mailbox->setSyncNewToken($client->getSyncToken($mailbox->getMailbox())); + $mailbox->setSyncChangedToken($client->getSyncToken($mailbox->getMailbox())); + $mailbox->setSyncVanishedToken($client->getSyncToken($mailbox->getMailbox())); + $this->mailboxMapper->update($mailbox); + + $perf->end(); + + // Not returning *all* new messages here as this could exhaust the memory + return new Response(); + } + + /** + * @param int[] $knownUids + * + * @throws ServiceException + */ + private function runPartialSync(Account $account, + Mailbox $mailbox, + int $criteria, + array $knownUids = null): Response { + $perf = $this->performanceLogger->start('partial sync ' . $account->getId() . ':' . $mailbox->getName()); + + $client = $this->clientFactory->getClient($account); + $uids = $knownUids ?? $this->dbMapper->findAllUids($mailbox); + $perf->step('get all known UIDs'); + + $response = new Response(); + if ($criteria & Horde_Imap_Client::SYNC_NEWMSGSUIDS) { + try { + $response = $response->merge($this->synchronizer->sync( + $client, + new Request( + $mailbox->getMailbox(), + $mailbox->getSyncNewToken(), + $uids + ), + Horde_Imap_Client::SYNC_NEWMSGSUIDS + )); + } catch (UidValidityChangedException $e) { + $this->logger->warning('Mailbox UID validity changed. Performing full sync.'); + + return $this->runInitialSync($account, $mailbox); + } + $perf->step('get new messages via Horde'); + + foreach (array_chunk($response->getNewMessages(), 500) as $chunk) { + $this->dbMapper->insertBulk(...array_map(function (IMAPMessage $imapMessage) use ($mailbox) { + return $imapMessage->toDbMessage($mailbox->getId()); + }, $chunk)); + } + $perf->step('persist new messages'); + + $mailbox->setSyncNewToken($client->getSyncToken($mailbox->getMailbox())); + } + if ($criteria & Horde_Imap_Client::SYNC_FLAGSUIDS) { + try { + $response = $response->merge($this->synchronizer->sync( + $client, + new Request( + $mailbox->getMailbox(), + $mailbox->getSyncChangedToken(), + $uids + ), + Horde_Imap_Client::SYNC_FLAGSUIDS + )); + } catch (UidValidityChangedException $e) { + $this->logger->warning('Mailbox UID validity changed. Performing full sync.'); + + return $this->runInitialSync($account, $mailbox); + } + $perf->step('get changed messages via Horde'); + + foreach (array_chunk($response->getChangedMessages(), 500) as $chunk) { + $this->dbMapper->updateBulk(...array_map(function (IMAPMessage $imapMessage) use ($mailbox) { + return $imapMessage->toDbMessage($mailbox->getId()); + }, $chunk)); + } + $perf->step('persist changed messages'); + + // If a list of UIDs was *provided* (as opposed to loaded from the DB, + // we can not assume that all changes were detected, hence this is kinda + // a silent sync and we don't update the change token until the next full + // mailbox sync + if ($knownUids === null) { + $mailbox->setSyncChangedToken($client->getSyncToken($mailbox->getMailbox())); + } + } + if ($criteria & Horde_Imap_Client::SYNC_VANISHEDUIDS) { + try { + $response = $response->merge($this->synchronizer->sync( + $client, + new Request( + $mailbox->getMailbox(), + $mailbox->getSyncVanishedToken(), + $uids + ), + Horde_Imap_Client::SYNC_VANISHEDUIDS + )); + } catch (UidValidityChangedException $e) { + $this->logger->warning('Mailbox UID validity changed. Performing full sync.'); + + return $this->runInitialSync($account, $mailbox); + } + $perf->step('get vanished messages via Horde'); + + foreach (array_chunk($response->getVanishedMessageUids(), 500) as $chunk) { + $this->dbMapper->deleteByUid($mailbox, ...$chunk); + } + $perf->step('persist new messages'); + + $mailbox->setSyncVanishedToken($client->getSyncToken($mailbox->getMailbox())); + } + $this->mailboxMapper->update($mailbox); + + $response = $response->merge( + $this->getDatabaseSyncChanges($mailbox, $uids) + ); + + $perf->end(); + + return $response; + } + + private function getDatabaseSyncChanges(Mailbox $mailbox, array $uids): Response { + if (empty($uids)) { + return new Response(); + } + + sort($uids, SORT_NUMERIC); + $last = end($uids); + + $new = $this->messageMapper->findNew($mailbox, $last); + // TODO: $changed = $this->messageMapper->findChanged($mailbox, $uids); + $changed = $this->messageMapper->findByUids($mailbox, $uids); + $old = array_map(function (Message $msg) { + return $msg->getUid(); + }, $this->messageMapper->findByUids($mailbox, $uids)); + $vanished = array_filter($uids, function (int $uid) use ($old) { + return !in_array($uid, $old, true); + }); + + return new Response($new, $changed, $vanished); + } + +} diff --git a/lib/IMAP/Search/SearchStrategyFactory.php b/lib/Support/PerformanceLogger.php similarity index 60% rename from lib/IMAP/Search/SearchStrategyFactory.php rename to lib/Support/PerformanceLogger.php index 0ad13f8f35..7043fbb36e 100644 --- a/lib/IMAP/Search/SearchStrategyFactory.php +++ b/lib/Support/PerformanceLogger.php @@ -21,27 +21,30 @@ * along with this program. If not, see . */ -namespace OCA\Mail\IMAP\Search; - -use Horde_Imap_Client_Search_Query; -use Horde_Imap_Client_Socket; - -class SearchStrategyFactory { - - public function getStrategy(Horde_Imap_Client_Socket $client, - string $mailbox, - Horde_Imap_Client_Search_Query $query, - ?int $cursor): ISearchStrategy { - if (!$client->capability->query('SORT') && 'ALL' === $query->__toString()) { - return new FullScanSearchStrategy($client, $mailbox, $cursor); - } - - return new ImapSortSearchStrategy( - $client, - $mailbox, - $query, - $cursor, - new FullScanSearchStrategy($client, $mailbox, $cursor) +namespace OCA\Mail\Support; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\ILogger; + +class PerformanceLogger { + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var ILogger */ + private $logger; + + public function __construct(ITimeFactory $timeFactory, + ILogger $logger) { + $this->timeFactory = $timeFactory; + $this->logger = $logger; + } + + public function start(string $task): PerformanceLoggerTask { + return new PerformanceLoggerTask( + $task, + $this->timeFactory, + $this->logger ); } diff --git a/lib/Support/PerformanceLoggerTask.php b/lib/Support/PerformanceLoggerTask.php new file mode 100644 index 0000000000..1452f2754f --- /dev/null +++ b/lib/Support/PerformanceLoggerTask.php @@ -0,0 +1,72 @@ + + * + * @author 2019 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Support; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\ILogger; + +class PerformanceLoggerTask { + + /** @var string */ + private $task; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var ILogger */ + private $logger; + + /** @var int */ + private $start; + + /** @var int */ + private $rel; + + public function __construct(string $task, + ITimeFactory $timeFactory, + ILogger $logger) { + $this->task = $task; + $this->timeFactory = $timeFactory; + $this->logger = $logger; + + $this->start = $this->rel = $timeFactory->getTime(); + } + + public function step(string $description): void { + $now = $this->timeFactory->getTime(); + $passed = $now - $this->rel; + + $this->logger->debug($this->task . " - $description took ${passed}s"); + + $this->rel = $now; + } + + public function end(): void { + $now = $this->timeFactory->getTime(); + $passed = $now - $this->start; + + $this->logger->debug($this->task . " took ${passed}s"); + } + +} diff --git a/src/components/FolderContent.vue b/src/components/FolderContent.vue index 0237d26a96..78a5abdb30 100644 --- a/src/components/FolderContent.vue +++ b/src/components/FolderContent.vue @@ -2,7 +2,11 @@
- + @@ -12,10 +15,38 @@ export default { props: { hint: { type: String, - default: () => undefined, }, + slowHint: { + type: String, + required: false, + }, + }, + data() { + return { + slow: false, + slowTimer: undefined, + } + }, + mounted() { + clearTimeout(this.slowTimer) + + this.slowTimer = setTimeout(() => { + this.slow = true + }, 3500) + }, + beforeDestroy() { + clearTimeout(this.slowTimer) }, } - + diff --git a/src/service/MessageService.js b/src/service/MessageService.js index 1897ad1769..cf1b5308f1 100644 --- a/src/service/MessageService.js +++ b/src/service/MessageService.js @@ -22,7 +22,7 @@ export function fetchEnvelopes(accountId, folderId, query, cursor) { }).then(resp => resp.data) } -export function syncEnvelopes(accountId, folderId, syncToken, uids) { +export function syncEnvelopes(accountId, folderId, uids) { const url = generateUrl('/apps/mail/api/accounts/{accountId}/folders/{folderId}/sync', { accountId, folderId, @@ -30,7 +30,6 @@ export function syncEnvelopes(accountId, folderId, syncToken, uids) { return HttpClient.get(url, { params: { - syncToken, uids, }, }).then(resp => resp.data) diff --git a/src/store/actions.js b/src/store/actions.js index 9a2008e7d7..ca5e4a3219 100644 --- a/src/store/actions.js +++ b/src/store/actions.js @@ -357,10 +357,9 @@ export default { ) } - const syncToken = folder.syncToken const uids = getters.getEnvelopes(accountId, folderId).map(env => env.id) - return syncEnvelopes(accountId, folderId, syncToken, uids).then(syncData => { + return syncEnvelopes(accountId, folderId, uids).then(syncData => { const unifiedFolder = getters.getUnifiedFolder(folder.specialRole) syncData.newMessages.forEach(envelope => { @@ -391,10 +390,6 @@ export default { }) // Already removed from unified inbox }) - commit('updateFolderSyncToken', { - folder, - syncToken: syncData.token, - }) return syncData.newMessages }) diff --git a/src/store/mutations.js b/src/store/mutations.js index 28ee0f2b10..ad2e7a7383 100644 --- a/src/store/mutations.js +++ b/src/store/mutations.js @@ -104,9 +104,6 @@ export default { account.folders.push(id) }) }, - updateFolderSyncToken(state, {folder, syncToken}) { - folder.syncToken = syncToken - }, addEnvelope(state, {accountId, folder, envelope}) { const uid = accountId + '-' + folder.id + '-' + envelope.id envelope.accountId = accountId diff --git a/tests/Integration/Db/MailboxMapperTest.php b/tests/Integration/Db/MailboxMapperTest.php index a1107145cd..0200b0f556 100644 --- a/tests/Integration/Db/MailboxMapperTest.php +++ b/tests/Integration/Db/MailboxMapperTest.php @@ -28,6 +28,7 @@ use OCA\Mail\Account; use OCA\Mail\Db\MailboxMapper; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use PHPUnit\Framework\MockObject\MockObject; @@ -42,12 +43,17 @@ class MailboxMapperTest extends TestCase { /** @var MailboxMapper */ private $mapper; + /** @var ITimeFactory| MockObject */ + private $timeFactory; + protected function setUp(): void { parent::setUp(); $this->db = \OC::$server->getDatabaseConnection(); + $this->timeFactory = $this->createMock(ITimeFactory::class); $this->mapper = new MailboxMapper( - $this->db + $this->db, + $this->timeFactory ); $qb = $this->db->getQueryBuilder(); @@ -74,7 +80,9 @@ public function testFindAll() { ->values([ 'name' => $qb->createNamedParameter("folder$i"), 'account_id' => $qb->createNamedParameter($i <= 5 ? 13 : 14, IQueryBuilder::PARAM_INT), - 'sync_token' => $qb->createNamedParameter('VTEsVjE0Mjg1OTkxNDk='), + 'sync_new_token' => $qb->createNamedParameter('VTEsVjE0Mjg1OTkxNDk='), + 'sync_changed_token' => $qb->createNamedParameter('VTEsVjE0Mjg1OTkxNDk='), + 'sync_vanished_token' => $qb->createNamedParameter('VTEsVjE0Mjg1OTkxNDk='), 'delimiter' => $qb->createNamedParameter('.'), 'messages' => $qb->createNamedParameter($i * 100, IQueryBuilder::PARAM_INT), 'unseen' => $qb->createNamedParameter($i, IQueryBuilder::PARAM_INT), @@ -106,7 +114,9 @@ public function testFindInbox() { ->values([ 'name' => $qb->createNamedParameter('INBOX'), 'account_id' => $qb->createNamedParameter(13, IQueryBuilder::PARAM_INT), - 'sync_token' => $qb->createNamedParameter('VTEsVjE0Mjg1OTkxNDk='), + 'sync_new_token' => $qb->createNamedParameter('VTEsVjE0Mjg1OTkxNDk='), + 'sync_changed_token' => $qb->createNamedParameter('VTEsVjE0Mjg1OTkxNDk='), + 'sync_vanished_token' => $qb->createNamedParameter('VTEsVjE0Mjg1OTkxNDk='), 'delimiter' => $qb->createNamedParameter('.'), 'messages' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT), 'unseen' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT), @@ -137,7 +147,9 @@ public function testFindTrash() { ->values([ 'name' => $qb->createNamedParameter('Trash'), 'account_id' => $qb->createNamedParameter(13, IQueryBuilder::PARAM_INT), - 'sync_token' => $qb->createNamedParameter('VTEsVjE0Mjg1OTkxNDk='), + 'sync_new_token' => $qb->createNamedParameter('VTEsVjE0Mjg1OTkxNDk='), + 'sync_changed_token' => $qb->createNamedParameter('VTEsVjE0Mjg1OTkxNDk='), + 'sync_vanished_token' => $qb->createNamedParameter('VTEsVjE0Mjg1OTkxNDk='), 'delimiter' => $qb->createNamedParameter('.'), 'messages' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT), 'unseen' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT), diff --git a/tests/Integration/FolderSynchronizationTest.php b/tests/Integration/FolderSynchronizationTest.php index 841a105086..c9c28bfdc7 100644 --- a/tests/Integration/FolderSynchronizationTest.php +++ b/tests/Integration/FolderSynchronizationTest.php @@ -22,16 +22,18 @@ namespace OCA\Mail\Tests\Integration; use OC; +use OCA\Mail\Account; use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Controller\FoldersController; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\SyncService; use OCA\Mail\Tests\Integration\Framework\ImapTest; use OCA\Mail\Tests\Integration\Framework\ImapTestAccount; class FolderSynchronizationTest extends TestCase { use ImapTest, - ImapTestAccount; + ImapTestAccount; /** @var FoldersController */ private $foldersController; @@ -39,31 +41,45 @@ class FolderSynchronizationTest extends TestCase { protected function setUp(): void { parent::setUp(); - $this->foldersController = new FoldersController('mail', OC::$server->getRequest(), OC::$server->query(AccountService::class), $this->getTestAccountUserId(), OC::$server->query(IMailManager::class)); + $this->foldersController = new FoldersController( + 'mail', + OC::$server->getRequest(), + OC::$server->query(AccountService::class), + $this->getTestAccountUserId(), + OC::$server->query(IMailManager::class), + OC::$server->query(SyncService::class) + ); } public function testSyncEmptyMailbox() { $account = $this->createTestAccount(); + /** @var SyncService $syncService */ + $syncService = OC::$server->query(SyncService::class); + $syncService->syncAccount(new Account($account), true); $mailbox = 'INBOX'; - $syncToken = $this->getMailboxSyncToken($mailbox); - - $jsonResponse = $this->foldersController->sync($account->getId(), base64_encode($mailbox), $syncToken); - $syncJson = $jsonResponse->getData()->jsonSerialize(); - $this->assertArrayHasKey('newMessages', $syncJson); - $this->assertArrayHasKey('changedMessages', $syncJson); - $this->assertArrayHasKey('vanishedMessages', $syncJson); - $this->assertArrayHasKey('token', $syncJson); - $this->assertEmpty($syncJson['newMessages']); - $this->assertEmpty($syncJson['changedMessages']); - $this->assertEmpty($syncJson['vanishedMessages']); + $jsonResponse = $this->foldersController->sync( + $account->getId(), + base64_encode($mailbox), + [] + ); + + $data = $jsonResponse->getData()->jsonSerialize(); + $this->assertArrayHasKey('newMessages', $data); + $this->assertArrayHasKey('changedMessages', $data); + $this->assertArrayHasKey('vanishedMessages', $data); + $this->assertEmpty($data['newMessages']); + $this->assertEmpty($data['changedMessages']); + $this->assertEmpty($data['vanishedMessages']); } public function testSyncNewMessage() { // First, set up account and retrieve sync token $account = $this->createTestAccount(); + /** @var SyncService $syncService */ + $syncService = OC::$server->query(SyncService::class); + $syncService->syncAccount(new Account($account), true); $mailbox = 'INBOX'; - $syncToken = $this->getMailboxSyncToken($mailbox); // Second, put a new message into the mailbox $message = $this->getMessageBuilder() ->from('ralph@buffington@domain.tld') @@ -71,7 +87,11 @@ public function testSyncNewMessage() { ->finish(); $this->saveMessage($mailbox, $message); - $jsonResponse = $this->foldersController->sync($account->getId(), base64_encode($mailbox), $syncToken); + $jsonResponse = $this->foldersController->sync( + $account->getId(), + base64_encode($mailbox), + [] + ); $syncJson = $jsonResponse->getData()->jsonSerialize(); $this->assertCount(1, $syncJson['newMessages']); @@ -82,6 +102,9 @@ public function testSyncNewMessage() { public function testSyncChangedMessage() { // First, put a message into the mailbox $account = $this->createTestAccount(); + /** @var SyncService $syncService */ + $syncService = OC::$server->query(SyncService::class); + $syncService->syncAccount(new Account($account), true); $mailbox = 'INBOX'; $message = $this->getMessageBuilder() ->from('ralph@buffington@domain.tld') @@ -93,19 +116,25 @@ public function testSyncChangedMessage() { // Third, flag it $this->flagMessage($mailbox, $id); - $jsonResponse = $this->foldersController->sync($account->getId(), base64_encode($mailbox), $syncToken, [ - $id - ]); + $jsonResponse = $this->foldersController->sync( + $account->getId(), + base64_encode($mailbox), + [ + $id + ]); $syncJson = $jsonResponse->getData()->jsonSerialize(); - $this->assertCount(0, $syncJson['newMessages']); - $this->assertCount(1, $syncJson['changedMessages']); + $this->assertCount(1, $syncJson['newMessages']); + $this->assertCount(2, $syncJson['changedMessages']); $this->assertCount(0, $syncJson['vanishedMessages']); } public function testSyncVanishedMessage() { // First, put a message into the mailbox $account = $this->createTestAccount(); + /** @var SyncService $syncService */ + $syncService = OC::$server->query(SyncService::class); + $syncService->syncAccount(new Account($account), true); $mailbox = 'INBOX'; $message = $this->getMessageBuilder() ->from('ralph@buffington@domain.tld') @@ -117,15 +146,18 @@ public function testSyncVanishedMessage() { // Third, remove it again $this->deleteMessage($mailbox, $id); - $jsonResponse = $this->foldersController->sync($account->getId(), base64_encode($mailbox), $syncToken, [ - $id - ]); + $jsonResponse = $this->foldersController->sync( + $account->getId(), + base64_encode($mailbox), + [ + $id + ]); $syncJson = $jsonResponse->getData()->jsonSerialize(); $this->assertCount(0, $syncJson['newMessages']); // TODO: deleted messages are flagged as changed? could be a testing-only issue // $this->assertCount(0, $syncJson['changedMessages']); - $this->assertCount(1, $syncJson['vanishedMessages']); + $this->assertCount(2, $syncJson['vanishedMessages']); } } diff --git a/tests/Integration/Framework/ImapTestAccount.php b/tests/Integration/Framework/ImapTestAccount.php index d6f15da677..30271542a9 100644 --- a/tests/Integration/Framework/ImapTestAccount.php +++ b/tests/Integration/Framework/ImapTestAccount.php @@ -22,7 +22,9 @@ namespace OCA\Mail\Tests\Integration\Framework; use OC; +use OCA\Mail\Account; use OCA\Mail\Db\MailAccount; +use OCA\Mail\IMAP\MailboxSync; use OCA\Mail\Service\AccountService; trait ImapTestAccount { @@ -57,7 +59,13 @@ public function createTestAccount() { $mailAccount->setOutboundUser('user@domain.tld'); $mailAccount->setOutboundPassword(OC::$server->getCrypto()->encrypt('mypassword')); $mailAccount->setOutboundSslMode('none'); - return $accountService->save($mailAccount); + $acc = $accountService->save($mailAccount); + + /** @var MailboxSync $mbSync */ + $mbSync = OC::$server->query(MailboxSync::class); + $mbSync->sync(new Account($mailAccount)); + + return $acc; } } diff --git a/tests/Unit/Controller/FoldersControllerTest.php b/tests/Unit/Controller/FoldersControllerTest.php index c2aaf68e79..2b338b1ba0 100644 --- a/tests/Unit/Controller/FoldersControllerTest.php +++ b/tests/Unit/Controller/FoldersControllerTest.php @@ -21,6 +21,8 @@ namespace OCA\Mail\Tests\Unit\Controller; +use OCA\Mail\Service\SyncService; +use PHPUnit\Framework\MockObject\MockObject; use function base64_encode; use OCA\Mail\Account; use OCA\Mail\Contracts\IMailManager; @@ -32,34 +34,45 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; -use PHPUnit_Framework_MockObject_MockObject; class FoldersControllerTest extends TestCase { /** @var string */ private $appName = 'mail'; - /** @var IRequest|PHPUnit_Framework_MockObject_MockObject */ + /** @var IRequest|MockObject */ private $request; - /** @var AccountService|PHPUnit_Framework_MockObject_MockObject */ + /** @var AccountService|MockObject */ private $accountService; /** @var string */ private $userId = 'john'; - /** @var IMailManager|PHPUnit_Framework_MockObject_MockObject */ + /** @var IMailManager|MockObject */ private $mailManager; /** @var FoldersController */ private $controller; + /** @var SyncService|MockObject */ + private $syncService; + public function setUp(): void { parent::setUp(); + $this->request = $this->createMock(IRequest::class); $this->accountService = $this->createMock(AccountService::class); $this->mailManager = $this->createMock(IMailManager::class); - $this->controller = new FoldersController($this->appName, $this->request, $this->accountService, $this->userId, $this->mailManager); + $this->syncService = $this->createMock(SyncService::class); + $this->controller = new FoldersController( + $this->appName, + $this->request, + $this->accountService, + $this->userId, + $this->mailManager, + $this->syncService + ); } public function testIndex() { @@ -75,7 +88,7 @@ public function testIndex() { ->with($this->equalTo($account)) ->willReturn([ $folder - ]); + ]); $account->expects($this->once()) ->method('getEmail') ->willReturn('user@example.com'); diff --git a/tests/Unit/Controller/MessagesControllerTest.php b/tests/Unit/Controller/MessagesControllerTest.php index 1178a03c46..0b215c5577 100644 --- a/tests/Unit/Controller/MessagesControllerTest.php +++ b/tests/Unit/Controller/MessagesControllerTest.php @@ -38,6 +38,7 @@ use OCA\Mail\Service\AccountService; use OCA\Mail\Service\ItineraryService; use OCA\Mail\Service\MailManager; +use OCA\Mail\Service\SyncService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\ContentSecurityPolicy; @@ -71,6 +72,9 @@ class MessagesControllerTest extends TestCase { /** @var ItineraryService|MockObject */ private $itineraryService; + /** @var SyncService|MockObject */ + private $syncService; + /** @var string */ private $userId; @@ -116,6 +120,7 @@ protected function setUp(): void { $this->mailManager = $this->createMock(IMailManager::class); $this->mailSearch = $this->createMock(IMailSearch::class); $this->itineraryService = $this->createMock(ItineraryService::class); + $this->syncService = $this->createMock(SyncService::class); $this->userId = 'john'; $this->userFolder = $this->createMock(Folder::class); $this->request = $this->createMock(Request::class); @@ -140,6 +145,7 @@ protected function setUp(): void { $this->mailManager, $this->mailSearch, $this->itineraryService, + $this->syncService, $this->userId, $this->userFolder, $this->logger, diff --git a/tests/Unit/IMAP/Search/SearchFilterStringParserTest.php b/tests/Unit/IMAP/Search/SearchFilterStringParserTest.php deleted file mode 100644 index 9a1affe75e..0000000000 --- a/tests/Unit/IMAP/Search/SearchFilterStringParserTest.php +++ /dev/null @@ -1,60 +0,0 @@ - - * - * @author 2019 Christoph Wurst - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -namespace OCA\Mail\Tests\Unit\IMAP\Search; - -use ChristophWurst\Nextcloud\Testing\TestCase; -use OCA\Mail\IMAP\Search\SearchFilterStringParser; - -class SearchFilterStringParserTest extends TestCase { - - private function search($filter) { - $helper = new SearchFilterStringParser(); - $query = $helper->parse($filter); - return (string)($query->build()['query']); - } - - public function testSearchEmpty() { - $this->assertEquals('ALL', $this->search('')); - } - - public function testSearchTest() { - $this->assertEquals('TEXT "dummy text"', $this->search('dummy text')); - } - - public function testSearchUnread() { - $this->assertEquals('UNSEEN', $this->search('is:unread')); - } - - public function testSearchNotAnswered() { - $this->assertEquals('UNANSWERED', $this->search('not:answered')); - } - - public function testSearchFrom() { - $this->assertEquals('FROM somebody@example.com', $this->search('from:somebody@example.com')); - } - - public function testSearchMixed() { - $this->assertEquals('UNSEEN FROM somebody@example.com TEXT nextcloud', $this->search('from:somebody@example.com is:unread nextcloud')); - } -} diff --git a/tests/Unit/IMAP/Search/SearchStrategyFactoryTest.php b/tests/Unit/IMAP/Search/SearchStrategyFactoryTest.php deleted file mode 100644 index 5f926125bf..0000000000 --- a/tests/Unit/IMAP/Search/SearchStrategyFactoryTest.php +++ /dev/null @@ -1,152 +0,0 @@ - - * - * @author 2019 Christoph Wurst - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -namespace OCA\Mail\Tests\Unit\IMAP\Search; - -use ChristophWurst\Nextcloud\Testing\TestCase; -use Horde_Imap_Client_Data_Capability; -use Horde_Imap_Client_Search_Query; -use Horde_Imap_Client_Socket; -use OCA\Mail\IMAP\Search\FullScanSearchStrategy; -use OCA\Mail\IMAP\Search\ImapSortSearchStrategy; -use OCA\Mail\IMAP\Search\SearchStrategyFactory; -use PHPUnit\Framework\MockObject\MockObject; - -class SearchStrategyFactoryTest extends TestCase { - - /** @var SearchStrategyFactory */ - private $factory; - - protected function setUp(): void { - parent::setUp(); - - $this->factory = new SearchStrategyFactory(); - } - - public function testGetStrategyForFetchNoFilter() { - /** @var MockObject|Horde_Imap_Client_Socket $client */ - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $mailbox = 'INBOX'; - $filter = new Horde_Imap_Client_Search_Query(); - $cursor = null; - $capability = $this->createMock(Horde_Imap_Client_Data_Capability::class); - $client->expects($this->once()) - ->method('__get') - ->with('capability') - ->willReturn($capability); - $capability->expects($this->once()) - ->method('query') - ->with('SORT') - ->willReturn(true); - - $strategy = $this->factory->getStrategy( - $client, - $mailbox, - $filter, - $cursor - ); - - $this->assertInstanceOf(ImapSortSearchStrategy::class, $strategy); - } - - public function testGetStrategyForFetchNoSort() { - /** @var MockObject|Horde_Imap_Client_Socket $client */ - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $mailbox = 'INBOX'; - $filter = new Horde_Imap_Client_Search_Query(); - $cursor = null; - $capability = $this->createMock(Horde_Imap_Client_Data_Capability::class); - $client->expects($this->once()) - ->method('__get') - ->with('capability') - ->willReturn($capability); - $capability->expects($this->once()) - ->method('query') - ->with('SORT') - ->willReturn(false); - - $strategy = $this->factory->getStrategy( - $client, - $mailbox, - $filter, - $cursor - ); - - $this->assertInstanceOf(FullScanSearchStrategy::class, $strategy); - } - - public function testGetStrategyForSearchWithSort() { - /** @var MockObject|Horde_Imap_Client_Socket $client */ - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $mailbox = 'INBOX'; - $filter = new Horde_Imap_Client_Search_Query(); - $filter->text('test'); - $cursor = null; - $capability = $this->createMock(Horde_Imap_Client_Data_Capability::class); - $client->expects($this->once()) - ->method('__get') - ->with('capability') - ->willReturn($capability); - $capability->expects($this->once()) - ->method('query') - ->with('SORT') - ->willReturn(true); - - $strategy = $this->factory->getStrategy( - $client, - $mailbox, - $filter, - $cursor - ); - - $this->assertInstanceOf(ImapSortSearchStrategy::class, $strategy); - } - - public function testGetStrategyForSearchWithoutSort() { - /** @var MockObject|Horde_Imap_Client_Socket $client */ - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $mailbox = 'INBOX'; - $filter = new Horde_Imap_Client_Search_Query(); - $filter->text('sort'); - $cursor = null; - $capability = $this->createMock(Horde_Imap_Client_Data_Capability::class); - $client->expects($this->once()) - ->method('__get') - ->with('capability') - ->willReturn($capability); - $capability->expects($this->once()) - ->method('query') - ->with('SORT') - ->willReturn(true); - - $strategy = $this->factory->getStrategy( - $client, - $mailbox, - $filter, - $cursor - ); - - $this->assertInstanceOf(ImapSortSearchStrategy::class, $strategy); - } - -} diff --git a/tests/Unit/IMAP/Sync/ResponseTest.php b/tests/Unit/IMAP/Sync/ResponseTest.php index 6d2299aa11..892324abb5 100644 --- a/tests/Unit/IMAP/Sync/ResponseTest.php +++ b/tests/Unit/IMAP/Sync/ResponseTest.php @@ -30,13 +30,11 @@ public function testJsonSerialize() { $newMessages = []; $changedMessages = []; $vanishedMessages = []; - $syncToken = 'bc4564'; - $response = new Response($syncToken, $newMessages, $changedMessages, $vanishedMessages); + $response = new Response($newMessages, $changedMessages, $vanishedMessages); $expected = [ 'newMessages' => [], 'changedMessages' => [], 'vanishedMessages' => [], - 'token' => $syncToken, ]; $json = $response->jsonSerialize(); diff --git a/tests/Unit/IMAP/Sync/SimpleMailboxSyncTest.php b/tests/Unit/IMAP/Sync/SimpleMailboxSyncTest.php index 59aa4e2ba1..455e893248 100644 --- a/tests/Unit/IMAP/Sync/SimpleMailboxSyncTest.php +++ b/tests/Unit/IMAP/Sync/SimpleMailboxSyncTest.php @@ -101,7 +101,7 @@ public function testGetVanishedMessages() { $this->hordeSync->vanisheduids = $this->createMock(Horde_Imap_Client_Ids::class); $this->hordeSync->vanisheduids->ids = [23, 24]; - $ids = $this->sync->getVanishedMessages($this->imapClient, $this->syncRequest, $this->hordeSync); + $ids = $this->sync->getVanishedMessageUids($this->imapClient, $this->syncRequest, $this->hordeSync); $this->assertEquals([23, 24], $ids); } diff --git a/tests/Unit/IMAP/Sync/SynchronizerTest.php b/tests/Unit/IMAP/Sync/SynchronizerTest.php index 9c83d654a5..b754822e84 100644 --- a/tests/Unit/IMAP/Sync/SynchronizerTest.php +++ b/tests/Unit/IMAP/Sync/SynchronizerTest.php @@ -1,4 +1,4 @@ - @@ -30,14 +30,14 @@ use OCA\Mail\IMAP\Sync\Response; use OCA\Mail\IMAP\Sync\SimpleMailboxSync; use OCA\Mail\IMAP\Sync\Synchronizer; -use PHPUnit_Framework_MockObject_MockObject; +use PHPUnit\Framework\MockObject\MockObject; class SynchronizerTest extends TestCase { - /** @var SimpleMailboxSync|PHPUnit_Framework_MockObject_MockObject */ + /** @var SimpleMailboxSync|MockObject */ private $simpleSync; - /** @var FavouritesMailboxSync|PHPUnit_Framework_MockObject_MockObject */ + /** @var FavouritesMailboxSync|MockObject */ private $favSync; /** @var Synchronizer */ @@ -83,7 +83,7 @@ public function testSync($flagged) { ->willReturn($flagged); $newMessages = []; $changedMessages = []; - $vanishedMessages = [4, 5]; + $vanishedMessageUids = [4, 5]; $sync->expects($this->once()) ->method('getNewMessages') ->with($imapClient, $request, $hordeSync) @@ -93,14 +93,10 @@ public function testSync($flagged) { ->with($imapClient, $request, $hordeSync) ->willReturn($changedMessages); $sync->expects($this->once()) - ->method('getVanishedMessages') + ->method('getVanishedMessageUids') ->with($imapClient, $request, $hordeSync) - ->willReturn($vanishedMessages); - $imapClient->expects($this->once()) - ->method('getSyncToken') - ->with($this->equalTo('inbox')) - ->willReturn('54321'); - $expected = new Response('54321', $newMessages, $changedMessages, $vanishedMessages); + ->willReturn($vanishedMessageUids); + $expected = new Response($newMessages, $changedMessages, $vanishedMessageUids); $response = $this->synchronizer->sync($imapClient, $request); diff --git a/tests/Unit/Service/AccountServiceTest.php b/tests/Unit/Service/AccountServiceTest.php index a07be412a0..9289b70c57 100644 --- a/tests/Unit/Service/AccountServiceTest.php +++ b/tests/Unit/Service/AccountServiceTest.php @@ -27,6 +27,7 @@ use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AliasesService; +use OCP\BackgroundJob\IJobList; use OCP\IL10N; use PHPUnit\Framework\MockObject\MockObject; @@ -53,15 +54,20 @@ class AccountServiceTest extends TestCase { /** @var MailAccount|MockObject */ private $account2; + /** @var IJobList|MockObject */ + private $jobList; + protected function setUp(): void { parent::setUp(); $this->mapper = $this->createMock(MailAccountMapper::class); $this->l10n = $this->createMock(IL10N::class); $this->aliasesService = $this->createMock(AliasesService::class); + $this->jobList = $this->createMock(IJobList::class); $this->accountService = new AccountService( $this->mapper, - $this->aliasesService + $this->aliasesService, + $this->jobList ); $this->account1 = $this->createMock(MailAccount::class); @@ -73,9 +79,9 @@ public function testFindByUserId() { ->method('findByUserId') ->with($this->user) ->will($this->returnValue([ - $this->account1, - $this->account2, - ])); + $this->account1, + $this->account2, + ])); $expected = [ new Account($this->account1), diff --git a/tests/Unit/Service/MailManagerTest.php b/tests/Unit/Service/MailManagerTest.php index f6bd644b25..11a11e6987 100644 --- a/tests/Unit/Service/MailManagerTest.php +++ b/tests/Unit/Service/MailManagerTest.php @@ -59,9 +59,6 @@ class MailManagerTest extends TestCase { /** @var MessageMapper|MockObject */ private $messageMapper; - /** @var Synchronizer|MockObject */ - private $sync; - /** @var IEventDispatcher|MockObject */ private $eventDispatcher; @@ -73,10 +70,9 @@ protected function setUp(): void { $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); $this->mailboxMapper = $this->createMock(MailboxMapper::class); - $this->mailboxSync = $this->createMock(MailboxSync::class); $this->folderMapper = $this->createMock(FolderMapper::class); $this->messageMapper = $this->createMock(MessageMapper::class); - $this->sync = $this->createMock(Synchronizer::class); + $this->mailboxSync = $this->createMock(MailboxSync::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->manager = new MailManager( @@ -84,7 +80,6 @@ protected function setUp(): void { $this->mailboxMapper, $this->mailboxSync, $this->folderMapper, - $this->sync, $this->messageMapper, $this->eventDispatcher ); @@ -156,22 +151,6 @@ public function testGetFolderStats() { $this->assertEquals($stats, $actual); } - public function testSync() { - $account = $this->createMock(Account::class); - $syncRequest = $this->createMock(Request::class); - $syncResonse = $this->createMock(Response::class); - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') - ->willReturn($client); - $this->sync->expects($this->once()) - ->method('sync') - ->with($client, $syncRequest) - ->willReturn($syncResonse); - - $this->manager->syncMessages($account, $syncRequest); - } - public function testDeleteMessageSourceFolderNotFound(): void { /** @var Account|MockObject $account */ $account = $this->createMock(Account::class); diff --git a/tests/Unit/Service/MailSearchTest.php b/tests/Unit/Service/MailSearchTest.php index fc44833078..2b5006ef40 100644 --- a/tests/Unit/Service/MailSearchTest.php +++ b/tests/Unit/Service/MailSearchTest.php @@ -28,23 +28,18 @@ use Horde_Imap_Client_Socket; use OCA\Mail\Account; use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\Db\MessageMapper; use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\IMAP\Search\SearchFilterStringParser; -use OCA\Mail\IMAP\Search\SearchStrategyFactory; -use OCA\Mail\Service\MailSearch; +use OCA\Mail\IMAP\Search\Provider; +use OCA\Mail\Service\Search\FilterStringParser; +use OCA\Mail\Service\Search\MailSearch; use OCP\ILogger; use PHPUnit\Framework\MockObject\MockObject; class MailSearchTest extends TestCase { - /** @var MockObject|IMAPClientFactory */ - private $imapClientFactory; - - /** @var MockObject|SearchStrategyFactory */ - private $searchStrategyFactory; - - /** @var MockObject|SearchFilterStringParser */ - private $searchStringParser; + /** @var FilterStringParser|MockObject */ + private $filterStringParser; /** @var MockObject|MailboxMapper */ private $mailboxMapper; @@ -55,34 +50,32 @@ class MailSearchTest extends TestCase { /** @var MailSearch */ private $search; + /** @var Provider|MockObject */ + private $imapSearchProvider; + + /** @var MessageMapper|MockObject */ + private $messageMapper; + protected function setUp(): void { parent::setUp(); - $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); - $this->searchStrategyFactory = $this->createMock(SearchStrategyFactory::class); - $this->searchStringParser = $this->createMock(SearchFilterStringParser::class); + $this->filterStringParser = $this->createMock(FilterStringParser::class); $this->mailboxMapper = $this->createMock(MailboxMapper::class); + $this->imapSearchProvider = $this->createMock(Provider::class); + $this->messageMapper = $this->createMock(MessageMapper::class); $this->logger = $this->createMock(ILogger::class); $this->search = new MailSearch( - $this->imapClientFactory, - $this->searchStrategyFactory, - $this->searchStringParser, + $this->filterStringParser, $this->mailboxMapper, + $this->imapSearchProvider, + $this->messageMapper, $this->logger ); } public function testNoFindMessages() { $account = $this->createMock(Account::class); - $client = $this->createMock(Horde_Imap_Client_Socket::class); - $this->imapClientFactory->expects($this->once()) - ->method('getClient') - ->with($account) - ->willReturn($client); - $client->expects($this->once()) - ->method('fetch') - ->willReturn(new Horde_Imap_Client_Fetch_Results()); $messages = $this->search->findMessages( $account,