diff options
27 files changed, 1252 insertions, 141 deletions
diff --git a/appinfo/info.xml b/appinfo/info.xml index 16d00a06b..151a27ea5 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -12,7 +12,7 @@ - **🙈 We’re not reinventing the wheel!** Based on the great [Horde](http://horde.org) libraries. - **📬 Want to host your own mail server?** We don’t have to reimplement this as you could set up [Mail-in-a-Box](https://mailinabox.email)! ]]></description> - <version>1.10.0-alpha.1</version> + <version>1.10.0-alpha.2</version> <licence>agpl</licence> <author>Christoph Wurst</author> <author>Greta Doçi</author> diff --git a/appinfo/routes.php b/appinfo/routes.php index d25b478db..3f8e4bc3e 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -175,6 +175,16 @@ return [ 'verb' => 'PUT' ], [ + 'name' => 'messages#setTag', + 'url' => '/api/messages/{id}/tags/{imapLabel}', + 'verb' => 'PUT' + ], + [ + 'name' => 'messages#removeTag', + 'url' => '/api/messages/{id}/tags/{imapLabel}', + 'verb' => 'DELETE' + ], + [ 'name' => 'messages#move', 'url' => '/api/messages/{id}/move', 'verb' => 'POST' diff --git a/lib/Account.php b/lib/Account.php index 456fac531..c6c3bb2f4 100644 --- a/lib/Account.php +++ b/lib/Account.php @@ -218,6 +218,13 @@ class Account implements JsonSerializable { } /** + * @return string + */ + public function getUserId() { + return $this->account->getUserId(); + } + + /** * @deprecated * * @return void diff --git a/lib/Contracts/IMailManager.php b/lib/Contracts/IMailManager.php index 9b3f48e41..33fe6d970 100644 --- a/lib/Contracts/IMailManager.php +++ b/lib/Contracts/IMailManager.php @@ -23,15 +23,16 @@ declare(strict_types=1); namespace OCA\Mail\Contracts; +use OCA\Mail\Db\Tag; +use OCA\Mail\Folder; use OCA\Mail\Account; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; -use OCA\Mail\Exception\ClientException; -use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Folder; +use OCA\Mail\Service\Quota; use OCA\Mail\IMAP\FolderStats; use OCA\Mail\Model\IMAPMessage; -use OCA\Mail\Service\Quota; +use OCA\Mail\Exception\ClientException; +use OCA\Mail\Exception\ServiceException; use OCP\AppFramework\Db\DoesNotExistException; interface IMailManager { @@ -172,6 +173,18 @@ interface IMailManager { /** * @param Account $account + * @param string $mailbox + * @param int $uid + * @param Tag $tag + * @param bool $value + * + * @throws ClientException + * @throws ServiceException + */ + public function tagMessage(Account $account, string $mailbox, Message $message, Tag $tag, bool $value): void; + + /** + * @param Account $account * * @return Quota|null */ @@ -229,4 +242,12 @@ interface IMailManager { * @return array */ public function getMailAttachments(Account $account, Mailbox $mailbox, Message $message) : array; + + /** + * @param string $imapLabel + * @param string $userId + * @return Tag + * @throws DoesNotExistException + */ + public function getTagByImapLabel(string $imapLabel, string $userId): Tag; } diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php index 6361d415e..0cfa3a8eb 100755 --- a/lib/Controller/MessagesController.php +++ b/lib/Controller/MessagesController.php @@ -41,7 +41,6 @@ use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Http\AttachmentDownloadResponse; use OCA\Mail\Http\HtmlResponse; -use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\ItineraryService; use OCP\AppFramework\Controller; @@ -653,6 +652,68 @@ class MessagesController extends Controller { * @NoAdminRequired * @TrapError * + * @param int $id + * @param string $imapLabel + * + * @return JSONResponse + * + * @throws ClientException + * @throws ServiceException + */ + public function setTag(int $id, string $imapLabel): 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); + } + + try { + $tag = $this->mailManager->getTagByImapLabel($imapLabel, $this->currentUserId); + } catch (DoesNotExistException $e) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $this->mailManager->tagMessage($account, $mailbox->getName(), $message, $tag, true); + return new JSONResponse(); + } + + /** + * @NoAdminRequired + * @TrapError + * + * @param int $id + * @param string $imapLabel + * + * @return JSONResponse + * + * @throws ClientException + * @throws ServiceException + */ + public function removeTag(int $id, string $imapLabel): 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); + } + + try { + $tag = $this->mailManager->getTagByImapLabel($imapLabel, $this->currentUserId); + } catch (DoesNotExistException $e) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $this->mailManager->tagMessage($account, $mailbox->getName(), $message, $tag, false); + return new JSONResponse(); + } + + /** + * @NoAdminRequired + * @TrapError + * * @param int $accountId * @param string $folderId * @param int $id @@ -705,10 +766,10 @@ class MessagesController extends Controller { } /** - * @param array $attachment - * * Determines if the content of this attachment is an image * + * @param array $attachment + * * @return boolean */ private function attachmentIsImage(array $attachment): bool { diff --git a/lib/Db/MailAccountMapper.php b/lib/Db/MailAccountMapper.php index 5b4fec756..1dccbb674 100644 --- a/lib/Db/MailAccountMapper.php +++ b/lib/Db/MailAccountMapper.php @@ -148,4 +148,13 @@ class MailAccountMapper extends QBMapper { return $this->findEntities($query); } + + public function getAllUserIdsWithAccounts(): array { + $qb = $this->db->getQueryBuilder(); + $query = $qb + ->selectDistinct('user_id') + ->from($this->getTableName()); + + return $this->findEntities($query); + } } diff --git a/lib/Db/Message.php b/lib/Db/Message.php index 3c6a46743..ea27288b0 100644 --- a/lib/Db/Message.php +++ b/lib/Db/Message.php @@ -86,8 +86,9 @@ class Message extends Entity implements JsonSerializable { 'forwarded', 'junk', 'notjunk', - 'important', - 'mdnsent' + 'mdnsent', + Tag::LABEL_IMPORTANT, + '$important' // @todo remove this when we have removed all references on IMAP to $important @link https://github.com/nextcloud/mail/issues/25 ]; protected $uid; @@ -125,6 +126,9 @@ class Message extends Entity implements JsonSerializable { /** @var AddressList */ private $bcc; + /** @var Tag[] */ + private $tags = []; + public function __construct() { $this->from = new AddressList([]); $this->to = new AddressList([]); @@ -200,6 +204,20 @@ class Message extends Entity implements JsonSerializable { } /** + * @return Tag[] + */ + public function getTags(): array { + return $this->tags; + } + + /** + * @param array $tags + */ + public function setTags(array $tags): void { + $this->tags = $tags; + } + + /** * @return AddressList */ public function getCc(): AddressList { @@ -232,14 +250,26 @@ class Message extends Entity implements JsonSerializable { // Ignore return; } - - $this->setter( - $this->columnToProperty("flag_$flag"), - [$value] - ); + if ($flag === Tag::LABEL_IMPORTANT) { + $this->setFlagImportant($value); + } else { + $this->setter( + $this->columnToProperty("flag_$flag"), + [$value] + ); + } } public function jsonSerialize() { + $tags = $this->getTags(); + $indexed = array_combine( + array_map( + function (Tag $tag) { + return $tag->getImapLabel(); + }, $tags), + $tags + ); + return [ 'databaseId' => $this->getId(), 'uid' => $this->getUid(), @@ -253,10 +283,10 @@ class Message extends Entity implements JsonSerializable { 'draft' => $this->getFlagDraft(), 'forwarded' => $this->getFlagForwarded(), 'hasAttachments' => $this->getFlagAttachments() ?? false, - 'important' => $this->getFlagImportant(), 'junk' => $this->getFlagJunk(), 'mdnsent' => $this->getFlagMdnsent(), ], + 'tags' => $indexed, 'from' => $this->getFrom()->jsonSerialize(), 'to' => $this->getTo()->jsonSerialize(), 'cc' => $this->getCc()->jsonSerialize(), diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index 796c2b551..b51217e5c 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -25,26 +25,28 @@ declare(strict_types=1); namespace OCA\Mail\Db; +use OCP\IUser; +use function ltrim; use OCA\Mail\Account; use OCA\Mail\Address; -use OCA\Mail\AddressList; -use OCA\Mail\IMAP\Threading\DatabaseMessage; -use OCA\Mail\Service\Search\Flag; -use OCA\Mail\Service\Search\FlagExpression; -use OCA\Mail\Service\Search\SearchQuery; -use OCP\AppFramework\Db\DoesNotExistException; -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 OCP\IDBConnection; use function array_map; use function get_class; -use function ltrim; use function mb_substr; +use function array_keys; +use function array_combine; +use function array_udiff; +use OCA\Mail\AddressList; +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\Support\PerformanceLogger; +use OCP\AppFramework\Db\DoesNotExistException; /** * @template-extends QBMapper<Message> @@ -54,10 +56,21 @@ class MessageMapper extends QBMapper { /** @var ITimeFactory */ private $timeFactory; + /** @var TagMapper */ + private $tagMapper; + + /** @var PerformanceLogger */ + private $performanceLogger; + public function __construct(IDBConnection $db, - ITimeFactory $timeFactory) { + ITimeFactory $timeFactory, + TagMapper $tagMapper, + PerformanceLogger $performanceLogger + ) { parent::__construct($db, 'mail_messages'); $this->timeFactory = $timeFactory; + $this->tagMapper = $tagMapper; + $this->performanceLogger = $performanceLogger; } /** @@ -119,7 +132,7 @@ class MessageMapper extends QBMapper { $query->expr()->eq('m.id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT) ); - $results = $this->findRecipients($this->findEntities($query)); + $results = $this->findRelatedData($this->findEntities($query)); if (empty($results)) { throw new DoesNotExistException("Message $id does not exist"); } @@ -229,7 +242,11 @@ class MessageMapper extends QBMapper { $this->db->commit(); } - public function insertBulk(Message ...$messages): void { + /** + * @param Message ...$messages + * @return void + */ + public function insertBulk(Account $account, Message ...$messages): void { $this->db->beginTransaction(); $qb1 = $this->db->getQueryBuilder(); @@ -309,19 +326,26 @@ class MessageMapper extends QBMapper { $qb2->execute(); } } + foreach ($message->getTags() as $tag) { + $this->tagMapper->tagMessage($tag, $message->getMessageId(), $account->getUserId()); + } } $this->db->commit(); } /** - * @param Message ...$messages - * + * @param Account $account + * @param Message[] $messages * @return Message[] */ - public function updateBulk(Message ...$messages): array { + public function updateBulk(Account $account, Message ...$messages): array { $this->db->beginTransaction(); + $perf = $this->performanceLogger->start( + 'partial sync ' . $account->getId() . ':' . $account->getName() + ); + $query = $this->db->getQueryBuilder(); $query->update($this->getTableName()) ->set('flag_answered', $query->createParameter('flag_answered')) @@ -339,7 +363,9 @@ class MessageMapper extends QBMapper { $query->expr()->eq('uid', $query->createParameter('uid')), $query->expr()->eq('mailbox_id', $query->createParameter('mailbox_id')) )); - + // get all tags before the loop and create a mapping [message_id => [tag,...]] + $tags = $this->tagMapper->getAllTagsForMessages($messages); + $perf->step("Selected Tags for all messages"); foreach ($messages as $message) { if (empty($message->getUpdatedFields())) { // Micro optimization @@ -360,13 +386,59 @@ class MessageMapper extends QBMapper { $query->setParameter('flag_important', $message->getFlagImportant(), IQueryBuilder::PARAM_BOOL); $query->execute(); + $perf->step('Updated message ' . $message->getId()); + + + $imapTags = $message->getTags(); + $dbTags = $tags[$message->getMessageId()] ?? []; + + if (empty($imapTags) === true && empty($dbTags) === true) { + // neither old nor new tags + continue; + } + + $toAdd = array_udiff($imapTags, $dbTags, function (Tag $a, Tag $b) { + return strcmp($a->getImapLabel(), $b->getImapLabel()); + }); + foreach ($toAdd as $tag) { + // add logging + $this->tagMapper->tagMessage($tag, $message->getMessageId(), $account->getUserId()); + } + $perf->step("Tagged messages"); + + if (empty($dbTags) === true) { + // we have nothing to possibly remove + continue; + } + + $toRemove = array_udiff($dbTags, $imapTags, function (Tag $a, Tag $b) { + return strcmp($a->getImapLabel(), $b->getImapLabel()); + }); + foreach ($toRemove as $tag) { + //add logging + $this->tagMapper->untagMessage($tag, $message->getMessageId()); + } + $perf->step("Untagged messages"); } $this->db->commit(); + $perf->end(); return $messages; } + public function getTags(Message $message) : array { + $mqb = $this->db->getQueryBuilder(); + $mqb->select('tag_id') + ->from('mail_message_tags') + ->where($mqb->expr()->eq('imap_message_id', $mqb->createNamedParameter($message->getMessageId()))); + $result = $mqb->execute(); + $ids = array_map(function (array $row) { + return (int)$row['tag_id']; + }, $result->fetchAll()); + + return $ids; + } /** * @param Message ...$messages * @@ -480,7 +552,7 @@ class MessageMapper extends QBMapper { $qb->expr()->isNotNull('m1.thread_root_id') ) ->orderBy('sent_at', 'desc'); - return $this->findRecipients($this->findEntities($selectMessages)); + return $this->findRelatedData($this->findEntities($selectMessages)); } /** @@ -711,6 +783,10 @@ class MessageMapper extends QBMapper { } private function flagToColumnName(Flag $flag): string { + // workaround for @link https://github.com/nextcloud/mail/issues/25 + if ($flag->getFlag() === Tag::LABEL_IMPORTANT) { + return "flag_important"; + } $key = ltrim($flag->getFlag(), '\\$'); return "flag_$key"; } @@ -733,7 +809,7 @@ class MessageMapper extends QBMapper { ) ->orderBy('sent_at', 'desc'); - return $this->findRecipients($this->findEntities($select)); + return $this->findRelatedData($this->findEntities($select)); } /** @@ -755,7 +831,7 @@ class MessageMapper extends QBMapper { ) ->orderBy('sent_at', 'desc'); - return $this->findRecipients($this->findEntities($select)); + return $this->findRelatedData($this->findEntities($select)); } /** @@ -809,6 +885,21 @@ class MessageMapper extends QBMapper { } /** + * @param Message[] $messages + * @return Message[] + */ + public function findRelatedData(array $messages): array { + $messages = $this->findRecipients($messages); + $tags = $this->tagMapper->getAllTagsForMessages($messages); + /** @var Message $message */ + $messages = array_map(function ($message) use ($tags) { + $message->setTags($tags[$message->getMessageId()] ?? []); + return $message; + }, $messages); + return $messages; + } + + /** * @param Mailbox $mailbox * @param int $highest * @@ -847,7 +938,7 @@ class MessageMapper extends QBMapper { $qb->expr()->gt('updated_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT)) ); - return $this->findRecipients($this->findEntities($select)); + return $this->findRelatedData($this->findEntities($select)); } /** @@ -870,7 +961,7 @@ class MessageMapper extends QBMapper { ->orderBy('sent_at', 'desc') ->setMaxResults($limit); - return $this->findRecipients($this->findEntities($select)); + return $this->findRelatedData($this->findEntities($select)); } public function deleteOrphans(): void { diff --git a/lib/Db/Tag.php b/lib/Db/Tag.php new file mode 100644 index 000000000..261f9c393 --- /dev/null +++ b/lib/Db/Tag.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Anna Larch <anna@nextcloud.com> + * + * @author 2021 Anna Larch <anna@nextcloud.com> + * + * @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 JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * @method string getUserId() + * @method void setUserId(string $userId) + * @method string getDisplayName() + * @method void setDisplayName(string $displayName) + * @method string getImapLabel() + * @method void setImapLabel(string $imapLabel) + * @method string getColor() + * @method void setColor(string $color) + * @method bool getIsDefaultTag() + * @method void setIsDefaultTag(bool $flag) + */ +class Tag extends Entity implements JsonSerializable { + protected $userId; + protected $displayName; + protected $imapLabel; + protected $color; + protected $isDefaultTag; + + public const LABEL_IMPORTANT = '$label1'; + + public function __construct() { + $this->addType('isDefaultTag', 'boolean'); + } + /** + * @return array + */ + public function jsonSerialize() { + return [ + 'id' => $this->getId(), + 'userId' => $this->getUserId(), + 'displayName' => $this->getDisplayName(), + 'imapLabel' => $this->getImapLabel(), + 'color' => $this->getColor(), + 'isDefaultTag' => $this->getIsDefaultTag(), + ]; + } +} diff --git a/lib/Db/TagMapper.php b/lib/Db/TagMapper.php new file mode 100644 index 000000000..219590223 --- /dev/null +++ b/lib/Db/TagMapper.php @@ -0,0 +1,219 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Anna Larch <anna@nextcloud.com> + * + * @author 2021 Anna Larch <anna@nextcloud.com> + * + * @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 function array_map; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\IL10N; + +/** + * @template-extends QBMapper<Tag> + */ +class TagMapper extends QBMapper { + + /** @var IL10N */ + private $l10n; + + public function __construct(IDBConnection $db, + IL10N $l10n) { + parent::__construct($db, 'mail_tags'); + $this->l10n = $l10n; + } + + /** + * @throws DoesNotExistException + */ + public function getTagByImapLabel(string $imapLabel, string $userId): Entity { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('imap_label', $qb->createNamedParameter($imapLabel)), + $qb->expr()->eq('user_id', $qb->createNamedParameter($userId)) + ); + return $this->findEntity($qb); + } + + /** + * @throws DoesNotExistException + */ + public function getTagForUser(int $id, string $userId): Entity { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)), + $qb->expr()->eq('user_id', $qb->createNamedParameter($userId)) + ); + return $this->findEntity($qb); + } + + /** + * @return Tag[] + * @throws DoesNotExistException + */ + public function getAllTagForUser(string $userId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('user_id', $qb->createNamedParameter($userId)) + ); + return $this->findEntities($qb); + } + + /** + * @throws DoesNotExistException + */ + public function getTag(int $id): Entity { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + + /** + * Tag a message in the DB + * + * To tag (flag) a message on IMAP, @see \OCA\Mail\Service\MailManager::tagMessage + */ + public function tagMessage(Tag $tag, string $messageId, string $userId): void { + /** @var Tag $exists */ + try { + $exists = $this->getTagByImapLabel($tag->getImapLabel(), $userId); + $tag->setId($exists->getId()); + } catch (DoesNotExistException $e) { + $tag = $this->insert($tag); + } + + $qb = $this->db->getQueryBuilder(); + $qb->insert('mail_message_tags'); + $qb->setValue('imap_message_id', $qb->createNamedParameter($messageId)); + $qb->setValue('tag_id', $qb->createNamedParameter($tag->getId(), IQueryBuilder::PARAM_INT)); + $qb->execute(); + } + + /** + * Remove a tag from a DB message + * + * This does not(!) untag a message on IMAP + */ + public function untagMessage(Tag $tag, string $messageId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete('mail_message_tags') + ->where($qb->expr()->eq('imap_message_id', $qb->createNamedParameter($messageId))) + ->where($qb->expr()->eq('tag_id', $qb->createNamedParameter($tag->getId()))); + $qb->execute(); + } + + /** + * @param Message[] $messages + * @return Tag[][] + */ + public function getAllTagsForMessages(array $messages): array { + $ids = array_map(function (Message $message) { + return $message->getMessageId(); + }, $messages); + + $qb = $this->db->getQueryBuilder(); + $idsQuery = $qb->select('mt.*') + ->from('mail_message_tags', 'mt') + ->where( + $qb->expr()->in('imap_message_id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_STR_ARRAY)) + ); + $idsQuery = $idsQuery->execute(); + $queryResult = $idsQuery->fetchAll(); + if (empty($queryResult)) { + return []; + } + $result = []; + foreach ($queryResult as $qr) { + $result[] = $qr['imap_message_id']; + $result[$qr['imap_message_id']][] = $this->getTag((int)$qr['tag_id']); + }; + return $result; + } + + /** + * Create some default system tags + * + * This is designed to be similar to Thunderbird's email tags + * $label1 to $label5 with the according states and colours + * + * <i>The array_udiff can be removed and the insert warpped in + * an exception as soon as NC20 is not supported any more</i> + * + * @link https://github.com/nextcloud/mail/issues/25 + */ + public function createDefaultTags(MailAccount $account): void { + $tags = []; + for ($i = 1; $i < 6; $i++) { + $tag = new Tag(); + $tag->setImapLabel('$label' . $i); + $tag->setUserId($account->getUserId()); + switch ($i) { + case 1: + $tag->setDisplayName($this->l10n->t('Important')); + $tag->setColor('#FF0000'); + $tag->setIsDefaultTag(true); + break; + case 2: + $tag->setDisplayName($this->l10n->t('Work')); + $tag->setColor('#FFC300'); + $tag->setIsDefaultTag(true); + break; + case 3: + $tag->setDisplayName($this->l10n->t('Personal')); + $tag->setColor('#008000'); + $tag->setIsDefaultTag(true); + break; + case 4: + $tag->setDisplayName($this->l10n->t('To Do')); + $tag->setColor('#000080'); + $tag->setIsDefaultTag(true); + break; + case 5: + $tag->setDisplayName($this->l10n->t('Later')); + $tag->setColor('#800080'); + $tag->setIsDefaultTag(true); + break; + } + $tags[] = $tag; + } + $dbTags = $this->getAllTagForUser($account->getUserId()); + $toInsert = array_udiff($tags, $dbTags, function (Tag $a, Tag $b) { + return strcmp($a->getImapLabel(), $b->getImapLabel()); + }); + foreach ($toInsert as $entity) { + $this->insert($entity); + } + } +} diff --git a/lib/Migration/Version1100Date20210304143008.php b/lib/Migration/Version1100Date20210304143008.php new file mode 100644 index 000000000..06c6f6578 --- /dev/null +++ b/lib/Migration/Version1100Date20210304143008.php @@ -0,0 +1,120 @@ +<?php + +declare(strict_types=1); + +namespace OCA\Mail\Migration; + +use Closure; +use OCA\Mail\Db\MailAccountMapper; +use OCA\Mail\Db\TagMapper; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * @link https://github.com/nextcloud/mail/issues/25 + */ +class Version1100Date20210304143008 extends SimpleMigrationStep { + + /** + * @var TagMapper + */ + protected $tagMapper; + + /** + * @var MailAccountMapper + */ + protected $mailAccountMapper; + + public function __construct(TagMapper $tagMapper, MailAccountMapper $mailAccountMapper) { + $this->tagMapper = $tagMapper; + $this->mailAccountMapper = $mailAccountMapper; + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + + if (!$schema->hasTable('mail_tags')) { + $tagsTable = $schema->createTable('mail_tags'); + $tagsTable->addColumn('id', 'integer', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 4, + ]); + $tagsTable->addColumn('user_id', 'string', [ + 'notnull' => true, + 'length' => 64, + ]); + $tagsTable->addColumn('imap_label', 'string', [ + 'notnull' => true, + 'length' => 64, + ]); + $tagsTable->addColumn('display_name', 'string', [ + 'notnull' => true, + 'length' => 128, + ]); + $tagsTable->addColumn('color', 'string', [ + 'notnull' => false, + 'length' => 9, + 'default' => "#fff" + ]); + $tagsTable->addColumn('is_default_tag', 'boolean', [ + 'notnull' => false, + 'default' => false + ]); + $tagsTable->setPrimaryKey(['id']); + $tagsTable->addIndex(['user_id'], 'mail_msg_tags_usr_id_index'); + $tagsTable->addUniqueIndex( + [ + 'user_id', + 'imap_label', + ], + 'mail_msg_tags_usr_lbl_idx' + ); + } + + if (!$schema->hasTable('mail_message_tags')) { + $tagsMessageTable = $schema->createTable('mail_message_tags'); + $tagsMessageTable->addColumn('id', 'integer', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 4, + ]); + $tagsMessageTable->addColumn('imap_message_id', 'string', [ + 'notnull' => true, + 'length' => 1023, + ]); + $tagsMessageTable->addColumn('tag_id', 'integer', [ + 'notnull' => true, + 'length' => 4, + ]); + $tagsMessageTable->setPrimaryKey(['id']); + $tagsMessageTable->addUniqueIndex( + [ + 'imap_message_id', + 'tag_id', + ], + 'mail_msg_tag_id_idx' + ); + } + return $schema; + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $accounts = $this->mailAccountMapper->getAllUserIdsWithAccounts(); + foreach ($accounts as $account) { + $this->tagMapper->createDefaultTags($account); + } + } +} diff --git a/lib/Model/IMAPMessage.php b/lib/Model/IMAPMessage.php index 46005a0c3..24743ef44 100644 --- a/lib/Model/IMAPMessage.php +++ b/lib/Model/IMAPMessage.php @@ -29,29 +29,32 @@ declare(strict_types=1); namespace OCA\Mail\Model; +use OC; use Exception; -use Horde_Imap_Client; -use Horde_Imap_Client_Data_Envelope; -use Horde_Imap_Client_Data_Fetch; -use Horde_Imap_Client_DateTime; -use Horde_Imap_Client_Fetch_Query; -use Horde_Imap_Client_Ids; -use Horde_Imap_Client_Mailbox; -use Horde_Imap_Client_Socket; -use Horde_Mime_Headers; -use Horde_Mime_Headers_MessageId; +use function trim; +use OCP\Files\File; use Horde_Mime_Part; +use OCA\Mail\Db\Tag; use JsonSerializable; -use OC; +use function in_array; +use Horde_Imap_Client; +use Horde_Mime_Headers; +use OCA\Mail\Db\Message; use OCA\Mail\AddressList; -use OCA\Mail\Db\LocalAttachment; +use Horde_Imap_Client_Ids; use OCA\Mail\Service\Html; -use OCP\AppFramework\Db\DoesNotExistException; -use OCP\Files\File; -use OCP\Files\SimpleFS\ISimpleFile; -use function in_array; +use OCA\Mail\Db\MailAccount; +use Horde_Imap_Client_Socket; +use Horde_Imap_Client_Mailbox; +use Horde_Imap_Client_DateTime; +use OCA\Mail\Db\LocalAttachment; use function mb_convert_encoding; -use function trim; +use Horde_Imap_Client_Data_Fetch; +use Horde_Mime_Headers_MessageId; +use Horde_Imap_Client_Fetch_Query; +use OCP\Files\SimpleFS\ISimpleFile; +use Horde_Imap_Client_Data_Envelope; +use OCP\AppFramework\Db\DoesNotExistException; class IMAPMessage implements IMessage, JsonSerializable { use ConvertAddresses; @@ -149,7 +152,7 @@ class IMAPMessage implements IMessage, JsonSerializable { 'forwarded' => in_array(Horde_Imap_Client::FLAG_FORWARDED, $flags), 'hasAttachments' => $this->hasAttachments($this->fetch->getStructure()), 'mdnsent' => in_array(Horde_Imap_Client::FLAG_MDNSENT, $flags, true), - 'important' => in_array('$important', $flags, true) + 'important' => in_array(Tag::LABEL_IMPORTANT, $flags, true) ]; } @@ -673,8 +676,14 @@ class IMAPMessage implements IMessage, JsonSerializable { throw new Exception('not implemented'); } - public function toDbMessage(int $mailboxId): \OCA\Mail\Db\Message { - $msg = new \OCA\Mail\Db\Message(); + /** + * Cast all values from an IMAP message into the correct DB format + * + * @param integer $mailboxId + * @return Message + */ + public function toDbMessage(int $mailboxId, MailAccount $account): Message { + $msg = new Message(); $messageId = $this->getMessageId(); if (empty(trim($messageId))) { @@ -707,10 +716,58 @@ class IMAPMessage implements IMessage, JsonSerializable { in_array('junk', $flags, true) ); $msg->setFlagNotjunk(in_array(Horde_Imap_Client::FLAG_NOTJUNK, $flags, true)); - $msg->setFlagImportant(in_array('$important', $flags, true)); + // @todo remove this as soon as possible @link https://github.com/nextcloud/mail/issues/25 + $msg->setFlagImportant(in_array('$important', $flags, true) || in_array(Tag::LABEL_IMPORTANT, $flags, true)); $msg->setFlagAttachments(false); $msg->setFlagMdnsent(in_array(Horde_Imap_Client::FLAG_MDNSENT, $flags, true)); + $allowed = [ + Horde_Imap_Client::FLAG_SEEN, + Horde_Imap_Client::FLAG_ANSWERED, + Horde_Imap_Client::FLAG_FLAGGED, + Horde_Imap_Client::FLAG_DELETED, + Horde_Imap_Client::FLAG_DRAFT, + Horde_Imap_Client::FLAG_RECENT, + Horde_Imap_Client::FLAG_JUNK, + Horde_Imap_Client::FLAG_MDNSENT, + ]; + // remove all standard IMAP flags from $filters + $tags = array_filter($flags, function ($flag) use ($allowed) { + return in_array($flag, $allowed, true) === false; + }); + + if (empty($tags) === true) { + return $msg; + } + // cast all leftover $flags to be used as tags + $msg->setTags($this->generateTagEntites($tags, $account->getUserId())); return $msg; } + + /** + * Build tag entities from keywords sent by IMAP + * + * Will use IMAP keyword '$xxx' to create a value for + * display_name like 'xxx' + * + * @link https://github.com/nextcloud/mail/issues/25 + * + * @param string[] $tags + * @return Tag[] + */ + private function generateTagEntites(array $tags, string $userId): array { + $t = []; + foreach ($tags as $keyword) { + // Map the old $important to $label1 until we have caught them all + if ($keyword === '$important') { + $keyword = Tag::LABEL_IMPORTANT; + } + $tag = new Tag(); + $tag->setImapLabel($keyword); + $tag->setDisplayName(str_replace('$', '', $keyword)); + $tag->setUserId($userId); + $t[] = $tag; + } + return $t; + } } diff --git a/lib/Service/MailManager.php b/lib/Service/MailManager.php index d37278477..782946e6a 100644 --- a/lib/Service/MailManager.php +++ b/lib/Service/MailManager.php @@ -23,34 +23,36 @@ declare(strict_types=1); namespace OCA\Mail\Service; -use Horde_Imap_Client; -use Horde_Imap_Client_Exception; -use Horde_Imap_Client_Exception_NoSupportExtension; -use Horde_Imap_Client_Socket; +use OCA\Mail\Db\Tag; +use OCA\Mail\Folder; use OCA\Mail\Account; -use OCA\Mail\Contracts\IMailManager; +use Horde_Imap_Client; +use function array_map; use OCA\Mail\Db\Mailbox; -use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; -use OCA\Mail\Db\MessageMapper as DbMessageMapper; -use OCA\Mail\Events\BeforeMessageDeletedEvent; +use function array_values; +use OCA\Mail\Db\TagMapper; +use Psr\Log\LoggerInterface; +use Horde_Imap_Client_Socket; +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\IMAP\FolderStats; +use OCA\Mail\IMAP\MailboxSync; +use OCA\Mail\IMAP\FolderMapper; +use OCA\Mail\Model\IMAPMessage; +use Horde_Imap_Client_Exception; +use OCA\Mail\Contracts\IMailManager; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Exception\ClientException; use OCA\Mail\Events\MessageDeletedEvent; use OCA\Mail\Events\MessageFlaggedEvent; -use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; +use OCP\EventDispatcher\IEventDispatcher; +use OCA\Mail\Events\BeforeMessageDeletedEvent; +use OCP\AppFramework\Db\DoesNotExistException; +use OCA\Mail\Db\MessageMapper as DbMessageMapper; +use Horde_Imap_Client_Exception_NoSupportExtension; use OCA\Mail\Exception\TrashMailboxNotSetException; -use OCA\Mail\Folder; -use OCA\Mail\IMAP\FolderMapper; -use OCA\Mail\IMAP\FolderStats; -use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\IMAP\MailboxSync; use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; -use OCA\Mail\Model\IMAPMessage; -use OCP\AppFramework\Db\DoesNotExistException; -use OCP\EventDispatcher\IEventDispatcher; -use Psr\Log\LoggerInterface; -use function array_map; -use function array_values; class MailManager implements IMailManager { @@ -86,8 +88,12 @@ class MailManager implements IMailManager { /** @var DbMessageMapper */ private $dbMessageMapper; + /** @var TagMapper */ + private $tagMapper; + /** @var IEventDispatcher */ private $eventDispatcher; + /** * @var LoggerInterface */ @@ -100,7 +106,8 @@ class MailManager implements IMailManager { ImapMessageMapper $messageMapper, DbMessageMapper $dbMessageMapper, IEventDispatcher $eventDispatcher, - LoggerInterface $logger) { + LoggerInterface $logger, + TagMapper $tagMapper) { $this->imapClientFactory = $imapClientFactory; $this->mailboxMapper = $mailboxMapper; $this->mailboxSync = $mailboxSync; @@ -109,6 +116,7 @@ class MailManager implements IMailManager { $this->dbMessageMapper = $dbMessageMapper; $this->eventDispatcher = $eventDispatcher; $this->logger = $logger; + $this->tagMapper = $tagMapper; } public function getMailbox(string $uid, int $id): Mailbox { @@ -393,7 +401,7 @@ class MailManager implements IMailManager { if (empty($imapFlag) === true) { continue; } - if ($value === true) { + if ($value) { $this->imapMessageMapper->addFlag($client, $mb, $uid, $imapFlag); } else { $this->imapMessageMapper->removeFlag($client, $mb, $uid, $imapFlag); @@ -420,6 +428,50 @@ class MailManager implements IMailManager { } /** + * Tag (flag) a message on IMAP + * + * @param Account $account + * @param string $mailbox + * @param integer $uid + * @param Tag $tag + * @param boolean $value + * @return void + * + * @uses + * + * @link https://github.com/nextcloud/mail/issues/25 + */ + public function tagMessage(Account $account, string $mailbox, Message $message, Tag $tag, bool $value): void { + $client = $this->imapClientFactory->getClient($account); + try { + $mb = $this->mailboxMapper->find($account, $mailbox); + } catch (DoesNotExistException $e) { + throw new ClientException("Mailbox $mailbox does not exist", 0, $e); + } + if ($this->isPermflagsEnabled($account, $mailbox) === true) { + try { + if ($value) { + // imap keywords and flags work the same way + $this->imapMessageMapper->addFlag($client, $mb, $message->getUid(), $tag->getImapLabel()); + } else { + $this->imapMessageMapper->removeFlag($client, $mb, $message->getUid(), $tag->getImapLabel()); + } + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException( + "Could not set message keyword on IMAP: " . $e->getMessage(), + (int) $e->getCode(), + $e + ); + } + } + if ($value) { + $this->tagMapper->tagMessage($tag, $message->getMessageId(), $account->getUserId()); + } else { + $this->tagMapper->untagMessage($tag, $message->getMessageId()); + } + } + + /** * @param Account $account * * @return Quota|null @@ -518,6 +570,21 @@ class MailManager implements IMailManager { } /** + * @param string $imapLabel + * @param string $userId + * @return Tag + * @throws DoesNotExistException + */ + public function getTagByImapLabel(string $imapLabel, string $userId): Tag { + try { + return $this->tagMapper->getTagByImapLabel($imapLabel, $userId); + } catch (DoesNotExistException $e) { + throw new ClientException('Unknow Tag', (int)$e->getCode(), $e); + } + } + + + /** * Filter out IMAP flags that aren't supported by the client server * * @param Horde_Imap_Client_Socket $client @@ -534,7 +601,7 @@ class MailManager implements IMailManager { // Only allow flag setting if IMAP supports Permaflags // @TODO check if there are length & char limits on permflags if ($this->isPermflagsEnabled($account, $mailbox) === true) { - return ["$" . $flag]; + return [$flag]; } return []; } diff --git a/lib/Service/Search/Flag.php b/lib/Service/Search/Flag.php index 79294813a..491a63aca 100644 --- a/lib/Service/Search/Flag.php +++ b/lib/Service/Search/Flag.php @@ -26,6 +26,7 @@ declare(strict_types=1); namespace OCA\Mail\Service\Search; use Horde_Imap_Client; +use OCA\Mail\Db\Tag; /** * @psalm-immutable @@ -34,7 +35,8 @@ class Flag { public const ANSWERED = Horde_Imap_Client::FLAG_ANSWERED; public const SEEN = Horde_Imap_Client::FLAG_SEEN; public const FLAGGED = Horde_Imap_Client::FLAG_FLAGGED; - public const IMPORTANT = '\\important'; + /** @deprecated */ + public const IMPORTANT = Tag::LABEL_IMPORTANT; public const DELETED = Horde_Imap_Client::FLAG_DELETED; /** @var string */ diff --git a/lib/Service/SetupService.php b/lib/Service/SetupService.php index ffd6146ac..78b423425 100644 --- a/lib/Service/SetupService.php +++ b/lib/Service/SetupService.php @@ -31,6 +31,7 @@ use Horde_Mail_Exception; use Horde_Mail_Transport_Smtphorde; use OCA\Mail\Account; use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\TagMapper; use OCA\Mail\Exception\CouldNotConnectException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\IMAP\IMAPClientFactory; @@ -56,21 +57,26 @@ class SetupService { /** @var IMAPClientFactory */ private $imapClientFactory; - /** var LoggerInterface */ + /** @var LoggerInterface */ private $logger; + /** @var TagMapper */ + private $tagMapper; + public function __construct(AutoConfig $autoConfig, AccountService $accountService, ICrypto $crypto, SmtpClientFactory $smtpClientFactory, IMAPClientFactory $imapClientFactory, - LoggerInterface $logger) { + LoggerInterface $logger, + TagMapper $tagMapper) { $this->autoConfig = $autoConfig; $this->accountService = $accountService; $this->crypto = $crypto; $this->smtpClientFactory = $smtpClientFactory; $this->imapClientFactory = $imapClientFactory; $this->logger = $logger; + $this->tagMapper = $tagMapper; } /** @@ -78,6 +84,8 @@ class SetupService { * @param string $emailAddress * @param string $password * @return Account|null + * + * @link https://github.com/nextcloud/mail/issues/25 */ public function createNewAutoConfiguredAccount($accountName, $emailAddress, $password) { $this->logger->info('setting up auto detected account'); @@ -88,6 +96,8 @@ class SetupService { $this->accountService->save($mailAccount); + $this->tagMapper->createDefaultTags($mailAccount); + return new Account($mailAccount); } @@ -139,6 +149,8 @@ class SetupService { $this->accountService->save($newAccount); $this->logger->debug("account created " . $newAccount->getId()); + $this->tagMapper->createDefaultTags($newAccount); + return $account; } diff --git a/lib/Service/Sync/ImapToDbSynchronizer.php b/lib/Service/Sync/ImapToDbSynchronizer.php index 3132aad06..2adba1ee2 100644 --- a/lib/Service/Sync/ImapToDbSynchronizer.php +++ b/lib/Service/Sync/ImapToDbSynchronizer.php @@ -292,8 +292,8 @@ class ImapToDbSynchronizer { } foreach (array_chunk($imapMessages['messages'], 500) as $chunk) { - $this->dbMapper->insertBulk(...array_map(function (IMAPMessage $imapMessage) use ($mailbox) { - return $imapMessage->toDbMessage($mailbox->getId()); + $this->dbMapper->insertBulk($account, ...array_map(function (IMAPMessage $imapMessage) use ($mailbox, $account) { + return $imapMessage->toDbMessage($mailbox->getId(), $account->getMailAccount()); }, $chunk)); } $perf->step('persist messages in database'); @@ -349,8 +349,8 @@ class ImapToDbSynchronizer { $perf->step('get new messages via Horde'); foreach (array_chunk($response->getNewMessages(), 500) as $chunk) { - $dbMessages = array_map(function (IMAPMessage $imapMessage) use ($mailbox) { - return $imapMessage->toDbMessage($mailbox->getId()); + $dbMessages = array_map(function (IMAPMessage $imapMessage) use ($mailbox, $account) { + return $imapMessage->toDbMessage($mailbox->getId(), $account->getMailAccount()); }, $chunk); $this->dispatcher->dispatch( @@ -359,7 +359,7 @@ class ImapToDbSynchronizer { ); $perf->step('classified a chunk of new messages'); - $this->dbMapper->insertBulk(...$dbMessages); + $this->dbMapper->insertBulk($account, ...$dbMessages); } $perf->step('persist new messages'); @@ -378,8 +378,8 @@ class ImapToDbSynchronizer { $perf->step('get changed messages via Horde'); foreach (array_chunk($response->getChangedMessages(), 500) as $chunk) { - $this->dbMapper->updateBulk(...array_map(function (IMAPMessage $imapMessage) use ($mailbox) { - return $imapMessage->toDbMessage($mailbox->getId()); + $this->dbMapper->updateBulk($account, ...array_map(function (IMAPMessage $imapMessage) use ($mailbox, $account) { + return $imapMessage->toDbMessage($mailbox->getId(), $account->getMailAccount()); }, $chunk)); } $perf->step('persist changed messages'); diff --git a/src/components/Envelope.vue b/src/components/Envelope.vue index 5b19a4ea7..228518c77 100644 --- a/src/components/Envelope.vue +++ b/src/components/Envelope.vue @@ -21,9 +21,9 @@ :data-starred="data.flags.flagged ? 'true' : 'false'" @click.prevent="onToggleFlagged" /> <div - v-if="data.flags.important" + v-if="data.tags.$label1" class="app-content-list-item-star icon-important" - :data-starred="data.flags.important ? 'true' : 'false'" + :data-starred="data.tags.$label1 ? 'true' : 'false'" @click.prevent="onToggleImportant" v-html="importantSvg" /> <div diff --git a/src/components/MenuEnvelope.vue b/src/components/MenuEnvelope.vue index 3a182fee9..b9c0f4a67 100644 --- a/src/components/MenuEnvelope.vue +++ b/src/components/MenuEnvelope.vue @@ -40,7 +40,7 @@ :close-after-click="true" @click.prevent="onToggleImportant"> {{ - envelope.flags.important ? t('mail', 'Mark unimportant') : t('mail', 'Mark important') + envelope.tags.$label1 ? t('mail', 'Mark unimportant') : t('mail', 'Mark important') }} </ActionButton> <ActionButton :icon="iconFavorite" diff --git a/src/components/ThreadEnvelope.vue b/src/components/ThreadEnvelope.vue index 9a49fd299..1c5de9992 100644 --- a/src/components/ThreadEnvelope.vue +++ b/src/components/ThreadEnvelope.vue @@ -28,9 +28,9 @@ :disable-tooltip="true" :size="40" /> <div - v-if="envelope.flags.important" + v-if="envelope.tags.$label1" class="app-content-list-item-star icon-important" - :data-starred="envelope.flags.important ? 'true' : 'false'" + :data-starred="envelope.tags.$label1 ? 'true' : 'false'" @click.prevent="onToggleImportant" v-html="importantSvg" /> <div diff --git a/src/service/MessageService.js b/src/service/MessageService.js index a1b49138d..c24402571 100644 --- a/src/service/MessageService.js +++ b/src/service/MessageService.js @@ -116,6 +116,24 @@ export function setEnvelopeFlag(id, flag, value) { }) } +export function setEnvelopeTag(id, tagId) { + const url = generateUrl('/apps/mail/api/messages/{id}/tags/{tagId}', { + id, tagId, + }) + + return axios + .put(url) +} + +export function removeEnvelopeTag(id, tagId) { + const url = generateUrl('/apps/mail/api/messages/{id}/tags/{tagId}', { + id, tagId, + }) + + return axios + .delete(url) +} + export async function fetchMessage(id) { const url = generateUrl('/apps/mail/api/messages/{id}/body', { id, diff --git a/src/store/actions.js b/src/store/actions.js index dcdba38a5..4ae91144d 100644 --- a/src/store/actions.js +++ b/src/store/actions.js @@ -595,20 +595,20 @@ export default { }, toggleEnvelopeImportant({ commit, getters }, envelope) { // Change immediately and switch back on error - const oldState = envelope.flags.important - commit('flagEnvelope', { + const oldState = envelope.tags.$label1 + commit('tagEnvelope', { envelope, - flag: 'important', + tag: '$label1', value: !oldState, }) - setEnvelopeFlag(envelope.databaseId, 'important', !oldState).catch((e) => { + setEnvelopeFlag(envelope.databaseId, '$label1', !oldState).catch((e) => { console.error('could not toggle message important state', e) // Revert change - commit('flagEnvelope', { + commit('tagEnvelope', { envelope, - flag: 'important', + tag: '$label1', value: oldState, }) }) diff --git a/src/store/mutations.js b/src/store/mutations.js index 4bfa5b4eb..b0c13e02b 100644 --- a/src/store/mutations.js +++ b/src/store/mutations.js @@ -173,10 +173,14 @@ export default { return } Vue.set(existing, 'flags', envelope.flags) + Vue.set(existing, 'tags', envelope.tags) }, flagEnvelope(state, { envelope, flag, value }) { envelope.flags[flag] = value }, + tagEnvelope(state, { envelope, tag, value }) { + envelope.tags[tag] = value + }, removeEnvelope(state, { id }) { const envelope = state.envelopes[id] if (!envelope) { diff --git a/tests/Unit/Controller/MessagesControllerTest.php b/tests/Unit/Controller/MessagesControllerTest.php index b795948b1..8943aa331 100644 --- a/tests/Unit/Controller/MessagesControllerTest.php +++ b/tests/Unit/Controller/MessagesControllerTest.php @@ -24,40 +24,41 @@ declare(strict_types=1); namespace OCA\Mail\Tests\Unit\Controller; -use ChristophWurst\Nextcloud\Testing\TestCase; -use OC\AppFramework\Http\Request; -use OC\Security\CSP\ContentSecurityPolicyNonceManager; +use OCP\IL10N; +use OCP\IRequest; +use OCA\Mail\Db\Tag; use OCA\Mail\Account; +use OCA\Mail\Mailbox; +use OCP\Files\Folder; +use ReflectionObject; +use OCP\IURLGenerator; use OCA\Mail\Attachment; -use OCA\Mail\Contracts\IMailManager; -use OCA\Mail\Contracts\IMailSearch; -use OCA\Mail\Contracts\IMailTransmission; -use OCA\Mail\Contracts\ITrustedSenderService; -use OCA\Mail\Controller\MessagesController; -use OCA\Mail\Exception\ClientException; -use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Http\AttachmentDownloadResponse; +use OCP\AppFramework\Http; +use OCA\Mail\Model\Message; +use Psr\Log\LoggerInterface; use OCA\Mail\Http\HtmlResponse; -use OCA\Mail\Mailbox; use OCA\Mail\Model\IMAPMessage; -use OCA\Mail\Model\Message; +use OCP\Files\IMimeTypeDetector; +use OC\AppFramework\Http\Request; +use OCA\Mail\Service\MailManager; +use OCA\Mail\Contracts\IMailSearch; +use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\ItineraryService; -use OCA\Mail\Service\MailManager; -use OCP\AppFramework\Db\DoesNotExistException; -use OCP\AppFramework\Http; -use OCP\AppFramework\Http\ContentSecurityPolicy; -use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\ZipResponse; +use OCA\Mail\Exception\ClientException; +use OCP\AppFramework\Http\JSONResponse; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Contracts\IMailTransmission; use OCP\AppFramework\Utility\ITimeFactory; -use OCP\Files\Folder; -use OCP\Files\IMimeTypeDetector; -use OCP\IL10N; -use OCP\IRequest; -use OCP\IURLGenerator; +use OCA\Mail\Controller\MessagesController; use PHPUnit\Framework\MockObject\MockObject; -use Psr\Log\LoggerInterface; -use ReflectionObject; +use OCA\Mail\Contracts\ITrustedSenderService; +use OCA\Mail\Http\AttachmentDownloadResponse; +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OC\Security\CSP\ContentSecurityPolicyNonceManager; class MessagesControllerTest extends TestCase { @@ -647,6 +648,208 @@ class MessagesControllerTest extends TestCase { $this->assertEquals($expected, $response); } + public function testSetTagFailing() { + $accountId = 17; + $mailboxId = 987; + $id = 1; + $message = new \OCA\Mail\Db\Message(); + $message->setUid(444); + $message->setMailboxId($mailboxId); + $message->setMessageId('<jhfjkhdsjkfhdsjkhfjkdsh@test.com>'); + $mailbox = new \OCA\Mail\Db\Mailbox(); + $mailbox->setName('INBOX'); + $mailbox->setAccountId($accountId); + $this->mailManager->expects($this->once()) + ->method('getMessage') + ->with($this->userId, $id) + ->willReturn($message); + $this->mailManager->expects($this->once()) + ->method('getMailbox') + ->with($this->userId, $mailboxId) + ->willReturn($mailbox); + $this->accountService->expects($this->once()) + ->method('find') + ->with($this->equalTo($this->userId), $this->equalTo($accountId)) + ->willThrowException(new DoesNotExistException('')); + $this->mailManager->expects($this->never()) + ->method('getTagByImapLabel'); + $this->mailManager->expects($this->never()) + ->method('tagMessage'); + + $this->controller->setTag($id, Tag::LABEL_IMPORTANT); + } + + public function testSetTagNotFound() { + $accountId = 17; + $mailboxId = 987; + $id = 1; + $imapLabel = '$label6'; + $message = new \OCA\Mail\Db\Message(); + $message->setUid(444); + $message->setMailboxId($mailboxId); + $message->setMessageId('<jhfjkhdsjkfhdsjkhfjkdsh@test.com>'); + $mailbox = new \OCA\Mail\Db\Mailbox(); + $mailbox->setName('INBOX'); + $mailbox->setAccountId($accountId); + $this->mailManager->expects($this->once()) + ->method('getMessage') + ->with($this->userId, $id) + ->willReturn($message); + $this->mailManager->expects($this->once()) + ->method('getMailbox') + ->with($this->userId, $mailboxId) + ->willReturn($mailbox); + $this->accountService->expects($this->once()) + ->method('find') + ->with($this->equalTo($this->userId), $this->equalTo($accountId)) + ->will($this->returnValue($this->account)); + $this->mailManager->expects($this->once()) + ->method('getTagByImapLabel') + ->with($imapLabel,$this->userId) + ->willThrowException(new DoesNotExistException('')); + $this->mailManager->expects($this->never()) + ->method('tagMessage'); + + $this->controller->setTag($id, $imapLabel); + } + + public function testSetTag() { + $accountId = 17; + $mailboxId = 987; + $id = 1; + $tag = new Tag(); + $tag->setImapLabel(Tag::LABEL_IMPORTANT); + $message = new \OCA\Mail\Db\Message(); + $message->setUid(444); + $message->setMailboxId($mailboxId); + $message->setMessageId('<jhfjkhdsjkfhdsjkhfjkdsh@test.com>'); + $mailbox = new \OCA\Mail\Db\Mailbox(); + $mailbox->setName('INBOX'); + $mailbox->setAccountId($accountId); + $this->mailManager->expects($this->once()) + ->method('getMessage') + ->with($this->userId, $id) + ->willReturn($message); + $this->mailManager->expects($this->once()) + ->method('getMailbox') + ->with($this->userId, $mailboxId) + ->willReturn($mailbox); + $this->accountService->expects($this->once()) + ->method('find') + ->with($this->equalTo($this->userId), $this->equalTo($accountId)) + ->will($this->returnValue($this->account)); + $this->mailManager->expects($this->once()) + ->method('getTagByImapLabel') + ->with($tag->getImapLabel(),$this->userId) + ->willReturn($tag); + $this->mailManager->expects($this->once()) + ->method('tagMessage') + ->with($this->account, $mailbox->getName(), $message, $tag, true); + + $this->controller->setTag($id, $tag->getImapLabel()); + } + + public function testRemoveTagFailing() { + $accountId = 17; + $mailboxId = 987; + $id = 1; + $message = new \OCA\Mail\Db\Message(); + $message->setUid(444); + $message->setMailboxId($mailboxId); + $message->setMessageId('<jhfjkhdsjkfhdsjkhfjkdsh@test.com>'); + $mailbox = new \OCA\Mail\Db\Mailbox(); + $mailbox->setName('INBOX'); + $mailbox->setAccountId($accountId); + $this->mailManager->expects($this->once()) + ->method('getMessage') + ->with($this->userId, $id) + ->willReturn($message); + $this->mailManager->expects($this->once()) + ->method('getMailbox') + ->with($this->userId, $mailboxId) + ->willReturn($mailbox); + $this->accountService->expects($this->once()) + ->method('find') + ->with($this->equalTo($this->userId), $this->equalTo($accountId)) + ->willThrowException(new DoesNotExistException('')); + $this->mailManager->expects($this->never()) + ->method('getTagByImapLabel'); + $this->mailManager->expects($this->never()) + ->method('tagMessage'); + + $this->controller->removeTag($id, Tag::LABEL_IMPORTANT); + } + + public function testRemoveTagNotFound() { + $accountId = 17; + $mailboxId = 987; + $id = 1; + $imapLabel = '$label6'; + $message = new \OCA\Mail\Db\Message(); + $message->setUid(444); + $message->setMailboxId($mailboxId); + $message->setMessageId('<jhfjkhdsjkfhdsjkhfjkdsh@test.com>'); + $mailbox = new \OCA\Mail\Db\Mailbox(); + $mailbox->setName('INBOX'); + $mailbox->setAccountId($accountId); + $this->mailManager->expects($this->once()) + ->method('getMessage') + ->with($this->userId, $id) + ->willReturn($message); + $this->mailManager->expects($this->once()) + ->method('getMailbox') + ->with($this->userId, $mailboxId) + ->willReturn($mailbox); + $this->accountService->expects($this->once()) + ->method('find') + ->with($this->equalTo($this->userId), $this->equalTo($accountId)) + ->will($this->returnValue($this->account)); + $this->mailManager->expects($this->once()) + ->method('getTagByImapLabel') + ->with($imapLabel,$this->userId) + ->willThrowException(new DoesNotExistException('')); + $this->mailManager->expects($this->never()) + ->method('tagMessage'); + + $this->controller->removeTag($id, $imapLabel); + } + + public function testRemoveTag() { + $accountId = 17; + $mailboxId = 987; + $id = 1; + $tag = new Tag(); + $tag->setImapLabel(Tag::LABEL_IMPORTANT); + $message = new \OCA\Mail\Db\Message(); + $message->setUid(444); + $message->setMailboxId($mailboxId); + $message->setMessageId('<jhfjkhdsjkfhdsjkhfjkdsh@test.com>'); + $mailbox = new \OCA\Mail\Db\Mailbox(); + $mailbox->setName('INBOX'); + $mailbox->setAccountId($accountId); + $this->mailManager->expects($this->once()) + ->method('getMessage') + ->with($this->userId, $id) + ->willReturn($message); + $this->mailManager->expects($this->once()) + ->method('getMailbox') + ->with($this->userId, $mailboxId) + ->willReturn($mailbox); + $this->accountService->expects($this->once()) + ->method('find') + ->with($this->equalTo($this->userId), $this->equalTo($accountId)) + ->will($this->returnValue($this->account)); + $this->mailManager->expects($this->once()) + ->method('getTagByImapLabel') + ->with($tag->getImapLabel(),$this->userId) + ->willReturn($tag); + $this->mailManager->expects($this->once()) + ->method('tagMessage') + ->with($this->account, $mailbox->getName(), $message, $tag, false); + + $this->controller->removeTag($id, $tag->getImapLabel()); + } + public function testSetFlagsFlagged() { $accountId = 17; $mailboxId = 987; diff --git a/tests/Unit/Listener/MessageCacheUpdaterListenerTest.php b/tests/Unit/Listener/MessageCacheUpdaterListenerTest.php index 5fac913cf..3981b3744 100644 --- a/tests/Unit/Listener/MessageCacheUpdaterListenerTest.php +++ b/tests/Unit/Listener/MessageCacheUpdaterListenerTest.php @@ -30,6 +30,7 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; +use OCA\Mail\Db\Tag; use OCA\Mail\Events\MessageFlaggedEvent; use OCA\Mail\Listener\MessageCacheUpdaterListener; use OCP\EventDispatcher\Event; @@ -65,7 +66,7 @@ class MessageCacheUpdaterListenerTest extends TestCase { $account, $mailbox, 123, - 'important', + Tag::LABEL_IMPORTANT, true ); $this->serviceMock->getParameter('mapper') diff --git a/tests/Unit/Service/MailManagerTest.php b/tests/Unit/Service/MailManagerTest.php index d46e96cbf..8406205dc 100644 --- a/tests/Unit/Service/MailManagerTest.php +++ b/tests/Unit/Service/MailManagerTest.php @@ -31,6 +31,8 @@ use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; use OCA\Mail\Db\MessageMapper as DbMessageMapper; +use OCA\Mail\Db\Tag; +use OCA\Mail\Db\TagMapper; use OCA\Mail\Events\BeforeMessageDeletedEvent; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Folder; @@ -74,6 +76,9 @@ class MailManagerTest extends TestCase { /** @var MockObject|LoggerInterface */ private $logger; + /** @var MockObject|TagMapper */ + private $tagMapper; + protected function setUp(): void { parent::setUp(); @@ -85,6 +90,7 @@ class MailManagerTest extends TestCase { $this->mailboxSync = $this->createMock(MailboxSync::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->tagMapper = $this->createMock(TagMapper::class); $this->manager = new MailManager( $this->imapClientFactory, @@ -94,7 +100,8 @@ class MailManagerTest extends TestCase { $this->imapMessageMapper, $this->dbMessageMapper, $this->eventDispatcher, - $this->logger + $this->logger, + $this->tagMapper ); } @@ -310,8 +317,8 @@ class MailManagerTest extends TestCase { $this->imapMessageMapper->expects($this->never()) ->method('removeFlag'); - $this->manager->flagMessage($account, 'INBOX', 123, 'important', true); - $this->manager->flagMessage($account, 'INBOX', 123, 'important', false); + $this->manager->flagMessage($account, 'INBOX', 123, Tag::LABEL_IMPORTANT, true); + $this->manager->flagMessage($account, 'INBOX', 123, Tag::LABEL_IMPORTANT, false); } public function testSetCustomFlagWithIMAPCapabilities(): void { @@ -327,7 +334,7 @@ class MailManagerTest extends TestCase { $this->imapMessageMapper->expects($this->once()) ->method('addFlag'); - $this->manager->flagMessage($account, 'INBOX', 123, 'important', true); + $this->manager->flagMessage($account, 'INBOX', 123, Tag::LABEL_IMPORTANT, true); } public function testUnsetCustomFlagWithIMAPCapabilities(): void { @@ -343,7 +350,7 @@ class MailManagerTest extends TestCase { $this->imapMessageMapper->expects($this->once()) ->method('removeFlag'); - $this->manager->flagMessage($account, 'INBOX', 123, 'important', false); + $this->manager->flagMessage($account, 'INBOX', 123, Tag::LABEL_IMPORTANT, false); } public function testFilterFlagStandard(): void { @@ -378,7 +385,7 @@ class MailManagerTest extends TestCase { ->method('getClient') ->willReturn($client); - $this->assertEquals([], $this->manager->filterFlags($account, '$important' , 'INBOX')); + $this->assertEquals([], $this->manager->filterFlags($account, Tag::LABEL_IMPORTANT , 'INBOX')); } public function testSetFilterFlagsImportant() { @@ -392,7 +399,7 @@ class MailManagerTest extends TestCase { ->method('status') ->willReturn(['permflags' => [ "11" => "\*" ]]); - $this->assertEquals(['$important'], $this->manager->filterFlags($account, 'important' , 'INBOX')); + $this->assertEquals([Tag::LABEL_IMPORTANT], $this->manager->filterFlags($account, Tag::LABEL_IMPORTANT , 'INBOX')); } public function testIsPermflagsEnabledTrue(): void { @@ -443,6 +450,94 @@ class MailManagerTest extends TestCase { $this->manager->flagMessage($account, 'INBOX', 123, 'seen', false); } + public function testTagMessage(): void { + $client = $this->createMock(Horde_Imap_Client_Socket::class); + $account = $this->createMock(Account::class); + $tag = new Tag(); + $tag->setImapLabel(Tag::LABEL_IMPORTANT); + $message = new \OCA\Mail\Db\Message(); + $message->setUid(123); + $message->setMessageId('<jhfjkhdsjkfhdsjkhfjkdsh@test.com>'); + $this->imapClientFactory->expects($this->any()) + ->method('getClient') + ->willReturn($client); + $mb = $this->createMock(Mailbox::class); + $this->mailboxMapper->expects($this->once()) + ->method('find') + ->with($account, 'INBOX') + ->willReturn($mb); + $client->expects($this->once()) + ->method('status') + ->willReturn(['permflags' => [ "11" => "\*"] ]); + $this->imapMessageMapper->expects($this->once()) + ->method('addFlag') + ->with($client, $mb, 123, Tag::LABEL_IMPORTANT); + $account->expects($this->once()) + ->method('getUserId') + ->willReturn('test'); + $this->manager->tagMessage($account, 'INBOX', $message, $tag, true); + } + + public function testUntagMessage(): void { + $client = $this->createMock(Horde_Imap_Client_Socket::class); + $account = $this->createMock(Account::class); + $tag = new Tag(); + $tag->setImapLabel(Tag::LABEL_IMPORTANT); + $message = new \OCA\Mail\Db\Message(); + $message->setUid(123); + $message->setMessageId('<jhfjkhdsjkfhdsjkhfjkdsh@test.com>'); + $this->imapClientFactory->expects($this->any()) + ->method('getClient') + ->willReturn($client); + $mb = $this->createMock(Mailbox::class); + $this->mailboxMapper->expects($this->once()) + ->method('find') + ->with($account, 'INBOX') + ->willReturn($mb); + $client->expects($this->once()) + ->method('status') + ->willReturn(['permflags' => [ "11" => "\*"] ]); + $this->imapMessageMapper->expects($this->once()) + ->method('removeFlag') + ->with($client, $mb, 123, Tag::LABEL_IMPORTANT); + $this->imapMessageMapper->expects($this->never()) + ->method('addFlag'); + $account->expects($this->never()) + ->method('getUserId') + ->willReturn('test'); + $this->manager->tagMessage($account, 'INBOX', $message, $tag, false); + } + + public function testTagNoIMAPCapabilities(): void { + $client = $this->createMock(Horde_Imap_Client_Socket::class); + $account = $this->createMock(Account::class); + $message = new \OCA\Mail\Db\Message(); + $message->setUid(123); + $message->setMessageId('<jhfjkhdsjkfhdsjkhfjkdsh@test.com>'); + $tag = new Tag(); + $tag->setImapLabel(Tag::LABEL_IMPORTANT); + + $this->imapClientFactory->expects($this->any()) + ->method('getClient') + ->willReturn($client); + $mb = $this->createMock(Mailbox::class); + $this->mailboxMapper->expects($this->once()) + ->method('find') + ->with($account, 'INBOX') + ->willReturn($mb); + $client->expects($this->once()) + ->method('status') + ->willReturn([]); + $this->imapMessageMapper->expects($this->never()) + ->method('removeFlag'); + $this->imapMessageMapper->expects($this->never()) + ->method('addFlag'); + $account->expects($this->once()) + ->method('getUserId') + ->willReturn('test'); + $this->manager->tagMessage($account, 'INBOX', $message, $tag, true); + } + public function testGetThread(): void { $account = $this->createMock(Account::class); $messageId = 123; diff --git a/tests/Unit/Service/SetupServiceTest.php b/tests/Unit/Service/SetupServiceTest.php index 70288db2a..1d5ff27bc 100644 --- a/tests/Unit/Service/SetupServiceTest.php +++ b/tests/Unit/Service/SetupServiceTest.php @@ -34,6 +34,7 @@ use OCA\Mail\Service\AutoConfig\AutoConfig; use OCA\Mail\Service\SetupService; use OCA\Mail\SMTP\SmtpClientFactory; use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Db\TagMapper; use OCP\Security\ICrypto; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -61,6 +62,9 @@ class SetupServiceTest extends TestCase { /** @var SetupService */ private $service; + /** @var TagMapper|MockObject */ + private $tagMapper; + protected function setUp(): void { parent::setUp(); @@ -70,6 +74,7 @@ class SetupServiceTest extends TestCase { $this->smtpClientFactory = $this->createMock(SmtpClientFactory::class); $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->tagMapper = $this->createMock(TagMapper::class); $this->service = new SetupService( $this->autoConfig, @@ -77,7 +82,8 @@ class SetupServiceTest extends TestCase { $this->crypto, $this->smtpClientFactory, $this->imapClientFactory, - $this->logger + $this->logger, + $this->tagMapper ); } @@ -109,6 +115,11 @@ class SetupServiceTest extends TestCase { $this->accountService->expects($this->once()) ->method('save') ->with($account); + + $this->tagMapper->expects($this->once()) + ->method('createDefaultTags') + ->with($account); + $expected = new Account($account); $actual = $this->service->createNewAutoConfiguredAccount($name, $email, $password); diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 1cb8ae6f3..f05676999 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -93,6 +93,11 @@ <code>$qb2->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)</code> </ImplicitToStringCast> </file> + <file src="lib/Db/TagMapper.php"> + <ImplicitToStringCast occurrences="1"> + <code>$qb->createNamedParameter($ids, IQueryBuilder::PARAM_STR_ARRAY)</code> + </ImplicitToStringCast> + </file> <file src="lib/Db/MessageMapper.php"> <ImplicitToStringCast occurrences="26"> <code>$deleteRecipientsQuery->createFunction($messageIdQuery->getSQL())</code> |