Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/mail.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Kesselberg <mail@danielkesselberg.de>2021-06-01 21:53:19 +0300
committerChristoph Wurst <christoph@winzerhof-wurst.at>2021-06-10 12:27:59 +0300
commit0da32c97f64e0cdf950368257447ec8fa6fe7fea (patch)
treee8caddaefcb971e17a83b32e60726d8cdeac568d
parentc149af8e36d5491b2a9bb0133495f289e00d6945 (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.php10
-rw-r--r--lib/Contracts/IMailManager.php25
-rwxr-xr-xlib/Controller/MessagesController.php2
-rwxr-xr-xlib/Controller/ThreadController.php123
-rw-r--r--lib/Db/MessageMapper.php206
-rw-r--r--lib/Db/ThreadMapper.php73
-rw-r--r--lib/Service/MailManager.php95
-rw-r--r--src/components/Envelope.vue12
-rw-r--r--src/components/EnvelopeList.vue21
-rw-r--r--src/components/Mailbox.vue4
-rw-r--r--src/components/MenuEnvelope.vue7
-rw-r--r--src/components/MoveModal.vue26
-rw-r--r--src/components/Thread.vue20
-rw-r--r--src/service/ThreadService.js27
-rw-r--r--src/store/actions.js27
-rw-r--r--tests/Unit/Controller/ThreadControllerTest.php228
-rw-r--r--tests/Unit/Service/MailManagerTest.php198
-rw-r--r--tests/psalm-baseline.xml4
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-&gt;createParameter('messageIds')</code>
<code>$deleteRecipientsQuery-&gt;createParameter('ids')</code>
<code>$deleteRecipientsQuery-&gt;createParameter('messageIds')</code>
<code>$messageIdQuery-&gt;createParameter('uids')</code>
<code>$messagesQuery-&gt;createFunction($mailboxesQuery-&gt;getSQL())</code>
- <code>$qb-&gt;createFunction($mailboxIdsQuery-&gt;getSQL())</code>
- <code>$qb-&gt;createFunction($mailboxIdsQuery-&gt;getSQL())</code>
<code>$qb-&gt;createFunction($selectMailboxIds-&gt;getSQL())</code>
<code>$qb-&gt;createNamedParameter($mailboxIds, IQueryBuilder::PARAM_INT_ARRAY)</code>
<code>$qb-&gt;createNamedParameter($query-&gt;getBcc(), IQueryBuilder::PARAM_STR_ARRAY)</code>