diff options
author | Daniel Kesselberg <mail@danielkesselberg.de> | 2021-06-01 21:53:19 +0300 |
---|---|---|
committer | Christoph Wurst <christoph@winzerhof-wurst.at> | 2021-06-10 12:27:59 +0300 |
commit | 0da32c97f64e0cdf950368257447ec8fa6fe7fea (patch) | |
tree | e8caddaefcb971e17a83b32e60726d8cdeac568d | |
parent | c149af8e36d5491b2a9bb0133495f289e00d6945 (diff) |
Show each thread once per message list
Signed-off-by: Daniel Kesselberg <mail@danielkesselberg.de>
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
-rw-r--r-- | appinfo/routes.php | 10 | ||||
-rw-r--r-- | lib/Contracts/IMailManager.php | 25 | ||||
-rwxr-xr-x | lib/Controller/MessagesController.php | 2 | ||||
-rwxr-xr-x | lib/Controller/ThreadController.php | 123 | ||||
-rw-r--r-- | lib/Db/MessageMapper.php | 206 | ||||
-rw-r--r-- | lib/Db/ThreadMapper.php | 73 | ||||
-rw-r--r-- | lib/Service/MailManager.php | 95 | ||||
-rw-r--r-- | src/components/Envelope.vue | 12 | ||||
-rw-r--r-- | src/components/EnvelopeList.vue | 21 | ||||
-rw-r--r-- | src/components/Mailbox.vue | 4 | ||||
-rw-r--r-- | src/components/MenuEnvelope.vue | 7 | ||||
-rw-r--r-- | src/components/MoveModal.vue | 26 | ||||
-rw-r--r-- | src/components/Thread.vue | 20 | ||||
-rw-r--r-- | src/service/ThreadService.js | 27 | ||||
-rw-r--r-- | src/store/actions.js | 27 | ||||
-rw-r--r-- | tests/Unit/Controller/ThreadControllerTest.php | 228 | ||||
-rw-r--r-- | tests/Unit/Service/MailManagerTest.php | 198 | ||||
-rw-r--r-- | tests/psalm-baseline.xml | 4 |
18 files changed, 963 insertions, 145 deletions
diff --git a/appinfo/routes.php b/appinfo/routes.php index bf642752e..085bd9e82 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -293,6 +293,16 @@ return [ 'name' => 'sieve#updateActiveScript', 'url' => '/api/sieve/active/{id}', 'verb' => 'PUT' + ], + [ + 'name' => 'thread#delete', + 'url' => '/api/thread/{id}', + 'verb' => 'DELETE' + ], + [ + 'name' => 'thread#move', + 'url' => '/api/thread/{id}', + 'verb' => 'POST' ] ], 'resources' => [ diff --git a/lib/Contracts/IMailManager.php b/lib/Contracts/IMailManager.php index 76eb2f1d5..1eafd7b32 100644 --- a/lib/Contracts/IMailManager.php +++ b/lib/Contracts/IMailManager.php @@ -110,11 +110,11 @@ interface IMailManager { /** * @param Account $account - * @param int $messageId database message ID + * @param string $threadRootId thread root id * * @return Message[] */ - public function getThread(Account $account, int $messageId): array; + public function getThread(Account $account, string $threadRootId): array; /** * @param Account $sourceAccount @@ -263,4 +263,25 @@ interface IMailManager { * @throws ClientException if the given tag does not exist */ public function updateTag(int $id, string $displayName, string $color, string $userId): Tag; + + /** + * @param Account $srcAccount + * @param Mailbox $srcMailbox + * @param Account $dstAccount + * @param Mailbox $dstMailbox + * @param string $threadRootId + * @return void + * @throws ServiceException + */ + public function moveThread(Account $srcAccount, Mailbox $srcMailbox, Account $dstAccount, Mailbox $dstMailbox, string $threadRootId): void; + + /** + * @param Account $account + * @param Mailbox $mailbox + * @param string $threadRootId + * @return void + * @throws ClientException + * @throws ServiceException + */ + public function deleteThread(Account $account, Mailbox $mailbox, string $threadRootId): void; } diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php index a39de7001..e3cc7a3e7 100755 --- a/lib/Controller/MessagesController.php +++ b/lib/Controller/MessagesController.php @@ -307,7 +307,7 @@ class MessagesController extends Controller { return new JSONResponse([], Http::STATUS_FORBIDDEN); } - return new JSONResponse($this->mailManager->getThread($account, $id)); + return new JSONResponse($this->mailManager->getThread($account, $message->getThreadRootId())); } /** diff --git a/lib/Controller/ThreadController.php b/lib/Controller/ThreadController.php new file mode 100755 index 000000000..eecc245a0 --- /dev/null +++ b/lib/Controller/ThreadController.php @@ -0,0 +1,123 @@ +<?php + +declare(strict_types=1); + +/** + * @author Daniel Kesselberg <mail@danielkesselberg.de> + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see <http://www.gnu.org/licenses/> + * + */ + +namespace OCA\Mail\Controller; + +use OCA\Mail\Contracts\IMailManager; +use OCA\Mail\Exception\ClientException; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Service\AccountService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +class ThreadController extends Controller { + + /** @var string */ + private $currentUserId; + + /** @var LoggerInterface */ + private $logger; + + /** @var AccountService */ + private $accountService; + + /** @var IMailManager */ + private $mailManager; + + public function __construct(string $appName, + IRequest $request, + string $UserId, + AccountService $accountService, + IMailManager $mailManager) { + parent::__construct($appName, $request); + + $this->currentUserId = $UserId; + $this->accountService = $accountService; + $this->mailManager = $mailManager; + } + + /** + * @NoAdminRequired + * @TrapError + * + * @param int $id + * @param int $destMailboxId + * + * @return JSONResponse + * @throws ClientException + * @throws ServiceException + */ + public function move(int $id, int $destMailboxId): JSONResponse { + try { + $message = $this->mailManager->getMessage($this->currentUserId, $id); + $srcMailbox = $this->mailManager->getMailbox($this->currentUserId, $message->getMailboxId()); + $srcAccount = $this->accountService->find($this->currentUserId, $srcMailbox->getAccountId()); + $dstMailbox = $this->mailManager->getMailbox($this->currentUserId, $destMailboxId); + $dstAccount = $this->accountService->find($this->currentUserId, $dstMailbox->getAccountId()); + } catch (DoesNotExistException $e) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $this->mailManager->moveThread( + $srcAccount, + $srcMailbox, + $dstAccount, + $dstMailbox, + $message->getThreadRootId() + ); + + return new JSONResponse(); + } + + /** + * @NoAdminRequired + * @TrapError + * + * @param int $id + * + * @return JSONResponse + * @throws ClientException + * @throws ServiceException + */ + public function delete(int $id): JSONResponse { + try { + $message = $this->mailManager->getMessage($this->currentUserId, $id); + $mailbox = $this->mailManager->getMailbox($this->currentUserId, $message->getMailboxId()); + $account = $this->accountService->find($this->currentUserId, $mailbox->getAccountId()); + } catch (DoesNotExistException $e) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $this->mailManager->deleteThread( + $account, + $mailbox, + $message->getThreadRootId() + ); + + return new JSONResponse(); + } +} diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index 6b79fb38a..203c877e6 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -25,29 +25,29 @@ declare(strict_types=1); namespace OCA\Mail\Db; -use OCP\IUser; -use function ltrim; use OCA\Mail\Account; use OCA\Mail\Address; -use RuntimeException; -use OCP\IDBConnection; -use function array_map; -use function get_class; -use function mb_strcut; -use function array_keys; -use function array_combine; -use function array_udiff; use OCA\Mail\AddressList; +use OCA\Mail\IMAP\Threading\DatabaseMessage; use OCA\Mail\Service\Search\Flag; -use OCP\AppFramework\Db\QBMapper; -use OCP\DB\QueryBuilder\IQueryBuilder; -use OCA\Mail\Service\Search\SearchQuery; -use OCP\AppFramework\Utility\ITimeFactory; use OCA\Mail\Service\Search\FlagExpression; -use OCA\Mail\IMAP\Threading\DatabaseMessage; +use OCA\Mail\Service\Search\SearchQuery; use OCA\Mail\Support\PerformanceLogger; use OCP\AppFramework\Db\DoesNotExistException; -use function \OCA\Mail\array_flat_map; +use OCP\AppFramework\Db\QBMapper; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\IUser; +use RuntimeException; +use function array_combine; +use function array_keys; +use function array_map; +use function array_udiff; +use function get_class; +use function ltrim; +use function mb_strcut; +use function OCA\Mail\array_flat_map; /** * @template-extends QBMapper<Message> @@ -442,6 +442,7 @@ class MessageMapper extends QBMapper { return $ids; } + /** * @param Message ...$messages * @@ -552,34 +553,22 @@ class MessageMapper extends QBMapper { /** * @param Account $account - * @param int $messageId + * @param string $threadRootId * * @return Message[] */ - public function findThread(Account $account, int $messageId): array { + public function findThread(Account $account, string $threadRootId): array { $qb = $this->db->getQueryBuilder(); - $subQb1 = $this->db->getQueryBuilder(); - - $mailboxIdsQuery = $subQb1 - ->select('id') - ->from('mail_mailboxes') - ->where($qb->expr()->eq('account_id', $qb->createNamedParameter($account->getId(), IQueryBuilder::PARAM_INT))); - - /** - * Select the message with the given ID or any that has the same thread ID - */ - $selectMessages = $qb - ->select('m2.*') - ->from($this->getTableName(), 'm1') - ->leftJoin('m1', $this->getTableName(), 'm2', $qb->expr()->eq('m1.thread_root_id', 'm2.thread_root_id')) + $qb->select('messages.*') + ->from($this->getTableName(), 'messages') + ->join('messages', 'mail_mailboxes', 'mailboxes', $qb->expr()->eq('messages.mailbox_id', 'mailboxes.id', IQueryBuilder::PARAM_INT)) ->where( - $qb->expr()->in('m1.mailbox_id', $qb->createFunction($mailboxIdsQuery->getSQL()), IQueryBuilder::PARAM_INT_ARRAY), - $qb->expr()->in('m2.mailbox_id', $qb->createFunction($mailboxIdsQuery->getSQL()), IQueryBuilder::PARAM_INT_ARRAY), - $qb->expr()->eq('m1.id', $qb->createNamedParameter($messageId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), - $qb->expr()->isNotNull('m1.thread_root_id') + $qb->expr()->eq('mailboxes.account_id', $qb->createNamedParameter($account->getId(), IQueryBuilder::PARAM_INT)), + $qb->expr()->eq('messages.thread_root_id', $qb->createNamedParameter($threadRootId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR) ) - ->orderBy('sent_at', 'desc'); - return $this->findRelatedData($this->findEntities($selectMessages), $account->getUserId()); + ->orderBy('messages.sent_at', 'desc'); + + return $this->findRelatedData($this->findEntities($qb), $account->getUserId()); } /** @@ -593,10 +582,14 @@ class MessageMapper extends QBMapper { public function findIdsByQuery(Mailbox $mailbox, SearchQuery $query, ?int $limit, array $uids = null): array { $qb = $this->db->getQueryBuilder(); - $select = $qb - ->selectDistinct('m.id') - ->addSelect('m.sent_at') - ->from($this->getTableName(), 'm'); + if ($this->needDistinct($query)) { + $select = $qb->selectDistinct(['m.id', 'm.sent_at']); + } else { + $select = $qb->select(['m.id', 'm.sent_at']); + } + + $select->from($this->getTableName(), 'm') + ->leftJoin('m', $this->getTableName(), 'm2', 'm.mailbox_id = m2.mailbox_id and m.thread_root_id = m2.thread_root_id and m.sent_at < m2.sent_at'); if (!empty($query->getFrom())) { $select->innerJoin('m', 'mail_recipients', 'r0', 'm.id = r0.message_id'); @@ -612,7 +605,7 @@ class MessageMapper extends QBMapper { } $select->where( - $qb->expr()->eq('mailbox_id', $qb->createNamedParameter($mailbox->getId()), IQueryBuilder::PARAM_INT) + $qb->expr()->eq('m.mailbox_id', $qb->createNamedParameter($mailbox->getId()), IQueryBuilder::PARAM_INT) ); if (!empty($query->getFrom())) { @@ -641,7 +634,7 @@ class MessageMapper extends QBMapper { $qb->expr()->orX( ...array_map(function (string $subject) use ($qb) { return $qb->expr()->iLike( - 'subject', + 'm.subject', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($subject) . '%', IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR ); @@ -652,31 +645,32 @@ class MessageMapper extends QBMapper { if ($query->getCursor() !== null) { $select->andWhere( - $qb->expr()->lt('sent_at', $qb->createNamedParameter($query->getCursor(), IQueryBuilder::PARAM_INT)) + $qb->expr()->lt('m.sent_at', $qb->createNamedParameter($query->getCursor(), IQueryBuilder::PARAM_INT)) ); } // createParameter if ($uids !== null) { $select->andWhere( - $qb->expr()->in('uid', $qb->createParameter('uids')) + $qb->expr()->in('m.uid', $qb->createParameter('uids')) ); } foreach ($query->getFlags() as $flag) { - $select->andWhere($qb->expr()->eq($this->flagToColumnName($flag), $qb->createNamedParameter($flag->isSet(), IQueryBuilder::PARAM_BOOL))); + $select->andWhere($qb->expr()->eq('m.' . $this->flagToColumnName($flag), $qb->createNamedParameter($flag->isSet(), IQueryBuilder::PARAM_BOOL))); } if (!empty($query->getFlagExpressions())) { $select->andWhere( ...array_map(function (FlagExpression $expr) use ($select) { - return $this->flagExpressionToQuery($expr, $select); + return $this->flagExpressionToQuery($expr, $select, 'm'); }, $query->getFlagExpressions()) ); } - $select = $select - ->orderBy('sent_at', 'desc'); + $select->andWhere($qb->expr()->isNull('m2.id')); + + $select->orderBy('m.sent_at', 'desc'); if ($limit !== null) { - $select = $select->setMaxResults($limit); + $select->setMaxResults($limit); } if ($uids !== null) { @@ -697,10 +691,14 @@ class MessageMapper extends QBMapper { $qb = $this->db->getQueryBuilder(); $qbMailboxes = $this->db->getQueryBuilder(); - $select = $qb - ->selectDistinct('m.id') - ->addSelect('m.sent_at') - ->from($this->getTableName(), 'm'); + if ($this->needDistinct($query)) { + $select = $qb->selectDistinct(['m.id', 'm.sent_at']); + } else { + $select = $qb->select(['m.id', 'm.sent_at']); + } + + $select->from($this->getTableName(), 'm') + ->leftJoin('m', $this->getTableName(), 'm2', 'm.mailbox_id = m2.mailbox_id and m.thread_root_id = m2.thread_root_id and m.sent_at < m2.sent_at'); if (!empty($query->getFrom())) { $select->innerJoin('m', 'mail_recipients', 'r0', 'm.id = r0.message_id'); @@ -720,7 +718,7 @@ class MessageMapper extends QBMapper { ->join('mb', 'mail_accounts', 'a', $qb->expr()->eq('a.id', 'mb.account_id', IQueryBuilder::PARAM_INT)) ->where($qb->expr()->eq('a.user_id', $qb->createNamedParameter($user->getUID()))); $select->where( - $qb->expr()->in('mailbox_id', $qb->createFunction($selectMailboxIds->getSQL()), IQueryBuilder::PARAM_INT_ARRAY) + $qb->expr()->in('m.mailbox_id', $qb->createFunction($selectMailboxIds->getSQL()), IQueryBuilder::PARAM_INT_ARRAY) ); if (!empty($query->getFrom())) { @@ -749,7 +747,7 @@ class MessageMapper extends QBMapper { $qb->expr()->orX( ...array_map(function (string $subject) use ($qb) { return $qb->expr()->iLike( - 'subject', + 'm.subject', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($subject) . '%', IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR ); @@ -760,30 +758,31 @@ class MessageMapper extends QBMapper { if ($query->getCursor() !== null) { $select->andWhere( - $qb->expr()->lt('sent_at', $qb->createNamedParameter($query->getCursor(), IQueryBuilder::PARAM_INT)) + $qb->expr()->lt('m.sent_at', $qb->createNamedParameter($query->getCursor(), IQueryBuilder::PARAM_INT)) ); } if ($uids !== null) { $select->andWhere( - $qb->expr()->in('uid', $qb->createParameter('uids')) + $qb->expr()->in('m.uid', $qb->createParameter('uids')) ); } foreach ($query->getFlags() as $flag) { - $select->andWhere($qb->expr()->eq($this->flagToColumnName($flag), $qb->createNamedParameter($flag->isSet(), IQueryBuilder::PARAM_BOOL))); + $select->andWhere($qb->expr()->eq('m.' . $this->flagToColumnName($flag), $qb->createNamedParameter($flag->isSet(), IQueryBuilder::PARAM_BOOL))); } if (!empty($query->getFlagExpressions())) { $select->andWhere( ...array_map(function (FlagExpression $expr) use ($select) { - return $this->flagExpressionToQuery($expr, $select); + return $this->flagExpressionToQuery($expr, $select, 'm'); }, $query->getFlagExpressions()) ); } - $select = $select - ->orderBy('sent_at', 'desc'); + $select->andWhere($qb->expr()->isNull('m2.id')); + + $select->orderBy('m.sent_at', 'desc'); if ($limit !== null) { - $select = $select->setMaxResults($limit); + $select->setMaxResults($limit); } if ($uids !== null) { @@ -800,17 +799,44 @@ class MessageMapper extends QBMapper { }, $this->findEntities($select)); } - private function flagExpressionToQuery(FlagExpression $expr, IQueryBuilder $qb): string { - $operands = array_map(function (object $operand) use ($qb) { + /** + * Return true when a distinct query is required. + * + * For the threaded message list it's necessary to self-join + * the mail_messages table to figure out if we are the latest message + * of a thread. + * + * Unfortunately a self-join on a larger table has a significant + * performance impact. An database index (e.g. on thread_root_id) + * could improve the query performance but adding an index is blocked by + * - https://github.com/nextcloud/server/pull/25471 + * - https://github.com/nextcloud/mail/issues/4735 + * + * We noticed a better query performance without distinct. As distinct is + * only necessary when a search query is present (e.g. search for mail with + * two recipients) it's reasonable to use distinct only for those requests. + * + * @param SearchQuery $query + * @return bool + */ + private function needDistinct(SearchQuery $query): bool { + return !empty($query->getFrom()) + || !empty($query->getTo()) + || !empty($query->getCc()) + || !empty($query->getBcc()); + } + + private function flagExpressionToQuery(FlagExpression $expr, IQueryBuilder $qb, string $tableAlias): string { + $operands = array_map(function (object $operand) use ($qb, $tableAlias) { if ($operand instanceof Flag) { return $qb->expr()->eq( - $this->flagToColumnName($operand), + $tableAlias . '.' . $this->flagToColumnName($operand), $qb->createNamedParameter($operand->isSet(), IQueryBuilder::PARAM_BOOL), IQueryBuilder::PARAM_BOOL ); } if ($operand instanceof FlagExpression) { - return $this->flagExpressionToQuery($operand, $qb); + return $this->flagExpressionToQuery($operand, $qb, $tableAlias); } throw new RuntimeException('Invalid operand type ' . get_class($operand)); @@ -954,30 +980,38 @@ class MessageMapper extends QBMapper { * @return int[] */ public function findNewIds(Mailbox $mailbox, array $ids): array { - $sub = $this->db->getQueryBuilder(); - $qb = $this->db->getQueryBuilder(); + $select = $this->db->getQueryBuilder(); + $subSelect = $this->db->getQueryBuilder(); + + $inExpression = []; + $notInExpression = []; + + foreach (array_chunk($ids, 1000) as $chunk) { + $inExpression[] = $subSelect->expr()->in('id', $select->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)); + $notInExpression[] = $subSelect->expr()->notIn('m.id', $select->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)); + } - $subSelect = $sub - ->select($sub->func()->max('uid')) + $subSelect + ->select($subSelect->func()->min('sent_at')) ->from($this->getTableName()) ->where( - $sub->expr()->eq('mailbox_id', $qb->createNamedParameter($mailbox->getId(), IQueryBuilder::PARAM_INT)), - $sub->expr()->in('id', $qb->createParameter('ids')) + $subSelect->expr()->eq('mailbox_id', $select->createNamedParameter($mailbox->getId(), IQueryBuilder::PARAM_INT)), + $subSelect->expr()->orX(...$inExpression) ); - $qb->select('id') - ->from($this->getTableName()) + $select + ->select('m.id') + ->from($this->getTableName(), 'm') + ->leftJoin('m', $this->getTableName(), 'm2', 'm.mailbox_id = m2.mailbox_id and m.thread_root_id = m2.thread_root_id and m.sent_at < m2.sent_at') ->where( - $qb->expr()->eq('mailbox_id', $qb->createNamedParameter($mailbox->getId(), IQueryBuilder::PARAM_INT)) - ); + $select->expr()->eq('m.mailbox_id', $select->createNamedParameter($mailbox->getId(), IQueryBuilder::PARAM_INT)), + $select->expr()->andX(...$notInExpression), + $select->expr()->gt('m.sent_at', $select->createFunction('(' . $subSelect->getSQL() . ')'), IQueryBuilder::PARAM_INT), + $select->expr()->isNull('m2.id') + ) + ->orderBy('m.sent_at', 'desc'); - return array_flat_map(function (array $chunk) use ($qb, $subSelect) { - $qb->setParameter('ids', $chunk, IQueryBuilder::PARAM_INT_ARRAY); - $select = $qb->andWhere( - $qb->expr()->gt('uid', $qb->createFunction('(' . $subSelect->getSQL() . ')'), IQueryBuilder::PARAM_INT) - ); - return $this->findIds($select); - }, array_chunk($ids, 1000)); + return $this->findIds($select); } /** @@ -1073,7 +1107,7 @@ class MessageMapper extends QBMapper { if (empty($rows)) { return null; } - return (int) $rows[0]['id']; + return (int)$rows[0]['id']; } /** diff --git a/lib/Db/ThreadMapper.php b/lib/Db/ThreadMapper.php new file mode 100644 index 000000000..991f46573 --- /dev/null +++ b/lib/Db/ThreadMapper.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Daniel Kesselberg <mail@danielkesselberg.de> + * + * @author 2021 Daniel Kesselberg <mail@danielkesselberg.de> + * + * @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 <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Db; + +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<Message> + */ +class ThreadMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'mail_messages'); + } + + /** + * @return array<array-key, array{mailboxName: string, messageUid: int}> + */ + public function findMessageUidsAndMailboxNamesByAccountAndThreadRoot(MailAccount $mailAccount, string $threadRootId, bool $trash): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('messages.uid', 'mailboxes.name') + ->from($this->tableName, 'messages') + ->join('messages', 'mail_mailboxes', 'mailboxes', 'messages.mailbox_id = mailboxes.id') + ->where( + $qb->expr()->eq('messages.thread_root_id', $qb->createNamedParameter($threadRootId, IQueryBuilder::PARAM_STR)), + $qb->expr()->eq('mailboxes.account_id', $qb->createNamedParameter($mailAccount->getId(), IQueryBuilder::PARAM_INT)) + ); + + $trashMailboxId = $mailAccount->getTrashMailboxId(); + if ($trashMailboxId !== null) { + if ($trash) { + $qb->andWhere($qb->expr()->eq('mailboxes.id', $qb->createNamedParameter($trashMailboxId, IQueryBuilder::PARAM_INT))); + } else { + $qb->andWhere($qb->expr()->neq('mailboxes.id', $qb->createNamedParameter($trashMailboxId, IQueryBuilder::PARAM_INT))); + } + } + + $result = $qb->execute(); + $rows = array_map(static function (array $row) { + return [ + 'messageUid' => (int)$row[0], + 'mailboxName' => (string)$row[1] + ]; + }, $result->fetchAll(\PDO::FETCH_NUM)); + $result->closeCursor(); + + return $rows; + } +} diff --git a/lib/Service/MailManager.php b/lib/Service/MailManager.php index dd283f9f1..238b87900 100644 --- a/lib/Service/MailManager.php +++ b/lib/Service/MailManager.php @@ -35,6 +35,7 @@ use OCA\Mail\Db\Message; use OCA\Mail\Db\MessageMapper as DbMessageMapper; use OCA\Mail\Db\Tag; use OCA\Mail\Db\TagMapper; +use OCA\Mail\Db\ThreadMapper; use OCA\Mail\Events\BeforeMessageDeletedEvent; use OCA\Mail\Events\MessageDeletedEvent; use OCA\Mail\Events\MessageFlaggedEvent; @@ -87,17 +88,18 @@ class MailManager implements IMailManager { /** @var DbMessageMapper */ private $dbMessageMapper; - /** @var TagMapper */ - private $tagMapper; - /** @var IEventDispatcher */ private $eventDispatcher; - /** - * @var LoggerInterface - */ + /** @var LoggerInterface */ private $logger; + /** @var TagMapper */ + private $tagMapper; + + /** @var ThreadMapper */ + private $threadMapper; + public function __construct(IMAPClientFactory $imapClientFactory, MailboxMapper $mailboxMapper, MailboxSync $mailboxSync, @@ -106,7 +108,8 @@ class MailManager implements IMailManager { DbMessageMapper $dbMessageMapper, IEventDispatcher $eventDispatcher, LoggerInterface $logger, - TagMapper $tagMapper) { + TagMapper $tagMapper, + ThreadMapper $threadMapper) { $this->imapClientFactory = $imapClientFactory; $this->mailboxMapper = $mailboxMapper; $this->mailboxSync = $mailboxSync; @@ -116,6 +119,7 @@ class MailManager implements IMailManager { $this->eventDispatcher = $eventDispatcher; $this->logger = $logger; $this->tagMapper = $tagMapper; + $this->threadMapper = $threadMapper; } public function getMailbox(string $uid, int $id): Mailbox { @@ -155,13 +159,13 @@ class MailManager implements IMailManager { throw new ServiceException( "Could not get mailbox status: " . $e->getMessage(), - (int) $e->getCode(), + (int)$e->getCode(), $e ); } $this->folderMapper->detectFolderSpecialUse([$folder]); - $this->mailboxSync->sync($account, $this->logger,true); + $this->mailboxSync->sync($account, $this->logger, true); return $this->mailboxMapper->find($account, $name); } @@ -182,14 +186,14 @@ class MailManager implements IMailManager { } catch (Horde_Imap_Client_Exception | DoesNotExistException $e) { throw new ServiceException( "Could not load message", - (int) $e->getCode(), + (int)$e->getCode(), $e ); } } - public function getThread(Account $account, int $messageId): array { - return $this->dbMessageMapper->findThread($account, $messageId); + public function getThread(Account $account, string $threadRootId): array { + return $this->dbMessageMapper->findThread($account, $threadRootId); } public function getMessageIdForUid(Mailbox $mailbox, $uid): ?int { @@ -349,7 +353,7 @@ class MailManager implements IMailManager { } catch (Horde_Imap_Client_Exception $e) { throw new ServiceException( "Could not set subscription status for mailbox " . $mailbox->getId() . " on IMAP: " . $e->getMessage(), - (int) $e->getCode(), + (int)$e->getCode(), $e ); } @@ -357,7 +361,7 @@ class MailManager implements IMailManager { /** * 2. Pull changes into the mailbox database cache */ - $this->mailboxSync->sync($account, $this->logger,true); + $this->mailboxSync->sync($account, $this->logger, true); /** * 3. Return the updated object @@ -396,7 +400,7 @@ class MailManager implements IMailManager { } catch (Horde_Imap_Client_Exception $e) { throw new ServiceException( "Could not set message flag on IMAP: " . $e->getMessage(), - (int) $e->getCode(), + (int)$e->getCode(), $e ); } @@ -445,7 +449,7 @@ class MailManager implements IMailManager { } catch (Horde_Imap_Client_Exception $e) { throw new ServiceException( "Could not set message keyword on IMAP: " . $e->getMessage(), - (int) $e->getCode(), + (int)$e->getCode(), $e ); } @@ -520,7 +524,7 @@ class MailManager implements IMailManager { /** * 2. Get the IMAP changes into our database cache */ - $this->mailboxSync->sync($account, $this->logger,true); + $this->mailboxSync->sync($account, $this->logger, true); /** * 3. Return the cached object with the new ID @@ -605,7 +609,7 @@ class MailManager implements IMailManager { } catch (Horde_Imap_Client_Exception $e) { throw new ServiceException( "Could not get message flag options from IMAP: " . $e->getMessage(), - (int) $e->getCode(), + (int)$e->getCode(), $e ); } @@ -649,4 +653,59 @@ class MailManager implements IMailManager { return $this->tagMapper->update($tag); } + + public function moveThread(Account $srcAccount, Mailbox $srcMailbox, Account $dstAccount, Mailbox $dstMailbox, string $threadRootId): void { + $mailAccount = $srcAccount->getMailAccount(); + $messageInTrash = $srcMailbox->getId() === $mailAccount->getTrashMailboxId(); + + $messages = $this->threadMapper->findMessageUidsAndMailboxNamesByAccountAndThreadRoot( + $mailAccount, + $threadRootId, + $messageInTrash + ); + + foreach ($messages as $message) { + $this->logger->debug('move message', [ + 'messageId' => $message['messageUid'], + 'srcMailboxId' => $srcMailbox->getId(), + 'dstMailboxId' => $dstMailbox->getId() + ]); + + $this->moveMessage( + $srcAccount, + $message['mailboxName'], + $message['messageUid'], + $dstAccount, + $dstMailbox->getName() + ); + } + } + + /** + * @throws ClientException + * @throws ServiceException + */ + public function deleteThread(Account $account, Mailbox $mailbox, string $threadRootId): void { + $mailAccount = $account->getMailAccount(); + $messageInTrash = $mailbox->getId() === $mailAccount->getTrashMailboxId(); + + $messages = $this->threadMapper->findMessageUidsAndMailboxNamesByAccountAndThreadRoot( + $mailAccount, + $threadRootId, + $messageInTrash + ); + + foreach ($messages as $message) { + $this->logger->debug('deleting message', [ + 'messageId' => $message['messageUid'], + 'mailboxId' => $mailbox->getId(), + ]); + + $this->deleteMessage( + $account, + $message['mailboxName'], + $message['messageUid'] + ); + } + } } diff --git a/src/components/Envelope.vue b/src/components/Envelope.vue index b0fb06a63..7b3ecf075 100644 --- a/src/components/Envelope.vue +++ b/src/components/Envelope.vue @@ -100,7 +100,7 @@ <ActionButton icon="icon-external" :close-after-click="true" @click.prevent="onOpenMoveModal"> - {{ t('mail', 'Move') }} + {{ t('mail', 'Move thread') }} </ActionButton> <ActionRouter icon="icon-add" :to="{ @@ -119,7 +119,7 @@ <ActionButton icon="icon-delete" :close-after-click="true" @click.prevent="onDelete"> - {{ t('mail', 'Delete') }} + {{ t('mail', 'Delete thread') }} </ActionButton> </template> <template #extra> @@ -138,6 +138,7 @@ <MoveModal v-if="showMoveModal" :account="account" :envelopes="[data]" + :move-thread="true" @move="onMove" @close="onCloseMoveModal" /> <TagModal @@ -338,10 +339,11 @@ export default { // Remove from selection first this.setSelected(false) // Delete - this.$emit('delete') + this.$emit('delete', this.data.databaseId) + try { - await this.$store.dispatch('deleteMessage', { - id: this.data.databaseId, + await this.$store.dispatch('deleteThread', { + envelope: this.data, }) } catch (error) { showError(await matchError(error, { diff --git a/src/components/EnvelopeList.vue b/src/components/EnvelopeList.vue index 330de250c..c56350594 100644 --- a/src/components/EnvelopeList.vue +++ b/src/components/EnvelopeList.vue @@ -71,8 +71,8 @@ @click.prevent="onOpenMoveModal"> {{ n( 'mail', - 'Move {number}', - 'Move {number}', + 'Move {number} thread', + 'Move {number} threads', selection.length, { number: selection.length, @@ -98,8 +98,8 @@ @click.prevent="deleteAllSelected"> {{ n( 'mail', - 'Delete {number}', - 'Delete {number}', + 'Delete {number} thread', + 'Delete {number} threads', selection.length, { number: selection.length, @@ -111,6 +111,7 @@ v-if="showMoveModal" :account="account" :envelopes="selectedEnvelopes" + :move-thread="true" @close="onCloseMoveModal" /> </div> </transition> @@ -266,8 +267,8 @@ export default { }) this.unselectAll() }, - deleteAllSelected() { - this.selectedEnvelopes.forEach(async(envelope) => { + async deleteAllSelected() { + for (const envelope of this.selectedEnvelopes) { // Navigate if the message being deleted is the one currently viewed // Shouldn't we simply use $emit here? // Would be better to navigate after all messages have been deleted @@ -290,10 +291,10 @@ export default { }) } } - logger.info(`deleting message ${envelope.databaseId}`) + logger.info(`deleting thread ${envelope.threadRootId}`) try { - await this.$store.dispatch('deleteMessage', { - id: envelope.databaseId, + await this.$store.dispatch('deleteThread', { + envelope, }) } catch (error) { showError(await matchError(error, { @@ -306,7 +307,7 @@ export default { }, })) } - }) + } // Get new messages this.$store.dispatch('fetchNextEnvelopes', { diff --git a/src/components/Mailbox.vue b/src/components/Mailbox.vue index bb89799c3..a2c218ec9 100644 --- a/src/components/Mailbox.vue +++ b/src/components/Mailbox.vue @@ -357,8 +357,8 @@ export default { logger.debug('deleting', { env }) this.onDelete(env.databaseId) try { - await this.$store.dispatch('deleteMessage', { - id: env.databaseId, + await this.$store.dispatch('deleteThread', { + envelope: env, }) } catch (error) { logger.error('could not delete envelope', { diff --git a/src/components/MenuEnvelope.vue b/src/components/MenuEnvelope.vue index 3e150e3e9..ba7cb504c 100644 --- a/src/components/MenuEnvelope.vue +++ b/src/components/MenuEnvelope.vue @@ -81,7 +81,7 @@ <ActionButton icon="icon-external" :close-after-click="true" @click.prevent="onOpenMoveModal"> - {{ t('mail', 'Move') }} + {{ t('mail', 'Move message') }} </ActionButton> <ActionButton v-if="withShowSource" :icon="sourceLoading ? 'icon-loading-small' : 'icon-details'" @@ -100,7 +100,7 @@ <ActionButton icon="icon-delete" :close-after-click="true" @click.prevent="onDelete"> - {{ t('mail', 'Delete') }} + {{ t('mail', 'Delete message') }} </ActionButton> </Actions> <Modal v-if="showSourceModal" class="source-modal" @close="onCloseSourceModal"> @@ -297,6 +297,9 @@ export default { // Delete this.$emit('delete', this.envelope.databaseId) + + logger.info(`deleting message ${this.envelope.databaseId}`) + try { await this.$store.dispatch('deleteMessage', { id: this.envelope.databaseId, diff --git a/src/components/MoveModal.vue b/src/components/MoveModal.vue index aa308f22b..2081f09ba 100644 --- a/src/components/MoveModal.vue +++ b/src/components/MoveModal.vue @@ -2,8 +2,8 @@ <MailboxPicker :account="account" :selected.sync="destMailboxId" :loading="moving" - :label-select="t('mail', 'Move')" - :label-select-loading="t('mail', 'Moving')" + :label-select="moveThread ? t('mail', 'Move thread') : t('mail', 'Move message')" + :label-select-loading="moveThread ? t('mail', 'Moving thread') : t('mail', 'Moving message')" @select="onMove" @close="onClose" /> </template> @@ -26,6 +26,10 @@ export default { type: Array, required: true, }, + moveThread: { + type: Boolean, + default: false, + }, }, data() { return { @@ -41,18 +45,20 @@ export default { this.moving = true try { - const envelopeIds = this.envelopes - .filter((envelope) => envelope.mailboxId !== this.destMailboxId) - .map((envelope) => envelope.databaseId) + const envelopes = this.envelopes + .filter(envelope => envelope.mailboxId !== this.destMailboxId) - if (envelopeIds.length === 0) { + if (envelopes.length === 0) { return } - await Promise.all(envelopeIds.map((id) => this.$store.dispatch('moveMessage', { - id, - destMailboxId: this.destMailboxId, - }))) + for (const envelope of envelopes) { + if (this.moveThread) { + await this.$store.dispatch('moveThread', { envelope, destMailboxId: this.destMailboxId }) + } else { + await this.$store.dispatch('moveMessage', { id: envelope.databaseId, destMailboxId: this.destMailboxId }) + } + } await this.$store.dispatch('syncEnvelopes', { mailboxId: this.destMailboxId }) this.$emit('move') diff --git a/src/components/Thread.vue b/src/components/Thread.vue index 31f1d41b9..690f66f60 100644 --- a/src/components/Thread.vue +++ b/src/components/Thread.vue @@ -90,7 +90,25 @@ export default { return parseInt(this.$route.params.threadId, 10) }, thread() { - return this.$store.getters.getEnvelopeThread(this.threadId) + const envelopes = this.$store.getters.getEnvelopeThread(this.threadId) + const envelope = envelopes.find(envelope => envelope.databaseId === this.threadId) + + if (envelope === undefined) { + return [] + } + + const currentMailbox = this.$store.getters.getMailbox(envelope.mailboxId) + const trashMailbox = this.$store.getters.getMailboxes(currentMailbox.accountId).find(mailbox => mailbox.specialRole === 'trash') + + if (trashMailbox === undefined) { + return envelopes + } + + if (currentMailbox.databaseId === trashMailbox.databaseId) { + return envelopes.filter(envelope => envelope.mailboxId === trashMailbox.databaseId) + } else { + return envelopes.filter(envelope => envelope.mailboxId !== trashMailbox.databaseId) + } }, threadParticipants() { const recipients = this.thread.flatMap(envelope => { diff --git a/src/service/ThreadService.js b/src/service/ThreadService.js new file mode 100644 index 000000000..21a9d9c43 --- /dev/null +++ b/src/service/ThreadService.js @@ -0,0 +1,27 @@ +import { generateUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' +import { convertAxiosError } from '../errors/convert' + +export async function deleteThread(id) { + const url = generateUrl('/apps/mail/api/thread/{id}', { + id, + }) + + try { + return await axios.delete(url) + } catch (e) { + throw convertAxiosError(e) + } +} + +export async function moveThread(id, destMailboxId) { + const url = generateUrl('/apps/mail/api/thread/{id}', { + id, + }) + + try { + return await axios.post(url, { destMailboxId }) + } catch (e) { + throw convertAxiosError(e) + } +} diff --git a/src/store/actions.js b/src/store/actions.js index c5fd31167..6e42c0402 100644 --- a/src/store/actions.js +++ b/src/store/actions.js @@ -79,7 +79,8 @@ import SyncIncompleteError from '../errors/SyncIncompleteError' import MailboxLockedError from '../errors/MailboxLockedError' import { wait } from '../util/wait' import { updateAccount as updateSieveAccount } from '../service/SieveService' -import { UNIFIED_INBOX_ID, PAGE_SIZE } from './constants' +import { PAGE_SIZE, UNIFIED_INBOX_ID } from './constants' +import * as ThreadService from '../service/ThreadService' const sliceToPage = slice(0, PAGE_SIZE) @@ -799,4 +800,28 @@ export default { tagId: tag.id, }) }, + async deleteThread({ getters, commit }, { envelope }) { + commit('removeEnvelope', { id: envelope.databaseId }) + + try { + await ThreadService.deleteThread(envelope.databaseId) + console.debug('thread removed') + } catch (e) { + commit('addEnvelope', envelope) + console.error('could not delete thread', e) + throw e + } + }, + async moveThread({ getters, commit }, { envelope, destMailboxId }) { + commit('removeEnvelope', { id: envelope.databaseId }) + + try { + await ThreadService.moveThread(envelope.databaseId, destMailboxId) + console.debug('thread removed') + } catch (e) { + commit('addEnvelope', envelope) + console.error('could not move thread', e) + throw e + } + }, } diff --git a/tests/Unit/Controller/ThreadControllerTest.php b/tests/Unit/Controller/ThreadControllerTest.php new file mode 100644 index 000000000..655463ece --- /dev/null +++ b/tests/Unit/Controller/ThreadControllerTest.php @@ -0,0 +1,228 @@ +<?php + +declare(strict_types=1); + +/** + * @author Daniel Kesselberg <mail@danielkesselberg.de> + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see <http://www.gnu.org/licenses/> + * + */ + +namespace OCA\Mail\Tests\Unit\Controller; + +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Account; +use OCA\Mail\Contracts\IMailManager; +use OCA\Mail\Controller\ThreadController; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\Message; +use OCA\Mail\Service\AccountService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; + +class ThreadControllerTest extends TestCase { + + /** @var string */ + private $appName; + + /** @var IRequest|MockObject */ + private $request; + + /** @var string */ + private $userId; + + /** @var AccountService|MockObject */ + private $accountService; + + /** @var IMailManager|MockObject */ + private $mailManager; + + /** @var ThreadController */ + private $controller; + + protected function setUp(): void { + parent::setUp(); + + $this->appName = 'mail'; + $this->request = $this->getMockBuilder(IRequest::class)->getMock(); + $this->userId = 'john'; + $this->accountService = $this->createMock(AccountService::class); + $this->mailManager = $this->createMock(IMailManager::class); + + $this->controller = new ThreadController( + $this->appName, + $this->request, + $this->userId, + $this->accountService, + $this->mailManager + ); + } + + public function testMoveForbidden() { + $this->mailManager + ->method('getMessage') + ->willThrowException(new DoesNotExistException('for some reason there is no such record')); + + $response = $this->controller->move(100, 20); + + $this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus()); + } + + public function testMoveInbox(): void { + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setTrashMailboxId(80); + $this->accountService + ->method('find') + ->willReturn(new Account($mailAccount)); + $srcMailbox = new Mailbox(); + $srcMailbox->setId(20); + $srcMailbox->setName('INBOX'); + $srcMailbox->setAccountId($mailAccount->getId()); + $dstMailbox = new Mailbox(); + $dstMailbox->setId(40); + $dstMailbox->setName('Archive'); + $dstMailbox->setAccountId($mailAccount->getId()); + $this->mailManager + ->method('getMailbox') + ->willReturnMap([ + [$this->userId, $srcMailbox->getId(), $srcMailbox], + [$this->userId, $dstMailbox->getId(), $dstMailbox], + ]); + $message = new Message(); + $message->setId(300); + $message->setMailboxId($srcMailbox->getId()); + $message->setThreadRootId('some-thread-root-id-1'); + $this->mailManager + ->method('getMessage') + ->willReturn($message); + $this->mailManager + ->expects(self::once()) + ->method('moveThread'); + + $response = $this->controller->move($message->getId(), $dstMailbox->getId()); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + } + + public function testMoveTrash(): void { + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setTrashMailboxId(80); + $this->accountService + ->method('find') + ->willReturn(new Account($mailAccount)); + $srcMailbox = new Mailbox(); + $srcMailbox->setId(80); + $srcMailbox->setName('Trash'); + $srcMailbox->setAccountId($mailAccount->getId()); + $dstMailbox = new Mailbox(); + $dstMailbox->setId(20); + $dstMailbox->setName('Archive'); + $dstMailbox->setAccountId($mailAccount->getId()); + $this->mailManager + ->method('getMailbox') + ->willReturnMap([ + [$this->userId, $srcMailbox->getId(), $srcMailbox], + [$this->userId, $dstMailbox->getId(), $dstMailbox], + ]); + $message = new Message(); + $message->setId(300); + $message->setMailboxId($srcMailbox->getId()); + $message->setThreadRootId('some-thread-root-id-1'); + $this->mailManager + ->method('getMessage') + ->willReturn($message); + $this->mailManager + ->expects(self::once()) + ->method('moveThread'); + + $response = $this->controller->move($message->getId(), $dstMailbox->getId()); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + } + + public function testDeleteForbidden(): void { + $this->mailManager + ->method('getMessage') + ->willThrowException(new DoesNotExistException('for some reason there is no such record')); + + $response = $this->controller->delete(100); + + $this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus()); + } + + public function testDeleteInbox(): void { + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setTrashMailboxId(80); + $this->accountService + ->method('find') + ->willReturn(new Account($mailAccount)); + $mailbox = new Mailbox(); + $mailbox->setId(20); + $mailbox->setAccountId($mailAccount->getId()); + $this->mailManager + ->method('getMailbox') + ->willReturn($mailbox); + $message = new Message(); + $message->setId(300); + $message->setMailboxId($mailbox->getId()); + $message->setThreadRootId('some-thread-root-id-1'); + $this->mailManager + ->method('getMessage') + ->willReturn($message); + $this->mailManager + ->expects(self::once()) + ->method('deleteThread'); + + $response = $this->controller->delete(300); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + } + + public function testDeleteTrash(): void { + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setTrashMailboxId(80); + $this->accountService + ->method('find') + ->willReturn(new Account($mailAccount)); + $mailbox = new Mailbox(); + $mailbox->setId(80); + $mailbox->setAccountId($mailAccount->getId()); + $this->mailManager + ->method('getMailbox') + ->willReturn($mailbox); + $message = new Message(); + $message->setId(300); + $message->setMailboxId($mailbox->getId()); + $message->setThreadRootId('some-thread-root-id-1'); + $this->mailManager + ->method('getMessage') + ->willReturn($message); + $this->mailManager + ->expects(self::once()) + ->method('deleteThread'); + + $response = $this->controller->delete($message->getId()); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + } +} diff --git a/tests/Unit/Service/MailManagerTest.php b/tests/Unit/Service/MailManagerTest.php index c90b04327..b4f72831e 100644 --- a/tests/Unit/Service/MailManagerTest.php +++ b/tests/Unit/Service/MailManagerTest.php @@ -33,6 +33,7 @@ use OCA\Mail\Db\Message; use OCA\Mail\Db\MessageMapper as DbMessageMapper; use OCA\Mail\Db\Tag; use OCA\Mail\Db\TagMapper; +use OCA\Mail\Db\ThreadMapper; use OCA\Mail\Events\BeforeMessageDeletedEvent; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; @@ -79,6 +80,9 @@ class MailManagerTest extends TestCase { /** @var MockObject|TagMapper */ private $tagMapper; + /** @var ThreadMapper|MockObject */ + private $threadMapper; + protected function setUp(): void { parent::setUp(); @@ -91,6 +95,7 @@ class MailManagerTest extends TestCase { $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->logger = $this->createMock(LoggerInterface::class); $this->tagMapper = $this->createMock(TagMapper::class); + $this->threadMapper = $this->createMock(ThreadMapper::class); $this->manager = new MailManager( $this->imapClientFactory, @@ -101,7 +106,8 @@ class MailManagerTest extends TestCase { $this->dbMessageMapper, $this->eventDispatcher, $this->logger, - $this->tagMapper + $this->tagMapper, + $this->threadMapper ); } @@ -518,12 +524,13 @@ class MailManagerTest extends TestCase { public function testGetThread(): void { $account = $this->createMock(Account::class); - $messageId = 123; + $threadRootId = '<some.message.id@localhost>'; + $this->dbMessageMapper->expects($this->once()) ->method('findThread') - ->with($account, $messageId); + ->with($account, $threadRootId); - $this->manager->getThread($account, $messageId); + $this->manager->getThread($account, $threadRootId); } public function testGetMailAttachments(): void { @@ -631,4 +638,187 @@ class MailManagerTest extends TestCase { $this->manager->updateTag(100, 'Hello Hello 👋', '#0082c9', 'admin'); } + + public function testMoveInbox(): void { + $srcMailboxId = 20; + $dstMailboxId = 80; + $threadRootId = 'some-thread-root-id-1'; + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setTrashMailboxId(80); + $account = new Account($mailAccount); + $srcMailbox = new Mailbox(); + $srcMailbox->setId($srcMailboxId); + $srcMailbox->setAccountId($mailAccount->getId()); + $srcMailbox->setName('INBOX'); + $this->mailboxMapper + ->expects(self::exactly(2)) + ->method('find') + ->with($account, $srcMailbox->getName()) + ->willReturn($srcMailbox); + $this->threadMapper + ->expects(self::once()) + ->method('findMessageUidsAndMailboxNamesByAccountAndThreadRoot') + ->with($mailAccount, $threadRootId, false) + ->willReturn([ + ['messageUid' => 200, 'mailboxName' => 'INBOX'], + ['messageUid' => 300, 'mailboxName' => 'INBOX'], + ]); + $dstMailbox = new Mailbox(); + $dstMailbox->setId($dstMailboxId); + $dstMailbox->setAccountId($mailAccount->getId()); + $dstMailbox->setName('Trash'); + + $this->imapMessageMapper + ->expects(self::exactly(2)) + ->method('move'); + $this->eventDispatcher + ->expects(self::exactly(2)) + ->method('dispatch'); + + $this->manager->moveThread( + $account, + $srcMailbox, + $account, + $dstMailbox, + $threadRootId + ); + } + + public function testMoveTrash(): void { + $srcMailboxId = 20; + $dstMailboxId = 80; + $threadRootId = 'some-thread-root-id-1'; + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setTrashMailboxId($srcMailboxId); + $account = new Account($mailAccount); + $srcMailbox = new Mailbox(); + $srcMailbox->setId($srcMailboxId); + $srcMailbox->setAccountId($mailAccount->getId()); + $srcMailbox->setName('Trash'); + $this->mailboxMapper + ->expects(self::exactly(2)) + ->method('find') + ->with($account, $srcMailbox->getName()) + ->willReturn($srcMailbox); + $this->threadMapper + ->expects(self::once()) + ->method('findMessageUidsAndMailboxNamesByAccountAndThreadRoot') + ->with($mailAccount, $threadRootId, true) + ->willReturn([ + ['messageUid' => 200, 'mailboxName' => 'Trash'], + ['messageUid' => 300, 'mailboxName' => 'Trash'], + ]); + $dstMailbox = new Mailbox(); + $dstMailbox->setId($dstMailboxId); + $dstMailbox->setAccountId($mailAccount->getId()); + $dstMailbox->setName('INBOX'); + + $this->imapMessageMapper + ->expects(self::exactly(2)) + ->method('move'); + $this->eventDispatcher + ->expects(self::exactly(2)) + ->method('dispatch'); + + $this->manager->moveThread( + $account, + $srcMailbox, + $account, + $dstMailbox, + $threadRootId + ); + } + + public function testDeleteInbox(): void { + $mailboxId = 20; + $trashMailboxId = 80; + $threadRootId = 'some-thread-root-id-1'; + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setTrashMailboxId($trashMailboxId); + $account = new Account($mailAccount); + $mailbox = new Mailbox(); + $mailbox->setId($mailboxId); + $mailbox->setAccountId($mailAccount->getId()); + $mailbox->setName('INBOX'); + $this->mailboxMapper + ->expects(self::exactly(2)) + ->method('find') + ->with($account, $mailbox->getName()) + ->willReturn($mailbox); + $this->threadMapper + ->expects(self::once()) + ->method('findMessageUidsAndMailboxNamesByAccountAndThreadRoot') + ->with($mailAccount, $threadRootId, false) + ->willReturn([ + ['messageUid' => 200, 'mailboxName' => 'INBOX'], + ['messageUid' => 300, 'mailboxName' => 'INBOX'], + ]); + $trashMailbox = new Mailbox(); + $trashMailbox->setId($trashMailboxId); + $trashMailbox->setAccountId($mailAccount->getId()); + $trashMailbox->setName('Trash'); + $this->mailboxMapper + ->expects(self::exactly(2)) + ->method('findById') + ->with($trashMailbox->getId()) + ->willReturn($trashMailbox); + $this->imapMessageMapper + ->expects(self::exactly(2)) + ->method('move'); + $this->eventDispatcher + ->expects(self::exactly(4)) + ->method('dispatch'); + + $this->manager->deleteThread( + $account, + $mailbox, + $threadRootId + ); + } + + public function testDeleteTrash(): void { + $mailboxId = 80; + $threadRootId = 'some-thread-root-id-1'; + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setTrashMailboxId($mailboxId); + $account = new Account($mailAccount); + $mailbox = new Mailbox(); + $mailbox->setId($mailboxId); + $mailbox->setAccountId($mailAccount->getId()); + $mailbox->setName('Trash'); + $this->mailboxMapper + ->expects(self::exactly(2)) + ->method('find') + ->with($account, $mailbox->getName()) + ->willReturn($mailbox); + $this->mailboxMapper + ->expects(self::exactly(2)) + ->method('findById') + ->with($mailbox->getId()) + ->willReturn($mailbox); + $this->threadMapper + ->expects(self::once()) + ->method('findMessageUidsAndMailboxNamesByAccountAndThreadRoot') + ->with($mailAccount, $threadRootId, true) + ->willReturn([ + ['messageUid' => 200, 'mailboxName' => 'Trash'], + ['messageUid' => 300, 'mailboxName' => 'Trash'], + ]); + $this->imapMessageMapper + ->expects(self::exactly(2)) + ->method('expunge'); + $this->eventDispatcher + ->expects(self::exactly(4)) + ->method('dispatch'); + + $this->manager->deleteThread( + $account, + $mailbox, + $threadRootId + ); + } } diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 39bf79298..cafce1c43 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -94,14 +94,12 @@ </ImplicitToStringCast> </file> <file src="lib/Db/MessageMapper.php"> - <ImplicitToStringCast occurrences="26"> + <ImplicitToStringCast occurrences="25"> <code>$deleteMessagesQuery->createParameter('messageIds')</code> <code>$deleteRecipientsQuery->createParameter('ids')</code> <code>$deleteRecipientsQuery->createParameter('messageIds')</code> <code>$messageIdQuery->createParameter('uids')</code> <code>$messagesQuery->createFunction($mailboxesQuery->getSQL())</code> - <code>$qb->createFunction($mailboxIdsQuery->getSQL())</code> - <code>$qb->createFunction($mailboxIdsQuery->getSQL())</code> <code>$qb->createFunction($selectMailboxIds->getSQL())</code> <code>$qb->createNamedParameter($mailboxIds, IQueryBuilder::PARAM_INT_ARRAY)</code> <code>$qb->createNamedParameter($query->getBcc(), IQueryBuilder::PARAM_STR_ARRAY)</code> |