diff options
author | Anna Larch <anna@nextcloud.com> | 2022-11-03 17:17:51 +0300 |
---|---|---|
committer | Anna Larch <anna@nextcloud.com> | 2022-11-10 23:46:33 +0300 |
commit | 7738e90c2ea18dff0a57e1ea47b195c8b544bfdb (patch) | |
tree | ca2eb238cd31bf216c75e7d88b91c6c82ebb170a | |
parent | cecf046ba532b6bfd452b57f2b126941448ab0fe (diff) |
Local Draft Handling Backend
Signed-off-by: Anna Larch <anna@nextcloud.com>
-rw-r--r-- | appinfo/info.xml | 2 | ||||
-rw-r--r-- | lib/AppInfo/Application.php | 2 | ||||
-rw-r--r-- | lib/BackgroundJob/DraftsJob.php | 57 | ||||
-rw-r--r-- | lib/Contracts/IMailTransmission.php | 2 | ||||
-rw-r--r-- | lib/Controller/DraftsController.php | 198 | ||||
-rw-r--r-- | lib/Db/LocalMessage.php | 11 | ||||
-rw-r--r-- | lib/Db/LocalMessageMapper.php | 58 | ||||
-rw-r--r-- | lib/Events/DraftMessageCreatedEvent.php | 56 | ||||
-rw-r--r-- | lib/Listener/DeleteDraftListener.php | 3 | ||||
-rw-r--r-- | lib/Migration/Version2020Date20221103140538.php | 59 | ||||
-rw-r--r-- | lib/Service/DraftsService.php | 240 | ||||
-rw-r--r-- | lib/Service/MailTransmission.php | 14 | ||||
-rw-r--r-- | tests/Unit/Service/DraftsServiceTest.php | 499 |
13 files changed, 1188 insertions, 13 deletions
diff --git a/appinfo/info.xml b/appinfo/info.xml index 051395a95..93e91dc9c 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -12,7 +12,7 @@ - **🙈 We’re not reinventing the wheel!** Based on the great [Horde](https://horde.org) libraries. - **📬 Want to host your own mail server?** We do not have to reimplement this as you could set up [Mail-in-a-Box](https://mailinabox.email)! ]]></description> - <version>2.2.0-alpha.0</version> + <version>2.2.0-alpha.1</version> <licence>agpl</licence> <author>Greta Doçi</author> <author homepage="https://github.com/nextcloud/groupware">Nextcloud Groupware Team</author> diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 0cae71c6b..106e7b65b 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -35,6 +35,7 @@ use OCA\Mail\Contracts\IUserPreferences; use OCA\Mail\Dashboard\ImportantMailWidget; use OCA\Mail\Dashboard\UnreadMailWidget; use OCA\Mail\Events\BeforeMessageSentEvent; +use OCA\Mail\Events\DraftMessageCreatedEvent; use OCA\Mail\Events\DraftSavedEvent; use OCA\Mail\Events\MailboxesSynchronizedEvent; use OCA\Mail\Events\OutboxMessageCreatedEvent; @@ -105,6 +106,7 @@ class Application extends App implements IBootstrap { $context->registerEventListener(BeforeMessageSentEvent::class, AntiAbuseListener::class); $context->registerEventListener(DraftSavedEvent::class, DeleteDraftListener::class); + $context->registerEventListener(DraftMessageCreatedEvent::class, DeleteDraftListener::class); $context->registerEventListener(OutboxMessageCreatedEvent::class, DeleteDraftListener::class); $context->registerEventListener(MailboxesSynchronizedEvent::class, MailboxesSynchronizedSpecialMailboxesUpdater::class); $context->registerEventListener(MessageFlaggedEvent::class, MessageCacheUpdaterListener::class); diff --git a/lib/BackgroundJob/DraftsJob.php b/lib/BackgroundJob/DraftsJob.php new file mode 100644 index 000000000..1bd5f0627 --- /dev/null +++ b/lib/BackgroundJob/DraftsJob.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2022 Anna Larch <anna.larch@gmx.net> + * + * @author 2022 Anna Larch <anna.larch@gmx.net> + * + * @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\BackgroundJob; + +use OCA\Mail\Db\LocalMessageMapper; +use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\DraftsService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use Psr\Log\LoggerInterface; + +class DraftsJob extends TimedJob { + private DraftsService $draftsService; + private LocalMessageMapper $messageMapper; + private AccountService $accountService; + private LoggerInterface $logger; + + public function __construct(ITimeFactory $time, + DraftsService $draftsService, + AccountService $accountService, + LocalMessageMapper $messageMapper, + LoggerInterface $logger) { + parent::__construct($time); + + // Run once per five minutes + $this->setInterval(5 * 60); + $this->setTimeSensitivity(self::TIME_SENSITIVE); + $this->draftsService = $draftsService; + } + + protected function run($argument): void { + $this->draftsService->flush(); + } +} diff --git a/lib/Contracts/IMailTransmission.php b/lib/Contracts/IMailTransmission.php index f8e29bbd9..812c921fc 100644 --- a/lib/Contracts/IMailTransmission.php +++ b/lib/Contracts/IMailTransmission.php @@ -57,7 +57,7 @@ interface IMailTransmission { * @throws ServiceException * @return void */ - public function sendLocalMessage(Account $account, LocalMessage $message): void; + public function sendLocalMessage(Account $account, LocalMessage $message, bool $isDraft = false): void; /** * Save a message draft diff --git a/lib/Controller/DraftsController.php b/lib/Controller/DraftsController.php new file mode 100644 index 000000000..b008ea117 --- /dev/null +++ b/lib/Controller/DraftsController.php @@ -0,0 +1,198 @@ +<?php + +declare(strict_types=1); + +/** + * Mail App + * + * @copyright 2022 Anna Larch <anna.larch@gmx.net> + * + * @author Anna Larch <anna.larch@gmx.net> + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE + * License as published by the Free Software Foundation; either + * version 3 of the License, or any later version. + * + * This library 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 library. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Mail\Controller; + +use OCA\Mail\Db\LocalMessage; +use OCA\Mail\Http\JsonResponse; +use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\DraftsService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IRequest; + +class DraftsController extends Controller { + private DraftsService $service; + private string $userId; + private AccountService $accountService; + private ITimeFactory $timeFactory; + + public function __construct(string $appName, + $UserId, + IRequest $request, + DraftsService $service, + AccountService $accountService, + ITimeFactory $timeFactory) { + parent::__construct($appName, $request); + $this->userId = $UserId; + $this->service = $service; + $this->accountService = $accountService; + $this->timeFactory = $timeFactory; + } + + /** + * @NoAdminRequired + * @TrapError + * + * @param int $accountId + * @param string $subject + * @param string $body + * @param string $editorBody + * @param bool $isHtml + * @param array $to i. e. [['label' => 'Linus', 'email' => 'tent@stardewvalley.com'], ['label' => 'Pierre', 'email' => 'generalstore@stardewvalley.com']] + * @param array $cc + * @param array $bcc + * @param array $attachments + * @param int|null $aliasId + * @param string|null $inReplyToMessageId + * @param int|null $sendAt + * @param int|null $draftId + * @return JsonResponse + */ + public function create( + int $accountId, + string $subject, + string $body, + string $editorBody, + bool $isHtml, + array $to = [], + array $cc = [], + array $bcc = [], + array $attachments = [], + ?int $aliasId = null, + ?string $inReplyToMessageId = null, + ?int $sendAt = null, + ?int $draftId = null) : JsonResponse { + $account = $this->accountService->find($this->userId, $accountId); + if ($draftId !== null) { + $this->service->handleDraft($account, $draftId); + } + $message = new LocalMessage(); + $message->setType(LocalMessage::TYPE_DRAFT); + $message->setAccountId($accountId); + $message->setAliasId($aliasId); + $message->setSubject($subject); + $message->setBody($body); + $message->setEditorBody($editorBody); + $message->setHtml($isHtml); + $message->setInReplyToMessageId($inReplyToMessageId); + $message->setUpdatedAt($this->timeFactory->getTime()); + $message->setSendAt($sendAt); + if ($sendAt !== null) { + $message->setType(LocalMessage::TYPE_OUTGOING); + } + $this->service->saveMessage($account, $message, $to, $cc, $bcc, $attachments); + + return JsonResponse::success($message, Http::STATUS_CREATED); + } + + /** + * @NoAdminRequired + * @TrapError + * + * @param int $id + * @param int $accountId + * @param string $subject + * @param string $body + * @param string $editorBody + * @param bool $isHtml + * @param bool $failed + * @param array $to i. e. [['label' => 'Linus', 'email' => 'tent@stardewvalley.com'], ['label' => 'Pierre', 'email' => 'generalstore@stardewvalley.com']] + * @param array $cc + * @param array $bcc + * @param array $attachments + * @param int|null $aliasId + * @param string|null $inReplyToMessageId + * @param int|null $sendAt + * @return JsonResponse + */ + public function update(int $id, + int $accountId, + string $subject, + string $body, + string $editorBody, + bool $isHtml, + bool $failed = false, + array $to = [], + array $cc = [], + array $bcc = [], + array $attachments = [], + ?int $aliasId = null, + ?string $inReplyToMessageId = null, + ?int $sendAt = null): JsonResponse { + $message = $this->service->getMessage($id, $this->userId); + $account = $this->accountService->find($this->userId, $accountId); + + ($sendAt !== null) + ? $message->setType(LocalMessage::TYPE_OUTGOING) + : $message->setType(LocalMessage::TYPE_DRAFT); + $message->setAccountId($accountId); + $message->setAliasId($aliasId); + $message->setSubject($subject); + $message->setBody($body); + $message->setEditorBody($editorBody); + $message->setHtml($isHtml); + $message->setFailed($failed); + $message->setInReplyToMessageId($inReplyToMessageId); + $message->setSendAt($sendAt); + $message->setUpdatedAt($this->timeFactory->getTime()); + + $message = $this->service->updateMessage($account, $message, $to, $cc, $bcc, $attachments); + return JsonResponse::success($message, Http::STATUS_ACCEPTED); + } + + /** + * @NoAdminRequired + * @TrapError + * + * @param int $id + * @return JsonResponse + */ + public function destroy(int $id): JsonResponse { + $message = $this->service->getMessage($id, $this->userId); + $account = $this->accountService->find($this->userId, $message->getAccountId()); + + $this->service->deleteMessage($this->userId, $message); + return JsonResponse::success('Message deleted', Http::STATUS_ACCEPTED); + } + + /** + * @NoAdminRequired + * @TrapError + * @param int $id + * @return JsonResponse + */ + public function move(int $id): JsonResponse { + $message = $this->service->getMessage($id, $this->userId); + $account = $this->accountService->find($this->userId, $message->getAccountId()); + + $this->service->sendMessage($message, $account); + return JsonResponse::success( + 'Message moved to IMAP', Http::STATUS_ACCEPTED + ); + } +} diff --git a/lib/Db/LocalMessage.php b/lib/Db/LocalMessage.php index 944b403f9..4e1b72ad0 100644 --- a/lib/Db/LocalMessage.php +++ b/lib/Db/LocalMessage.php @@ -37,8 +37,8 @@ use function array_filter; * @method void setAccountId(int $accountId) * @method int|null getAliasId() * @method void setAliasId(?int $aliasId) - * @method int getSendAt() - * @method void setSendAt(int $sendAt) + * @method int|null getSendAt() + * @method void setSendAt(?int $sendAt) * @method string getSubject() * @method void setSubject(string $subject) * @method string getBody() @@ -51,6 +51,8 @@ use function array_filter; * @method void setFailed(bool $failed) * @method string|null getInReplyToMessageId() * @method void setInReplyToMessageId(?string $inReplyToId) + * @method int|null getUpdatedAt() + * @method setUpdatedAt(?int $updatedAt) */ class LocalMessage extends Entity implements JsonSerializable { public const TYPE_OUTGOING = 0; @@ -95,6 +97,9 @@ class LocalMessage extends Entity implements JsonSerializable { /** @var bool|null */ protected $failed; + /** @var int|null */ + protected $updatedAt; + public function __construct() { $this->addType('type', 'integer'); $this->addType('accountId', 'integer'); @@ -102,6 +107,7 @@ class LocalMessage extends Entity implements JsonSerializable { $this->addType('sendAt', 'integer'); $this->addType('html', 'boolean'); $this->addType('failed', 'boolean'); + $this->addType('updatedAt', 'integer'); } #[ReturnTypeWillChange] @@ -112,6 +118,7 @@ class LocalMessage extends Entity implements JsonSerializable { 'accountId' => $this->getAccountId(), 'aliasId' => $this->getAliasId(), 'sendAt' => $this->getSendAt(), + 'updatedAt' => $this->getUpdatedAt(), 'subject' => $this->getSubject(), 'body' => $this->getBody(), 'editorBody' => $this->getEditorBody(), diff --git a/lib/Db/LocalMessageMapper.php b/lib/Db/LocalMessageMapper.php index b15b917fe..7a2e2801d 100644 --- a/lib/Db/LocalMessageMapper.php +++ b/lib/Db/LocalMessageMapper.php @@ -57,14 +57,14 @@ class LocalMessageMapper extends QBMapper { * @return LocalMessage[] * @throws DBException */ - public function getAllForUser(string $userId): array { + public function getAllForUser(string $userId, int $type = LocalMessage::TYPE_OUTGOING): array { $qb = $this->db->getQueryBuilder(); $qb->select('m.*') ->from('mail_accounts', 'a') ->join('a', $this->getTableName(), 'm', $qb->expr()->eq('m.account_id', 'a.id')) ->where( $qb->expr()->eq('a.user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR), - $qb->expr()->eq('m.type', $qb->createNamedParameter(LocalMessage::TYPE_OUTGOING, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT) + $qb->expr()->eq('m.type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT) ); $rows = $qb->execute(); @@ -124,12 +124,13 @@ class LocalMessageMapper extends QBMapper { * * @return LocalMessage[] */ - public function findDue(int $time): array { + public function findDue(int $time, int $type = LocalMessage::TYPE_OUTGOING): array { $qb = $this->db->getQueryBuilder(); $select = $qb->select('*') ->from($this->getTableName()) ->where( $qb->expr()->isNotNull('send_at'), + $qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), $qb->expr()->lte('send_at', $qb->createNamedParameter($time, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), $qb->expr()->orX( $qb->expr()->isNull('failed'), @@ -167,6 +168,57 @@ class LocalMessageMapper extends QBMapper { } /** + * Find all messages that should be sent + * + * @param int $time upper bound send time stamp + * + * @return LocalMessage[] + */ + public function findDueDrafts(int $time): array { + $qb = $this->db->getQueryBuilder(); + $select = $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->isNotNull('send_at'), + $qb->expr()->eq('type', $qb->createNamedParameter(LocalMessage::TYPE_DRAFT, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + $qb->expr()->lte('updated_at', $qb->createNamedParameter($time - 300, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + $qb->expr()->orX( + $qb->expr()->isNull('failed'), + $qb->expr()->eq('failed', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL), IQueryBuilder::PARAM_BOOL), + ) + ) + ->orderBy('updated_at', 'asc') + ->orderBy('account_id', 'asc'); + $messages = $this->findEntities($select); + + if (empty($messages)) { + return []; + } + + $ids = array_map(function (LocalMessage $message) { + return $message->getId(); + }, $messages); + + $attachments = $this->attachmentMapper->findByLocalMessageIds($ids); + $recipients = $this->recipientMapper->findByLocalMessageIds($ids); + + $recipientMap = []; + foreach ($recipients as $r) { + $recipientMap[$r->getLocalMessageId()][] = $r; + } + $attachmentMap = []; + foreach ($attachments as $a) { + $attachmentMap[$a->getLocalMessageId()][] = $a; + } + + return array_map(static function ($localMessage) use ($attachmentMap, $recipientMap) { + $localMessage->setAttachments($attachmentMap[$localMessage->getId()] ?? []); + $localMessage->setRecipients($recipientMap[$localMessage->getId()] ?? []); + return $localMessage; + }, $messages); + } + + /** * @param Recipient[] $to * @param Recipient[] $cc * @param Recipient[] $bcc diff --git a/lib/Events/DraftMessageCreatedEvent.php b/lib/Events/DraftMessageCreatedEvent.php new file mode 100644 index 000000000..efd92290b --- /dev/null +++ b/lib/Events/DraftMessageCreatedEvent.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Anna Larch <anna.larch@gmx.net> + * + * @author 2022 Anna Larch <anna.larch@gmx.net> + * + * @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\Events; + +use OCA\Mail\Account; +use OCA\Mail\Db\Message; +use OCP\EventDispatcher\Event; + +/** + * @psalm-immutable + */ +class DraftMessageCreatedEvent extends Event { + /** @var Account */ + private $account; + + /** @var Message */ + private $draft; + + public function __construct(Account $account, + Message $draft) { + parent::__construct(); + $this->account = $account; + $this->draft = $draft; + } + + public function getAccount(): Account { + return $this->account; + } + + public function getDraft(): ?Message { + return $this->draft; + } +} diff --git a/lib/Listener/DeleteDraftListener.php b/lib/Listener/DeleteDraftListener.php index c615122d8..dc53778d1 100644 --- a/lib/Listener/DeleteDraftListener.php +++ b/lib/Listener/DeleteDraftListener.php @@ -31,6 +31,7 @@ use OCA\Mail\Account; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; +use OCA\Mail\Events\DraftMessageCreatedEvent; use OCA\Mail\Events\DraftSavedEvent; use OCA\Mail\Events\MessageDeletedEvent; use OCA\Mail\Events\OutboxMessageCreatedEvent; @@ -71,7 +72,7 @@ class DeleteDraftListener implements IEventListener { } public function handle(Event $event): void { - if (($event instanceof DraftSavedEvent || $event instanceof OutboxMessageCreatedEvent) && $event->getDraft() !== null) { + if (($event instanceof DraftSavedEvent || $event instanceof OutboxMessageCreatedEvent || $event instanceof DraftMessageCreatedEvent) && $event->getDraft() !== null) { $this->deleteDraft($event->getAccount(), $event->getDraft()); } } diff --git a/lib/Migration/Version2020Date20221103140538.php b/lib/Migration/Version2020Date20221103140538.php new file mode 100644 index 000000000..e380ca88e --- /dev/null +++ b/lib/Migration/Version2020Date20221103140538.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2022 Anna Larch <anna.larch@gmx.net> + * + * @author Anna Larch <anna.larch@gmx.net> + * + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version2020Date20221103140538 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @psalm-param Closure $schemaClosure + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $localMessagesTable = $schema->getTable('mail_local_messages'); + if (!$localMessagesTable->hasColumn('updated_at')) { + $localMessagesTable->addColumn('updated_at', Types::INTEGER, [ + 'notnull' => false, + 'length' => 4, + ]); + } + $localMessagesTable->changeColumn('send_at', [ + 'notnull' => false + ]); + return $schema; + } +} diff --git a/lib/Service/DraftsService.php b/lib/Service/DraftsService.php new file mode 100644 index 000000000..bee465378 --- /dev/null +++ b/lib/Service/DraftsService.php @@ -0,0 +1,240 @@ +<?php + +declare(strict_types=1); + +/** + * Mail App + * + * @copyright 2022 Anna Larch <anna.larch@gmx.net> + * + * @author Anna Larch <anna.larch@gmx.net> + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE + * License as published by the Free Software Foundation; either + * version 3 of the License, or any later version. + * + * This library 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 library. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Mail\Service; + +use OCA\Mail\Account; +use OCA\Mail\Contracts\ILocalMailboxService; +use OCA\Mail\Contracts\IMailManager; +use OCA\Mail\Contracts\IMailTransmission; +use OCA\Mail\Db\LocalMessage; +use OCA\Mail\Db\LocalMessageMapper; +use OCA\Mail\Db\Recipient; +use OCA\Mail\Events\DraftMessageCreatedEvent; +use OCA\Mail\Exception\ClientException; +use OCA\Mail\Exception\NotImplemented; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Service\Attachment\AttachmentService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\IEventDispatcher; +use Psr\Log\LoggerInterface; +use Throwable; + +class DraftsService implements ILocalMailboxService { + private IMailTransmission $transmission; + private LocalMessageMapper $mapper; + private AttachmentService $attachmentService; + private IEventDispatcher $eventDispatcher; + private IMAPClientFactory $clientFactory; + private IMailManager $mailManager; + private LoggerInterface $logger; + private AccountService $accountService; + private ITimeFactory $time; + + public function __construct(IMailTransmission $transmission, + LocalMessageMapper $mapper, + AttachmentService $attachmentService, + IEventDispatcher $eventDispatcher, + IMAPClientFactory $clientFactory, + IMailManager $mailManager, + LoggerInterface $logger, + AccountService $accountService, + ITimeFactory $time) { + $this->transmission = $transmission; + $this->mapper = $mapper; + $this->attachmentService = $attachmentService; + $this->eventDispatcher = $eventDispatcher; + $this->clientFactory = $clientFactory; + $this->mailManager = $mailManager; + $this->logger = $logger; + $this->accountService = $accountService; + $this->time = $time; + } + + /** + * @param array $recipients + * @param int $type + * @return Recipient[] + */ + private static function convertToRecipient(array $recipients, int $type): array { + return array_map(function ($recipient) use ($type) { + $r = new Recipient(); + $r->setType($type); + $r->setLabel($recipient['label'] ?? $recipient['email']); + $r->setEmail($recipient['email']); + return $r; + }, $recipients); + } + + + public function deleteMessage(string $userId, LocalMessage $message): void { + $this->attachmentService->deleteLocalMessageAttachments($userId, $message->getId()); + $this->mapper->deleteWithRecipients($message); + } + + public function saveMessage(Account $account, LocalMessage $message, array $to, array $cc, array $bcc, array $attachments = []): LocalMessage { + $toRecipients = self::convertToRecipient($to, Recipient::TYPE_TO); + $ccRecipients = self::convertToRecipient($cc, Recipient::TYPE_CC); + $bccRecipients = self::convertToRecipient($bcc, Recipient::TYPE_BCC); + + // if this message has a "sent at" datestamp and the type is set as Type Outbox + // check for a valid recipient + if ($message->getType() === LocalMessage::TYPE_OUTGOING && empty($toRecipients)) { + throw new ClientException('Cannot convert message to outbox message without at least one recipient'); + } + + $message = $this->mapper->saveWithRecipients($message, $toRecipients, $ccRecipients, $bccRecipients); + + if (empty($attachments)) { + $message->setAttachments($attachments); + return $message; + } + + $client = $this->clientFactory->getClient($account); + try { + $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); + } finally { + $client->logout(); + } + + $message->setAttachments($this->attachmentService->saveLocalMessageAttachments($account->getUserId(), $message->getId(), $attachmentIds)); + return $message; + } + + public function updateMessage(Account $account, LocalMessage $message, array $to, array $cc, array $bcc, array $attachments = []): LocalMessage { + $toRecipients = self::convertToRecipient($to, Recipient::TYPE_TO); + $ccRecipients = self::convertToRecipient($cc, Recipient::TYPE_CC); + $bccRecipients = self::convertToRecipient($bcc, Recipient::TYPE_BCC); + + // if this message has a "sent at" datestamp and the type is set as Type Outbox + // check for a valid recipient + if ($message->getType() === LocalMessage::TYPE_OUTGOING && empty($toRecipients)) { + throw new ClientException('Cannot convert message to outbox message without at least one recipient'); + } + + $message = $this->mapper->updateWithRecipients($message, $toRecipients, $ccRecipients, $bccRecipients); + + if (empty($attachments)) { + $message->setAttachments($this->attachmentService->updateLocalMessageAttachments($account->getUserId(), $message, [])); + return $message; + } + + $client = $this->clientFactory->getClient($account); + try { + $attachmentIds = $this->attachmentService->handleAttachments($account, $attachments, $client); + } finally { + $client->logout(); + } + $message->setAttachments($this->attachmentService->updateLocalMessageAttachments($account->getUserId(), $message, $attachmentIds)); + return $message; + } + + public function handleDraft(Account $account, int $draftId): void { + $message = $this->mailManager->getMessage($account->getUserId(), $draftId); + $this->eventDispatcher->dispatchTyped(new DraftMessageCreatedEvent($account, $message)); + } + + /** + * "Send" the message + * + * @param LocalMessage $message + * @param Account $account + * @return void + */ + public function sendMessage(LocalMessage $message, Account $account): void { + try { + $this->transmission->sendLocalMessage($account, $message, true); + } catch (ClientException|ServiceException $e) { + $this->logger->error('Could not move draft to IMAP', ['exception' => $e]); + // Mark as failed so the message is not moved repeatedly in background + $message->setFailed(true); + $this->mapper->update($message); + throw $e; + } + $this->attachmentService->deleteLocalMessageAttachments($account->getUserId(), $message->getId()); + $this->mapper->deleteWithRecipients($message); + } + + /** + * @throws DoesNotExistException + */ + public function getMessage(int $id, string $userId): LocalMessage { + return $this->mapper->findById($id, $userId); + } + + /** + * @throws NotImplemented + */ + public function getMessages(string $userId): array { + throw new NotImplemented('Not implemented'); + } + + public function flush() { + $messages = $this->mapper->findDueDrafts($this->time->getTime()); + if (empty($messages)) { + return; + } + + $accountIds = array_unique(array_map(function ($message) { + return $message->getAccountId(); + }, $messages)); + + $accounts = array_combine($accountIds, array_map(function ($accountId) { + try { + return $this->accountService->findById($accountId); + } catch (DoesNotExistException $e) { + // The message belongs to a deleted account + return null; + } + }, $accountIds)); + + foreach ($messages as $message) { + try { + $account = $accounts[$message->getAccountId()]; + if ($account === null) { + // Ignore message of non-existent account + continue; + } + $this->sendMessage( + $message, + $account, + ); + $this->logger->debug('Draft {id} moved to IMAP', [ + 'id' => $message->getId(), + ]); + } catch (Throwable $e) { + // Failure of one message should not stop sending other messages + // Log and continue + $this->logger->warning('Could not move draft {id} to IMAP: ' . $e->getMessage(), [ + 'id' => $message->getId(), + 'exception' => $e, + ]); + } + } + } +} diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php index 210bef244..4472809ad 100644 --- a/lib/Service/MailTransmission.php +++ b/lib/Service/MailTransmission.php @@ -218,7 +218,7 @@ class MailTransmission implements IMailTransmission { ); } - public function sendLocalMessage(Account $account, LocalMessage $message): void { + public function sendLocalMessage(Account $account, LocalMessage $message, bool $isDraft = false): void { $to = new AddressList( array_map( static function ($recipient) { @@ -271,10 +271,14 @@ class MailTransmission implements IMailTransmission { $alias = $this->aliasesService->find($message->getAliasId(), $account->getUserId()); } - try { - $this->sendMessage($messageData, $message->getInReplyToMessageId(), $alias ?? null); - } catch (SentMailboxNotSetException $e) { - throw new ClientException('Could not send message' . $e->getMessage(), (int)$e->getCode(), $e); + if ($isDraft) { + $this->saveDraft($messageData); + } else { + try { + $this->sendMessage($messageData, $message->getInReplyToMessageId(), $alias ?? null); + } catch (SentMailboxNotSetException $e) { + throw new ClientException('Could not send message' . $e->getMessage(), (int)$e->getCode(), $e); + } } } diff --git a/tests/Unit/Service/DraftsServiceTest.php b/tests/Unit/Service/DraftsServiceTest.php new file mode 100644 index 000000000..f5262b157 --- /dev/null +++ b/tests/Unit/Service/DraftsServiceTest.php @@ -0,0 +1,499 @@ +<?php + +declare(strict_types=1); + +/** + * Mail App + * + * @copyright 2022 Anna Larch <anna.larch@gmx.net> + * + * @author Anna Larch <anna.larch@gmx.net> + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE + * License as published by the Free Software Foundation; either + * version 3 of the License, or any later version. + * + * This library 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 library. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Mail\Tests\Unit\Service; + +use ChristophWurst\Nextcloud\Testing\TestCase; +use OC\EventDispatcher\EventDispatcher; +use OCA\Mail\Account; +use OCA\Mail\Contracts\IMailManager; +use OCA\Mail\Db\LocalAttachment; +use OCA\Mail\Db\LocalMessage; +use OCA\Mail\Db\LocalMessageMapper; +use OCA\Mail\Db\Recipient; +use OCA\Mail\Exception\ClientException; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\Attachment\AttachmentService; +use OCA\Mail\Service\MailTransmission; +use OCA\Mail\Service\OutboxService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\Exception; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; + +class OutboxServiceTest extends TestCase { + /** @var MailTransmission|MockObject */ + private $transmission; + + /** @var LocalMessageMapper|MockObject */ + private $mapper; + + /** @var OutboxService */ + private $outboxService; + + /** @var string */ + private $userId; + + /** @var ITimeFactory|MockObject */ + private $time; + + /** @var AttachmentService|MockObject */ + private $attachmentService; + + /** @var IMAPClientFactory|MockObject */ + private $clientFactory; + + /** @var IMailManager|MockObject */ + private $mailManager; + + /** @var AccountService|MockObject */ + private $accountService; + + /** @var ITimeFactory|MockObject */ + private $timeFactory; + + /** @var MockObject|LoggerInterface */ + private $logger; + + protected function setUp(): void { + parent::setUp(); + + $this->transmission = $this->createMock(MailTransmission::class); + $this->mapper = $this->createMock(LocalMessageMapper::class); + $this->attachmentService = $this->createMock(AttachmentService::class); + $this->clientFactory = $this->createMock(IMAPClientFactory::class); + $this->mailManager = $this->createMock(IMailManager::class); + $this->accountService = $this->createMock(AccountService::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->outboxService = new OutboxService( + $this->transmission, + $this->mapper, + $this->attachmentService, + $this->createMock(EventDispatcher::class), + $this->clientFactory, + $this->mailManager, + $this->accountService, + $this->timeFactory, + $this->logger, + ); + $this->userId = 'linus'; + $this->time = $this->createMock(ITimeFactory::class); + } + + public function testGetMessages(): void { + $this->mapper->expects(self::once()) + ->method('getAllForUser') + ->with($this->userId) + ->willReturn([ + [ + 'id' => 1, + 'type' => 0, + 'account_id' => 1, + 'alias_id' => 2, + 'send_at' => $this->time->getTime(), + 'subject' => 'Test', + 'body' => 'Test', + 'html' => false, + 'reply_to_id' => null, + 'draft_id' => 99 + + ], + [ + 'id' => 2, + 'type' => 0, + 'account_id' => 1, + 'alias_id' => 2, + 'send_at' => $this->time->getTime(), + 'subject' => 'Second Test', + 'body' => 'Second Test', + 'html' => true, + 'reply_to_id' => null, + 'draft_id' => null + ] + ]); + + $this->outboxService->getMessages($this->userId); + } + + public function testGetMessagesNoneFound(): void { + $this->mapper->expects(self::once()) + ->method('getAllForUser') + ->with($this->userId) + ->willThrowException(new Exception()); + + $this->expectException(Exception::class); + $this->outboxService->getMessages($this->userId); + } + + public function testGetMessage(): void { + $message = new LocalMessage(); + $message->setAccountId(1); + $message->setSendAt($this->time->getTime()); + $message->setSubject('Test'); + $message->setBody('Test Test Test'); + $message->setHtml(true); + $message->setInReplyToMessageId('abcd'); + + $this->mapper->expects(self::once()) + ->method('findById') + ->with(1, $this->userId) + ->willReturn($message); + + $this->outboxService->getMessage(1, $this->userId); + } + + public function testNoMessage(): void { + $this->mapper->expects(self::once()) + ->method('findById') + ->with(1, $this->userId) + ->willThrowException(new DoesNotExistException('Could not fetch any messages')); + + $this->expectException(DoesNotExistException::class); + $this->outboxService->getMessage(1, $this->userId); + } + + public function testDeleteMessage(): void { + $message = new LocalMessage(); + $message->setId(10); + $message->setAccountId(1); + $message->setSendAt($this->time->getTime()); + $message->setSubject('Test'); + $message->setBody('Test Test Test'); + $message->setHtml(true); + $message->setInReplyToMessageId('abcd'); + + $this->attachmentService->expects(self::once()) + ->method('deleteLocalMessageAttachments') + ->with($this->userId, $message->getId()); + $this->mapper->expects(self::once()) + ->method('deleteWithRecipients') + ->with($message); + + $this->outboxService->deleteMessage($this->userId, $message); + } + + public function testSaveMessage(): void { + $message = new LocalMessage(); + $message->setAccountId(1); + $message->setSendAt($this->time->getTime()); + $message->setSubject('Test'); + $message->setBody('Test Test Test'); + $message->setHtml(true); + $message->setInReplyToMessageId('abcd'); + $to = [ + [ + 'label' => 'Lewis', + 'email' => 'tent-living@startdewvalley.com', + 'type' => Recipient::TYPE_TO, + ] + ]; + $cc = []; + $bcc = []; + $attachments = [[]]; + $attachmentIds = [1]; + $rTo = Recipient::fromParams([ + 'label' => 'Lewis', + 'email' => 'tent-living@startdewvalley.com', + 'type' => Recipient::TYPE_TO, + ]); + $message2 = $message; + $message2->setId(10); + $account = $this->createConfiguredMock(Account::class, [ + 'getUserId' => $this->userId + ]); + $client = $this->createMock(\Horde_Imap_Client_Socket::class); + + $this->mapper->expects(self::once()) + ->method('saveWithRecipients') + ->with($message, [$rTo], $cc, $bcc) + ->willReturn($message2); + $this->clientFactory->expects(self::once()) + ->method('getClient') + ->with($account) + ->willReturn($client); + $this->attachmentService->expects(self::once()) + ->method('handleAttachments') + ->with($account, $attachments, $client) + ->willReturn($attachmentIds); + $this->attachmentService->expects(self::once()) + ->method('saveLocalMessageAttachments') + ->with($this->userId, 10, $attachmentIds); + + $this->outboxService->saveMessage($account, $message, $to, $cc, $bcc, $attachments); + } + + public function testSaveMessageNoAttachments(): void { + $message = new LocalMessage(); + $message->setAccountId(1); + $message->setSendAt($this->time->getTime()); + $message->setSubject('Test'); + $message->setBody('Test Test Test'); + $message->setHtml(true); + $message->setInReplyToMessageId('abcd'); + $to = [ + [ + 'label' => 'Lewis', + 'email' => 'tent-living@startdewvalley.com', + 'type' => Recipient::TYPE_TO, + ] + ]; + $cc = []; + $bcc = []; + $attachments = []; + $rTo = Recipient::fromParams([ + 'label' => 'Lewis', + 'email' => 'tent-living@startdewvalley.com', + 'type' => Recipient::TYPE_TO, + ]); + $message2 = $message; + $message2->setId(10); + $account = $this->createConfiguredMock(Account::class, [ + 'getUserId' => $this->userId + ]); + + $this->mapper->expects(self::once()) + ->method('saveWithRecipients') + ->with($message, [$rTo], $cc, $bcc) + ->willReturn($message2); + $this->clientFactory->expects(self::never()) + ->method('getClient'); + $this->attachmentService->expects(self::never()) + ->method('handleAttachments'); + $this->attachmentService->expects(self::never()) + ->method('saveLocalMessageAttachments'); + + $result = $this->outboxService->saveMessage($account, $message, $to, $cc, $bcc, $attachments); + $this->assertEquals($message2->getId(), $result->getId()); + $this->assertEmpty($result->getAttachments()); + } + + public function testUpdateMessage(): void { + $message = new LocalMessage(); + $message->setId(10); + $message->setAccountId(1); + $message->setSendAt($this->time->getTime()); + $message->setSubject('Test'); + $message->setBody('Test Test Test'); + $message->setHtml(true); + $message->setInReplyToMessageId('abcd'); + $old = Recipient::fromParams([ + 'label' => 'Pam', + 'email' => 'BuyMeAnAle@startdewvalley.com', + 'type' => Recipient::TYPE_TO, + ]); + $message->setRecipients([$old]); + $to = [ + [ + 'label' => 'Linus', + 'email' => 'tent-living@startdewvalley.com', + 'type' => Recipient::TYPE_TO, + ] + ]; + $cc = []; + $bcc = []; + $attachments = [['type' => '']]; + $attachmentIds = [3]; + $rTo = Recipient::fromParams([ + 'label' => 'Linus', + 'email' => 'tent-living@startdewvalley.com', + 'type' => Recipient::TYPE_TO, + ]); + $message2 = $message; + $message2->setRecipients([$rTo]); + $account = $this->createConfiguredMock(Account::class, [ + 'getUserId' => $this->userId + ]); + $client = $this->createMock(\Horde_Imap_Client_Socket::class); + + $this->mapper->expects(self::once()) + ->method('updateWithRecipients') + ->with($message, [$rTo], $cc, $bcc) + ->willReturn($message2); + $this->clientFactory->expects(self::once()) + ->method('getClient') + ->with($account) + ->willReturn($client); + $this->attachmentService->expects(self::once()) + ->method('handleAttachments') + ->with($account, $attachments, $client) + ->willReturn($attachmentIds); + $this->attachmentService->expects(self::once()) + ->method('updateLocalMessageAttachments') + ->with($this->userId, $message2, $attachmentIds); + + $this->outboxService->updateMessage($account, $message, $to, $cc, $bcc, $attachments); + } + + public function testUpdateMessageNoAttachments(): void { + $message = new LocalMessage(); + $message->setId(10); + $message->setAccountId(1); + $message->setSendAt($this->time->getTime()); + $message->setSubject('Test'); + $message->setBody('Test Test Test'); + $message->setHtml(true); + $message->setInReplyToMessageId('abcd'); + $old = Recipient::fromParams([ + 'label' => 'Pam', + 'email' => 'BuyMeAnAle@startdewvalley.com', + 'type' => Recipient::TYPE_TO, + ]); + $message->setRecipients([$old]); + $to = [ + [ + 'label' => 'Linus', + 'email' => 'tent-living@startdewvalley.com', + 'type' => Recipient::TYPE_TO, + ] + ]; + $cc = []; + $bcc = []; + $attachments = []; + $rTo = Recipient::fromParams([ + 'label' => 'Linus', + 'email' => 'tent-living@startdewvalley.com', + 'type' => Recipient::TYPE_TO, + ]); + $message2 = $message; + $message2->setRecipients([$rTo]); + $account = $this->createConfiguredMock(Account::class, [ + 'getUserId' => $this->userId + ]); + $this->mapper->expects(self::once()) + ->method('updateWithRecipients') + ->with($message, [$rTo], $cc, $bcc) + ->willReturn($message2); + $this->attachmentService->expects(self::once()) + ->method('updateLocalMessageAttachments') + ->with($this->userId, $message2, $attachments); + $this->clientFactory->expects(self::never()) + ->method('getClient'); + $this->attachmentService->expects(self::never()) + ->method('handleAttachments'); + $result = $this->outboxService->updateMessage($account, $message, $to, $cc, $bcc, $attachments); + $this->assertEmpty($result->getAttachments()); + } + + public function testSaveMessageError(): void { + $message = new LocalMessage(); + $message->setAccountId(1); + $message->setSendAt($this->time->getTime()); + $message->setSubject('Test'); + $message->setBody('Test Test Test'); + $message->setHtml(true); + $message->setInReplyToMessageId('laskdjhsakjh33233928@startdewvalley.com'); + $to = [ + [ + 'label' => 'Gunther', + 'email' => 'museum@startdewvalley.com', + 'type' => Recipient::TYPE_TO, + ] + ]; + $rTo = Recipient::fromParams([ + 'label' => 'Gunther', + 'email' => 'museum@startdewvalley.com', + 'type' => Recipient::TYPE_TO, + ]); + $account = $this->createMock(Account::class); + + $this->mapper->expects(self::once()) + ->method('saveWithRecipients') + ->with($message, [$rTo], [], []) + ->willThrowException(new Exception()); + $this->attachmentService->expects(self::never()) + ->method('saveLocalMessageAttachments'); + $this->expectException(Exception::class); + + $this->outboxService->saveMessage($account, $message, $to, [], []); + } + + public function testSendMessage(): void { + $message = new LocalMessage(); + $message->setId(1); + $recipient = new Recipient(); + $recipient->setEmail('museum@startdewvalley.com'); + $recipient->setLabel('Gunther'); + $recipient->setType(Recipient::TYPE_TO); + $recipients = [$recipient]; + $attachment = new LocalAttachment(); + $attachment->setMimeType('image/png'); + $attachment->setFileName('SlimesInTheMines.png'); + $attachment->setCreatedAt($this->time->getTime()); + $attachments = [$attachment]; + $message->setRecipients($recipients); + $message->setAttachments($attachments); + $account = $this->createConfiguredMock(Account::class, [ + 'getUserId' => $this->userId + ]); + + $this->transmission->expects(self::once()) + ->method('sendLocalMessage') + ->with($account, $message); + $this->attachmentService->expects(self::once()) + ->method('deleteLocalMessageAttachments') + ->with($account->getUserId(), $message->getId()); + $this->mapper->expects(self::once()) + ->method('deleteWithRecipients') + ->with($message); + + $this->outboxService->sendMessage($message, $account); + } + + public function testSendMessageTransmissionError(): void { + $message = new LocalMessage(); + $message->setId(1); + $recipient = new Recipient(); + $recipient->setEmail('museum@startdewvalley.com'); + $recipient->setLabel('Gunther'); + $recipient->setType(Recipient::TYPE_TO); + $recipients = [$recipient]; + $attachment = new LocalAttachment(); + $attachment->setMimeType('image/png'); + $attachment->setFileName('SlimesInTheMines.png'); + $attachment->setCreatedAt($this->time->getTime()); + $attachments = [$attachment]; + $message->setRecipients($recipients); + $message->setAttachments($attachments); + $account = $this->createConfiguredMock(Account::class, [ + 'getUserId' => $this->userId + ]); + + $this->transmission->expects(self::once()) + ->method('sendLocalMessage') + ->with($account, $message) + ->willThrowException(new ClientException()); + $this->attachmentService->expects(self::never()) + ->method('deleteLocalMessageAttachments'); + $this->mapper->expects(self::never()) + ->method('deleteWithRecipients'); + + $this->expectException(ClientException::class); + $this->outboxService->sendMessage($message, $account); + } +} |