From 2dcf8b9576326c2bf6e42d63894e2c605803c265 Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Thu, 30 Jun 2022 14:13:54 +0200 Subject: [PATCH] Add imip processing Signed-off-by: Anna Larch --- appinfo/info.xml | 1 + lib/BackgroundJob/IMipMessageJob.php | 47 ++ .../PreviewEnhancementProcessingJob.php | 96 ++++ lib/Db/MailboxMapper.php | 18 + lib/Db/Message.php | 13 + lib/Db/MessageMapper.php | 88 +++- lib/IMAP/MessageMapper.php | 29 +- lib/IMAP/MessageStructureData.php | 11 +- lib/IMAP/PreviewEnhancer.php | 1 + lib/Migration/FixBackgroundJobs.php | 2 + .../Version2000Date20220908130842.php | 66 +++ lib/Model/IMAPMessage.php | 19 + lib/Service/IMipService.php | 169 +++++++ lib/Service/MailManager.php | 30 ++ lib/Service/PreprocessingService.php | 85 ++++ .../PreviewEnhancementProcessingJobTest.php | 172 +++++++ tests/Unit/Service/IMipServiceTest.php | 458 ++++++++++++++++++ .../Unit/Service/PreprocessingServiceTest.php | 130 +++++ 18 files changed, 1425 insertions(+), 10 deletions(-) create mode 100644 lib/BackgroundJob/IMipMessageJob.php create mode 100644 lib/BackgroundJob/PreviewEnhancementProcessingJob.php create mode 100644 lib/Migration/Version2000Date20220908130842.php create mode 100644 lib/Service/IMipService.php create mode 100644 lib/Service/PreprocessingService.php create mode 100644 tests/Unit/Job/PreviewEnhancementProcessingJobTest.php create mode 100644 tests/Unit/Service/IMipServiceTest.php create mode 100644 tests/Unit/Service/PreprocessingServiceTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 6becb7c3ec..96e009bac3 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -35,6 +35,7 @@ OCA\Mail\BackgroundJob\CleanupJob OCA\Mail\BackgroundJob\OutboxWorkerJob + OCA\Mail\BackgroundJob\IMipMessageJob diff --git a/lib/BackgroundJob/IMipMessageJob.php b/lib/BackgroundJob/IMipMessageJob.php new file mode 100644 index 0000000000..371645c910 --- /dev/null +++ b/lib/BackgroundJob/IMipMessageJob.php @@ -0,0 +1,47 @@ + + * + * @author 2022 Anna Larch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\BackgroundJob; + +use OCA\Mail\Service\IMipService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; + +class IMipMessageJob extends TimedJob { + private IMipService $iMipService; + + public function __construct(ITimeFactory $time, + IMipService $iMipService) { + parent::__construct($time); + + // Run once per hour + $this->setInterval(60 * 60); + $this->iMipService = $iMipService; + } + + protected function run($argument): void { + $this->iMipService->process(); + } +} diff --git a/lib/BackgroundJob/PreviewEnhancementProcessingJob.php b/lib/BackgroundJob/PreviewEnhancementProcessingJob.php new file mode 100644 index 0000000000..6aaecc1f14 --- /dev/null +++ b/lib/BackgroundJob/PreviewEnhancementProcessingJob.php @@ -0,0 +1,96 @@ + + * + * @author Anna Larch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\BackgroundJob; + +use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\PreprocessingService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\TimedJob; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; +use function sprintf; + +class PreviewEnhancementProcessingJob extends TimedJob { + private IUserManager $userManager; + private AccountService $accountService; + private LoggerInterface $logger; + private IJobList $jobList; + private PreprocessingService $preprocessingService; + + public function __construct(ITimeFactory $time, + IUserManager $userManager, + AccountService $accountService, + PreprocessingService $preprocessingService, + LoggerInterface $logger, + IJobList $jobList) { + parent::__construct($time); + + $this->userManager = $userManager; + $this->accountService = $accountService; + $this->logger = $logger; + $this->jobList = $jobList; + $this->preprocessingService = $preprocessingService; + + $this->setInterval(3600); + $this->setTimeSensitivity(self::TIME_SENSITIVE); + } + + /** + * @return void + */ + public function run($argument) { + $accountId = (int)$argument['accountId']; + + try { + $account = $this->accountService->findById($accountId); + } catch (DoesNotExistException $e) { + $this->logger->debug('Could not find account <' . $accountId . '> removing from jobs'); + $this->jobList->remove(self::class, $argument); + return; + } + + $user = $this->userManager->get($account->getUserId()); + if ($user === null || !$user->isEnabled()) { + $this->logger->debug(sprintf( + 'Account %d of user %s could not be found or was disabled, skipping preprocessing of messages', + $account->getId(), + $account->getUserId() + )); + return; + } + + $dbAccount = $account->getMailAccount(); + if (!is_null($dbAccount->getProvisioningId()) && $dbAccount->getInboundPassword() === null) { + $this->logger->info("Ignoring preprocessing job for provisioned account that has no password set yet"); + return; + } + + $limitTimestamp = $this->time->getTime() - (60 * 60 * 24 * 14); // Two weeks into the past + $this->preprocessingService->process($limitTimestamp, $account); + } +} diff --git a/lib/Db/MailboxMapper.php b/lib/Db/MailboxMapper.php index 52e0b814cd..d8c2ce8952 100644 --- a/lib/Db/MailboxMapper.php +++ b/lib/Db/MailboxMapper.php @@ -35,6 +35,7 @@ use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\QBMapper; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\DB\QueryBuilder\IQueryFunction; use OCP\IDBConnection; @@ -128,6 +129,23 @@ public function findById(int $id): Mailbox { } } + /** + * @return Mailbox[] + * + * @throws Exception + */ + public function findByIds(array $ids): array { + $qb = $this->db->getQueryBuilder(); + + $select = $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY) + ); + return $this->findEntities($select); + } + + /** * @param int $id * @param string $uid diff --git a/lib/Db/Message.php b/lib/Db/Message.php index c01f7650ba..d72ad5403f 100644 --- a/lib/Db/Message.php +++ b/lib/Db/Message.php @@ -76,6 +76,12 @@ * @method null|string getPreviewText() * @method void setUpdatedAt(int $time) * @method int getUpdatedAt() + * @method bool isImipMessage() + * @method void setImipMessage(bool $imipMessage) + * @method bool isImipProcessed() + * @method void setImipProcessed(bool $imipProcessed) + * @method bool isImipError() + * @method void setImipError(bool $imipError) */ class Message extends Entity implements JsonSerializable { private const MUTABLE_FLAGS = [ @@ -114,6 +120,9 @@ class Message extends Entity implements JsonSerializable { protected $flagImportant = false; protected $flagMdnsent; protected $previewText; + protected $imipMessage = false; + protected $imipProcessed = false; + protected $imipError = false; /** @var AddressList */ private $from; @@ -152,6 +161,9 @@ public function __construct() { $this->addType('flagImportant', 'boolean'); $this->addType('flagMdnsent', 'boolean'); $this->addType('updatedAt', 'integer'); + $this->addType('imipMessage', 'boolean'); + $this->addType('imipProcessed', 'boolean'); + $this->addType('imipError', 'boolean'); } /** @@ -316,6 +328,7 @@ function (Tag $tag) { 'inReplyTo' => $this->getInReplyTo(), 'references' => empty($this->getReferences()) ? null: json_decode($this->getReferences(), true), 'threadRootId' => $this->getThreadRootId(), + 'imipMessage' => $this->isImipMessage(), 'previewText' => $this->getPreviewText(), ]; } diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index 6c6de1b201..5d62e9093a 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -312,7 +312,6 @@ public function insertBulk(Account $account, Message ...$messages): void { $qb1->setParameter('flag_notjunk', $message->getFlagNotjunk(), IQueryBuilder::PARAM_BOOL); $qb1->setParameter('flag_important', $message->getFlagImportant(), IQueryBuilder::PARAM_BOOL); $qb1->setParameter('flag_mdnsent', $message->getFlagMdnsent(), IQueryBuilder::PARAM_BOOL); - $qb1->execute(); $messageId = $qb1->getLastInsertId(); @@ -482,6 +481,7 @@ public function updatePreviewDataBulk(Message ...$messages): array { ->set('preview_text', $query->createParameter('preview_text')) ->set('structure_analyzed', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)) ->set('updated_at', $query->createNamedParameter($this->timeFactory->getTime(), IQueryBuilder::PARAM_INT)) + ->set('imip_message', $query->createParameter('imip_message')) ->where($query->expr()->andX( $query->expr()->eq('uid', $query->createParameter('uid')), $query->expr()->eq('mailbox_id', $query->createParameter('mailbox_id')) @@ -505,6 +505,7 @@ public function updatePreviewDataBulk(Message ...$messages): array { $previewText, $previewText === null ? IQueryBuilder::PARAM_NULL : IQueryBuilder::PARAM_STR ); + $query->setParameter('imip_message', $message->isImipMessage(), IQueryBuilder::PARAM_BOOL); $query->execute(); } @@ -520,6 +521,50 @@ public function updatePreviewDataBulk(Message ...$messages): array { return $messages; } + /** + * @param Message ...$messages + * + * @return Message[] + */ + public function updateImipData(Message ...$messages): array { + $this->db->beginTransaction(); + + try { + $query = $this->db->getQueryBuilder(); + $query->update($this->getTableName()) + ->set('imip_message', $query->createParameter('imip_message')) + ->set('imip_error', $query->createParameter('imip_error')) + ->set('imip_processed', $query->createParameter('imip_processed')) + ->where($query->expr()->andX( + $query->expr()->eq('uid', $query->createParameter('uid')), + $query->expr()->eq('mailbox_id', $query->createParameter('mailbox_id')) + )); + + foreach ($messages as $message) { + if (empty($message->getUpdatedFields())) { + // Micro optimization + continue; + } + + $query->setParameter('uid', $message->getUid(), IQueryBuilder::PARAM_INT); + $query->setParameter('mailbox_id', $message->getMailboxId(), IQueryBuilder::PARAM_INT); + $query->setParameter('imip_message', $message->isImipMessage(), IQueryBuilder::PARAM_BOOL); + $query->setParameter('imip_error', $message->isImipError(), IQueryBuilder::PARAM_BOOL); + $query->setParameter('imip_processed', $message->isImipProcessed(), IQueryBuilder::PARAM_BOOL); + $query->execute(); + } + + $this->db->commit(); + } catch (Throwable $e) { + // Make sure to always roll back, otherwise the outer code runs in a failed transaction + $this->db->rollBack(); + + throw $e; + } + + return $messages; + } + public function resetPreviewDataFlag(): void { $qb = $this->db->getQueryBuilder(); $update = $qb->update($this->getTableName()) @@ -1232,4 +1277,45 @@ public function resetInReplyTo(): int { ); return $update->execute(); } + + /** + * Get all iMIP messages from the last two weeks + * that haven't been processed yet + * @return Message[] + */ + public function findIMipMessagesAscending(): array { + $time = $this->timeFactory->getTime() - 60 * 60 * 24 * 14; + $qb = $this->db->getQueryBuilder(); + + $select = $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('imip_message', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL), IQueryBuilder::PARAM_BOOL), + $qb->expr()->eq('imip_processed', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL), IQueryBuilder::PARAM_BOOL), + $qb->expr()->eq('imip_error', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL), IQueryBuilder::PARAM_BOOL), + $qb->expr()->eq('flag_junk', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL), IQueryBuilder::PARAM_BOOL), + $qb->expr()->gt('sent_at', $qb->createNamedParameter($time, IQueryBuilder::PARAM_INT)), + )->orderBy('sent_at', 'ASC'); // make sure we don't process newer messages first + + return $this->findEntities($select); + } + + /** + * @return Message[] + * + * @throws \OCP\DB\Exception + */ + public function getUnanalyzed(int $lastRun, array $mailboxIds): array { + $qb = $this->db->getQueryBuilder(); + + $select = $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->lte('sent_at', $qb->createNamedParameter($lastRun. IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + $qb->expr()->eq('structure_analyzed', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL), IQueryBuilder::PARAM_BOOL), + $qb->expr()->in('mailbox_id', $qb->createNamedParameter($mailboxIds, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY), + )->orderBy('sent_at', 'ASC'); + + return $this->findEntities($select); + } } diff --git a/lib/IMAP/MessageMapper.php b/lib/IMAP/MessageMapper.php index d70e6036d6..b3a10bd571 100644 --- a/lib/IMAP/MessageMapper.php +++ b/lib/IMAP/MessageMapper.php @@ -665,6 +665,10 @@ public function getBodyStructureData(Horde_Imap_Client_Socket $client, array $uids): array { $structureQuery = new Horde_Imap_Client_Fetch_Query(); $structureQuery->structure(); + $structureQuery->headerText([ + 'cache' => true, + 'peek' => true, + ]); $structures = $client->fetch($mailbox, $structureQuery, [ 'ids' => new Horde_Imap_Client_Ids($uids), @@ -673,19 +677,28 @@ public function getBodyStructureData(Horde_Imap_Client_Socket $client, return array_map(function (Horde_Imap_Client_Data_Fetch $fetchData) use ($mailbox, $client) { $hasAttachments = false; $text = ''; + $isImipMessage = false; $structure = $fetchData->getStructure(); - foreach ($structure as $part) { - if ($part instanceof Horde_Mime_Part && $part->isAttachment()) { + /** @var Horde_Mime_Part $part */ + foreach ($structure->getParts() as $part) { + if ($part->isAttachment()) { $hasAttachments = true; - break; + } + $bodyParts = $part->getParts(); + /** @var Horde_Mime_Part $bodyPart */ + foreach ($bodyParts as $bodyPart) { + $contentParameters = $bodyPart->getAllContentTypeParameters(); + if ($bodyPart->getType() === 'text/calendar' && isset($contentParameters['method'])) { + $isImipMessage = true; + } } } $textBodyId = $structure->findBody() ?? $structure->findBody('text'); $htmlBodyId = $structure->findBody('html'); if ($textBodyId === null && $htmlBodyId === null) { - return new MessageStructureData($hasAttachments, $text); + return new MessageStructureData($hasAttachments, $text, $isImipMessage); } if ($htmlBodyId !== null) { $partsQuery = new Horde_Imap_Client_Fetch_Query(); @@ -720,7 +733,7 @@ public function getBodyStructureData(Horde_Imap_Client_Socket $client, } $structure->setContents($htmlBody); $html = new Html2Text($structure->getContents()); - return new MessageStructureData($hasAttachments, trim($html->getText())); + return new MessageStructureData($hasAttachments, trim($html->getText()), $isImipMessage); } $textBody = $part->getBodyPart($textBodyId); if (!empty($textBody)) { @@ -728,12 +741,12 @@ public function getBodyStructureData(Horde_Imap_Client_Socket $client, if ($enc = $mimeHeaders->getValue('content-transfer-encoding')) { $structure->setTransferEncoding($enc); $structure->setContents($textBody); - return new MessageStructureData($hasAttachments, $structure->getContents()); + return new MessageStructureData($hasAttachments, $structure->getContents(), $isImipMessage); } - return new MessageStructureData($hasAttachments, $textBody); + return new MessageStructureData($hasAttachments, $textBody, $isImipMessage); } - return new MessageStructureData($hasAttachments, $text); + return new MessageStructureData($hasAttachments, $text, $isImipMessage); }, iterator_to_array($structures->getIterator())); } } diff --git a/lib/IMAP/MessageStructureData.php b/lib/IMAP/MessageStructureData.php index 9466497036..3c092a237a 100644 --- a/lib/IMAP/MessageStructureData.php +++ b/lib/IMAP/MessageStructureData.php @@ -32,10 +32,15 @@ class MessageStructureData { /** @var string */ private $previewText; + /** @var bool */ + private $isImipMessage; + public function __construct(bool $hasAttachments, - string $previewText) { + string $previewText, + bool $isImipMessage) { $this->hasAttachments = $hasAttachments; $this->previewText = $previewText; + $this->isImipMessage = $isImipMessage; } public function hasAttachments(): bool { @@ -45,4 +50,8 @@ public function hasAttachments(): bool { public function getPreviewText(): string { return $this->previewText; } + + public function isImipMessage(): bool { + return $this->isImipMessage; + } } diff --git a/lib/IMAP/PreviewEnhancer.php b/lib/IMAP/PreviewEnhancer.php index 23b0ddbef4..a8c2cd531d 100644 --- a/lib/IMAP/PreviewEnhancer.php +++ b/lib/IMAP/PreviewEnhancer.php @@ -109,6 +109,7 @@ public function process(Account $account, Mailbox $mailbox, array $messages): ar $message->setFlagAttachments($structureData->hasAttachments()); $message->setPreviewText($structureData->getPreviewText()); $message->setStructureAnalyzed(true); + $message->setImipMessage($structureData->isImipMessage()); return $message; }, $messages)); diff --git a/lib/Migration/FixBackgroundJobs.php b/lib/Migration/FixBackgroundJobs.php index 121fe601d7..403b8afe1a 100644 --- a/lib/Migration/FixBackgroundJobs.php +++ b/lib/Migration/FixBackgroundJobs.php @@ -25,6 +25,7 @@ namespace OCA\Mail\Migration; +use OCA\Mail\BackgroundJob\PreviewEnhancementProcessingJob; use OCA\Mail\BackgroundJob\SyncJob; use OCA\Mail\BackgroundJob\TrainImportanceClassifierJob; use OCA\Mail\Db\MailAccount; @@ -59,6 +60,7 @@ public function run(IOutput $output) { foreach ($accounts as $account) { $this->jobList->add(SyncJob::class, ['accountId' => $account->getId()]); $this->jobList->add(TrainImportanceClassifierJob::class, ['accountId' => $account->getId()]); + $this->jobList->add(PreviewEnhancementProcessingJob::class, ['accountId' => $account->getId()]); $output->advance(); } $output->finishProgress(); diff --git a/lib/Migration/Version2000Date20220908130842.php b/lib/Migration/Version2000Date20220908130842.php new file mode 100644 index 0000000000..133f2074b9 --- /dev/null +++ b/lib/Migration/Version2000Date20220908130842.php @@ -0,0 +1,66 @@ + + * + * @author Anna Larch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version2000Date20220908130842 extends SimpleMigrationStep { + /** + * @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 { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $messagesTable = $schema->getTable('mail_messages'); + if (!$messagesTable->hasColumn('imip_message')) { + $messagesTable->addColumn('imip_message', 'boolean', [ + 'notnull' => false, + 'default' => false, + ]); + } + if (!$messagesTable->hasColumn('imip_processed')) { + $messagesTable->addColumn('imip_processed', 'boolean', [ + 'notnull' => false, + 'default' => false, + ]); + } + if (!$messagesTable->hasColumn('imip_error')) { + $messagesTable->addColumn('imip_error', 'boolean', [ + 'notnull' => false, + 'default' => false, + ]); + } + return $schema; + } +} diff --git a/lib/Model/IMAPMessage.php b/lib/Model/IMAPMessage.php index 95ac594c8d..c639bc8f10 100644 --- a/lib/Model/IMAPMessage.php +++ b/lib/Model/IMAPMessage.php @@ -732,6 +732,22 @@ public function setInReplyTo(string $id) { throw new Exception('not implemented'); } + /** + * @return AddressList + */ + public function getReplyTo() { + return AddressList::fromHorde($this->getEnvelope()->reply_to); + } + + /** + * @param string $id + * + * @return void + */ + public function setReplyTo(string $id) { + throw new Exception('not implemented'); + } + /** * Cast all values from an IMAP message into the correct DB format * @@ -779,6 +795,9 @@ public function toDbMessage(int $mailboxId, MailAccount $account): Message { $msg->setFlagImportant(in_array('$important', $flags, true) || in_array('$labelimportant', $flags, true) || in_array(Tag::LABEL_IMPORTANT, $flags, true)); $msg->setFlagAttachments(false); $msg->setFlagMdnsent(in_array(Horde_Imap_Client::FLAG_MDNSENT, $flags, true)); + if (!empty($this->scheduling)) { + $msg->setImipMessage(true); + } $allowed = [ Horde_Imap_Client::FLAG_ANSWERED, diff --git a/lib/Service/IMipService.php b/lib/Service/IMipService.php new file mode 100644 index 0000000000..1005475135 --- /dev/null +++ b/lib/Service/IMipService.php @@ -0,0 +1,169 @@ + + * + * @author Anna Larch + * + * 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 . + * + */ + +namespace OCA\Mail\Service; + +use OCA\Mail\Account; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\Db\Message; +use OCA\Mail\Db\MessageMapper; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Model\IMAPMessage; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\Calendar\IManager; +use Psr\Log\LoggerInterface; + +class IMipService { + private AccountService $accountService; + private IManager $calendarManager; + private LoggerInterface $logger; + private MailboxMapper $mailboxMapper; + private MailManager $mailManager; + private MessageMapper $messageMapper; + + public function __construct( + AccountService $accountService, + IManager $manager, + LoggerInterface $logger, + MailboxMapper $mailboxMapper, + MailManager $mailManager, + MessageMapper $messageMapper + ) { + $this->accountService = $accountService; + $this->calendarManager = $manager; + $this->logger = $logger; + $this->mailboxMapper = $mailboxMapper; + $this->mailManager = $mailManager; + $this->messageMapper = $messageMapper; + } + + public function process(): void { + $messages = $this->messageMapper->findIMipMessagesAscending(); + if (empty($messages)) { + $this->logger->info('No iMIP messages to process.'); + return; + } + + // Collect all mailboxes in memory + // possible perf improvement - make this one IN query + // and JOIN with accounts table + // although this might not make much of a difference + // since there are very few messages to process + $mailboxIds = array_unique(array_map(function (Message $message) { + return $message->getMailboxId(); + }, $messages)); + + $mailboxes = array_map(function (int $mailboxId) { + try { + return $this->mailboxMapper->findById($mailboxId); + } catch (DoesNotExistException | ServiceException $e) { + return null; + } + }, $mailboxIds); + + // Collect all accounts in memory + $accountIds = array_unique(array_map(function (Mailbox $mailbox) { + return $mailbox->getAccountId(); + }, $mailboxes)); + + $accounts = array_combine($accountIds, array_map(function (int $accountId) { + try { + return $this->accountService->findById($accountId); + } catch (DoesNotExistException $e) { + return null; + } + }, $accountIds)); + + /** @var Mailbox $mailbox */ + foreach ($mailboxes as $mailbox) { + /** @var Account $account */ + $account = $accounts[$mailbox->getAccountId()]; + $filteredMessages = array_filter($messages, function ($message) use ($mailbox) { + return $message->getMailboxId() === $mailbox->getId(); + }); + + if (empty($filteredMessages)) { + continue; + } + + // Check for accounts or mailboxes that no longer exist, + // no processing for drafts, sent items, junk or archive + if ($account === null + || $account->getMailAccount()->getTrashMailboxId() === $mailbox->getId() + || $account->getMailAccount()->getSentMailboxId() === $mailbox->getId() + || $account->getMailAccount()->getDraftsMailboxId() === $mailbox->getId() + || $mailbox->isSpecialUse(\Horde_Imap_Client::SPECIALUSE_ARCHIVE) + ) { + $processedMessages = array_map(function (Message $message) { + $message->setImipProcessed(true); + return $message; + }, $filteredMessages); // Silently drop from passing to DAV and mark as processed, so we won't run into these messages again. + $this->messageMapper->updateImipData(...$processedMessages); + continue; + } + + try { + $imapMessages = $this->mailManager->getImapMessagesForScheduleProcessing($account, $mailbox, array_map(function ($message) { + return $message->getUid(); + }, $filteredMessages)); + } catch (ServiceException $e) { + $this->logger->error('Could not get IMAP messages form IMAP server', ['exception' => $e]); + continue; + } + + foreach ($filteredMessages as $message) { + /** @var IMAPMessage $imapMessage */ + $imapMessage = current(array_filter($imapMessages, function (IMAPMessage $imapMessage) use ($message) { + return $message->getUid() === $imapMessage->getUid(); + })); + if (empty($imapMessage->scheduling)) { + // No scheduling info, maybe the DB is wrong + $message->setImipError(true); + continue; + } + + $principalUri = 'principals/users/' . $account->getUserId(); + $sender = $imapMessage->getFrom()->first()->getEmail(); + $recipient = $account->getEmail(); + foreach ($imapMessage->scheduling as $schedulingInfo) { // an IMAP message could contain more than one iMIP object + if ($schedulingInfo['method'] === 'REPLY') { + $processed = $this->calendarManager->handleIMipReply($principalUri, $sender, $recipient, $schedulingInfo['contents']); + $message->setImipProcessed($processed); + $message->setImipError(!$processed); + } elseif ($schedulingInfo['method'] === 'CANCEL') { + $replyTo = $imapMessage->getReplyTo()->first(); + $replyTo = !empty($replyTo) ? $replyTo->getEmail() : null; + $processed = $this->calendarManager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $schedulingInfo['contents']); + $message->setImipProcessed($processed); + $message->setImipError(!$processed); + } + } + } + $this->messageMapper->updateImipData(...$filteredMessages); + } + } +} diff --git a/lib/Service/MailManager.php b/lib/Service/MailManager.php index 883d985544..cbe5229d4c 100644 --- a/lib/Service/MailManager.php +++ b/lib/Service/MailManager.php @@ -26,6 +26,7 @@ use Horde_Imap_Client; use Horde_Imap_Client_Exception; use Horde_Imap_Client_Exception_NoSupportExtension; +use Horde_Imap_Client_Ids; use Horde_Imap_Client_Socket; use OCA\Mail\Account; use OCA\Mail\Contracts\IMailManager; @@ -193,6 +194,35 @@ public function getImapMessage(Account $account, } } + /** + * @param Account $account + * @param Mailbox $mailbox + * @param int[] $uids + * @return IMAPMessage[] + * @throws ServiceException + */ + public function getImapMessagesForScheduleProcessing(Account $account, + Mailbox $mailbox, + array $uids): array { + $client = $this->imapClientFactory->getClient($account); + try { + return $this->imapMessageMapper->findByIds( + $client, + $mailbox->getName(), + new Horde_Imap_Client_Ids($uids), + true + ); + } catch (Horde_Imap_Client_Exception $e) { + throw new ServiceException( + 'Could not load messages: ' . $e->getMessage(), + (int)$e->getCode(), + $e + ); + } finally { + $client->logout(); + } + } + public function getThread(Account $account, string $threadRootId): array { return $this->dbMessageMapper->findThread($account, $threadRootId); } diff --git a/lib/Service/PreprocessingService.php b/lib/Service/PreprocessingService.php new file mode 100644 index 0000000000..e37a51ad22 --- /dev/null +++ b/lib/Service/PreprocessingService.php @@ -0,0 +1,85 @@ + + * * + * * @author Anna Larch + * * + * * 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 . + * * + * + */ + +namespace OCA\Mail\Service; + +use OCA\Mail\Account; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\Db\MessageMapper; +use OCA\Mail\IMAP\PreviewEnhancer; +use Psr\Log\LoggerInterface; + +class PreprocessingService { + private MailboxMapper $mailboxMapper; + private MessageMapper $messageMapper; + private LoggerInterface $logger; + private PreviewEnhancer $previewEnhancer; + + public function __construct( + MessageMapper $messageMapper, + LoggerInterface $logger, + MailboxMapper $mailboxMapper, + PreviewEnhancer $previewEnhancer + ) { + $this->messageMapper = $messageMapper; + $this->logger = $logger; + $this->mailboxMapper = $mailboxMapper; + $this->previewEnhancer = $previewEnhancer; + } + + public function process(int $limitTimestamp, Account $account): void { + $mailboxes = $this->mailboxMapper->findAll($account); + if (empty($mailboxes)) { + $this->logger->debug('No mailboxes found.'); + return; + } + $mailboxIds = array_unique(array_map(function (Mailbox $mailbox) { + return $mailbox->getId(); + }, $mailboxes)); + + + $messages = $this->messageMapper->getUnanalyzed($limitTimestamp, $mailboxIds); + if (empty($messages)) { + $this->logger->debug('No structure data to analyse.'); + return; + } + + foreach ($mailboxes as $mailbox) { + $filteredMessages = array_filter($messages, function ($message) use ($mailbox) { + return $message->getMailboxId() === $mailbox->getId(); + }); + + if (empty($filteredMessages)) { + continue; + } + + $processedMessages = $this->previewEnhancer->process($account, $mailbox, $filteredMessages); + $this->logger->debug('Processed ' . count($processedMessages) . ' messages for structure data for mailbox ' . $mailbox->getId()); + } + } +} diff --git a/tests/Unit/Job/PreviewEnhancementProcessingJobTest.php b/tests/Unit/Job/PreviewEnhancementProcessingJobTest.php new file mode 100644 index 0000000000..e9dfd57558 --- /dev/null +++ b/tests/Unit/Job/PreviewEnhancementProcessingJobTest.php @@ -0,0 +1,172 @@ + + * * + * * @author Anna Larch + * * + * * 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 . + * * + * + */ + +namespace OCA\Mail\Tests\Unit\Job; + +use OCA\Mail\Account; +use OCA\Mail\BackgroundJob\PreviewEnhancementProcessingJob; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\PreprocessingService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\IUser; +use OCP\IUserManager; +use PHPUnit\Framework\MockObject\MockObject; +use ChristophWurst\Nextcloud\Testing\TestCase; +use Psr\Log\LoggerInterface; + +class PreviewEnhancementProcessingJobTest extends TestCase { + /** @var ITimeFactory|ITimeFactory&MockObject|MockObject */ + private $time; + + /** @var IUserManager|IUserManager&MockObject|MockObject */ + private $manager; + + /** @var AccountService|AccountService&MockObject|MockObject */ + private $accountService; + + /** @var PreprocessingService|PreprocessingService&MockObject|MockObject */ + private $preprocessingService; + + /** @var MockObject|LoggerInterface|LoggerInterface&MockObject */ + private $logger; + + /** @var IJobList|IJobList&MockObject|MockObject */ + private $jobList; + + /** @var int[] */ + private static $argument; + + public function setUp(): void { + parent::setUp(); + $this->time = $this->createMock(ITimeFactory::class); + $this->manager = $this->createMock(IUserManager::class); + $this->accountService = $this->createMock(AccountService::class); + $this->preprocessingService = $this->createMock(PreprocessingService::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->jobList = $this->createMock(IJobList::class); + $this->job = new PreviewEnhancementProcessingJob( + $this->time, + $this->manager, + $this->accountService, + $this->preprocessingService, + $this->logger, + $this->jobList + ); + + self::$argument = ['accountId' => 1]; + } + + public function testNoAccount(): void { + $this->accountService->expects(self::once()) + ->method('findById') + ->with(self::$argument['accountId']) + ->willThrowException(new DoesNotExistException('Account does not exist')); + $this->logger->expects(self::once()) + ->method('debug'); + $this->jobList->expects(self::once()) + ->method('remove'); + + $this->job->run(self::$argument); + } + + public function testNoUser(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId(1); + $account = new Account($mailAccount); + + $this->accountService->expects(self::once()) + ->method('findById') + ->with(self::$argument['accountId']) + ->willReturn($account); + $this->manager->expects(self::once()) + ->method('get') + ->with($account->getUserId()) + ->willReturn(null); + $this->logger->expects(self::once()) + ->method('debug'); + + $this->job->run(self::$argument); + } + + public function testProvisionedNoPassword(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId(1); + $mailAccount->setProvisioningId(1); + $mailAccount->setInboundPassword(null); + $account = new Account($mailAccount); + $user = $this->createMock(IUser::class); + $user->setEnabled(); + + $this->accountService->expects(self::once()) + ->method('findById') + ->with(self::$argument['accountId']) + ->willReturn($account); + $this->manager->expects(self::once()) + ->method('get') + ->with($account->getUserId()) + ->willReturn($user); + $user->expects(self::once()) + ->method('isEnabled') + ->willReturn(true); + $this->logger->expects(self::once()) + ->method('info'); + + $this->job->run(self::$argument); + } + + public function testProcessing(): void { + $mailAccount = new MailAccount(); + $mailAccount->setUserId(1); + $account = new Account($mailAccount); + $time = time(); + $user = $this->createMock(IUser::class); + $user->setEnabled(); + + $this->accountService->expects(self::once()) + ->method('findById') + ->with(self::$argument['accountId']) + ->willReturn($account); + $this->manager->expects(self::once()) + ->method('get') + ->with($account->getUserId()) + ->willReturn($user); + $user->expects(self::once()) + ->method('isEnabled') + ->willReturn(true); + $this->time->expects(self::once()) + ->method('getTime') + ->willReturn($time); + $this->preprocessingService->expects(self::once()) + ->method('process') + ->with(($time - (60 * 60 * 24 * 14)), $account); + $this->logger->expects(self::never()) + ->method('error'); + + $this->job->run(self::$argument); + } +} diff --git a/tests/Unit/Service/IMipServiceTest.php b/tests/Unit/Service/IMipServiceTest.php new file mode 100644 index 0000000000..2f39b5b2c6 --- /dev/null +++ b/tests/Unit/Service/IMipServiceTest.php @@ -0,0 +1,458 @@ + + * * + * * @author Anna Larch + * * + * * 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 . + * * + * + */ + +declare(strict_types=1); + +/** + * @copyright 2022 Anna Larch + * + * @author 2022 Anna Larch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Tests\Service; + +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Account; +use OCA\Mail\Address; +use OCA\Mail\AddressList; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\Db\Message; +use OCA\Mail\Db\MessageMapper; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Model\IMAPMessage; +use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\IMipService; +use OCA\Mail\Service\MailManager; +use OCP\Calendar\IManager; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; + +class IMipServiceTest extends TestCase { + /** @var MailboxMapper|MockObject */ + private $mailboxMapper; + + /** @var MessageMapper|MockObject */ + private $messageMapper; + + /** @var AccountService|MockObject */ + private $accountService; + + /** @var MailManager|MockObject */ + private $mailManager; + + /** @var MockObject|LoggerInterface */ + private $logger; + + private IMipService $service; + + protected function setUp(): void { + parent::setUp(); + + // iMIP is NC25+ + if (!method_exists(IManager::class, 'handleImipReply')) { + self::markTestIncomplete(); + } + + + $this->accountService = $this->createMock(AccountService::class); + $this->calendarManager = $this->createMock(IManager::class); + $this->mailboxMapper = $this->createMock(MailboxMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->mailManager = $this->createMock(MailManager::class); + $this->messageMapper = $this->createMock(MessageMapper::class); + + $this->service = new IMipService( + $this->accountService, + $this->calendarManager, + $this->logger, + $this->mailboxMapper, + $this->mailManager, + $this->messageMapper + ); + } + + public function testNoSchedulingInformation(): void { + $this->messageMapper->expects(self::once()) + ->method('findIMipMessagesAscending') + ->willReturn([]); + $this->logger->expects(self::once()) + ->method('info'); + $this->mailboxMapper->expects(self::never()) + ->method('findById'); + $this->accountService->expects(self::never()) + ->method('findById'); + $this->calendarManager->expects(self::never()) + ->method('handleIMipReply'); + $this->calendarManager->expects(self::never()) + ->method('handleIMipCancel'); + $this->messageMapper->expects(self::never()) + ->method('updateImipData'); + + $this->service->process(); + } + + public function testIsSpecialUse(): void { + $message = new Message(); + $message->setImipMessage(true); + $message->setUid(1); + $message->setMailboxId(100); + $mailbox = new Mailbox(); + $mailbox->setId(100); + $mailbox->setAccountId(200); + $mailbox->setSpecialUse('["sent"]'); + $mailAccount = new MailAccount(); + $mailAccount->setDraftsMailboxId(100); + $account = new Account($mailAccount); + + $this->messageMapper->expects(self::once()) + ->method('findIMipMessagesAscending') + ->willReturn([$message]); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->with($message->getMailboxId()) + ->willReturn($mailbox); + $this->accountService->expects(self::once()) + ->method('findById') + ->with($mailbox->getAccountId()) + ->willReturn($account); + $this->messageMapper->expects(self::once()) + ->method('updateImipData'); + $this->calendarManager->expects(self::never()) + ->method('handleIMipReply'); + $this->calendarManager->expects(self::never()) + ->method('handleIMipCancel'); + + $this->service->process(); + } + + public function testIsArchive(): void { + $message = new Message(); + $message->setImipMessage(true); + $message->setUid(1); + $message->setMailboxId(100); + $mailbox = new Mailbox(); + $mailbox->setId(100); + $mailbox->setAccountId(200); + $mailbox->setSpecialUse('["archive"]'); + $mailAccount = new MailAccount(); + $account = new Account($mailAccount); + + $this->messageMapper->expects(self::once()) + ->method('findIMipMessagesAscending') + ->willReturn([$message]); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->with($message->getMailboxId()) + ->willReturn($mailbox); + $this->accountService->expects(self::once()) + ->method('findById') + ->with($mailbox->getAccountId()) + ->willReturn($account); + $this->messageMapper->expects(self::once()) + ->method('updateImipData'); + $this->calendarManager->expects(self::never()) + ->method('handleIMipReply'); + $this->calendarManager->expects(self::never()) + ->method('handleIMipCancel'); + + $this->service->process(); + } + + public function testNoSchedulingInfo(): void { + $message = new Message(); + $message->setImipMessage(true); + $message->setUid(1); + $message->setMailboxId(100); + $mailbox = new Mailbox(); + $mailbox->setId(100); + $mailbox->setAccountId(200); + $account = $this->createConfiguredMock(Account::class, [ + 'getId' => 200, + 'getEmail' => 'dimitrius@stardew-science.com' + ]); + $imapMessage = $this->createConfiguredMock(IMAPMessage::class, [ + 'getUid' => 1 + ]); + $imapMessage->scheduling = []; + + $this->messageMapper->expects(self::once()) + ->method('findIMipMessagesAscending') + ->willReturn([$message]); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->willReturn($mailbox); + $this->accountService->expects(self::once()) + ->method('findById') + ->willReturn($account); + $this->mailManager->expects(self::once()) + ->method('getImapMessagesForScheduleProcessing') + ->with($account, $mailbox, [$message->getUid()]) + ->willReturn([$imapMessage]); + $this->calendarManager->expects(self::never()) + ->method('handleIMipReply'); + $this->calendarManager->expects(self::never()) + ->method('handleIMipCancel'); + $this->messageMapper->expects(self::once()) + ->method('updateImipData') + ->with($message); + + $this->service->process(); + } + + public function testImapConnectionServiceException(): void { + $message = new Message(); + $message->setImipMessage(true); + $message->setUid(1); + $message->setMailboxId(100); + $mailbox = new Mailbox(); + $mailbox->setId(100); + $mailbox->setAccountId(200); + $account = $this->createConfiguredMock(Account::class, [ + 'getId' => 200, + 'getEmail' => 'dimitrius@stardew-science.com' + ]); + $imapMessage = $this->createConfiguredMock(IMAPMessage::class, [ + 'getUid' => 1 + ]); + $imapMessage->scheduling = []; + + $this->messageMapper->expects(self::once()) + ->method('findIMipMessagesAscending') + ->willReturn([$message]); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->willReturn($mailbox); + $this->accountService->expects(self::once()) + ->method('findById') + ->willReturn($account); + $this->mailManager->expects(self::once()) + ->method('getImapMessagesForScheduleProcessing') + ->willThrowException(new ServiceException()); + $this->logger->expects(self::once()) + ->method('error'); + $this->calendarManager->expects(self::never()) + ->method('handleIMipReply'); + $this->calendarManager->expects(self::never()) + ->method('handleIMipCancel'); + $this->messageMapper->expects(self::never()) + ->method('updateImipData'); + + $this->service->process(); + } + + public function testIsRequest(): void { + $message = new Message(); + $message->setImipMessage(true); + $message->setUid(1); + $message->setMailboxId(100); + $mailbox = new Mailbox(); + $mailbox->setId(100); + $mailbox->setAccountId(200); + $mailAccount = new MailAccount(); + $mailAccount->setId(200); + $account = new Account($mailAccount); + $imapMessage = $this->createMock(IMAPMessage::class); + $imapMessage->scheduling[] = ['method' => 'REQUEST']; + $addressList = $this->createMock(AddressList::class); + $address = $this->createMock(Address::class); + + $this->messageMapper->expects(self::once()) + ->method('findIMipMessagesAscending') + ->willReturn([$message]); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->willReturn($mailbox); + $this->accountService->expects(self::once()) + ->method('findById') + ->willReturn($account); + $this->mailManager->expects(self::once()) + ->method('getImapMessagesForScheduleProcessing') + ->with($account, $mailbox, [$message->getUid()]) + ->willReturn([$imapMessage]); + $imapMessage->expects(self::once()) + ->method('getUid') + ->willReturn(1); + $imapMessage->expects(self::once()) + ->method('getFrom') + ->willReturn($addressList); + $addressList->expects(self::once()) + ->method('first') + ->willReturn($address); + $address->expects(self::once()) + ->method('getEmail') + ->willReturn('pam@stardew-bus-company.com'); + $this->logger->expects(self::never()) + ->method('info'); + $this->calendarManager->expects(self::never()) + ->method('handleIMipReply'); + $this->calendarManager->expects(self::never()) + ->method('handleIMipCancel'); + $this->messageMapper->expects(self::never()) + ->method('updateBulk'); + + $this->service->process(); + } + + public function testIsReply(): void { + $message = new Message(); + $message->setImipMessage(true); + $message->setUid(1); + $message->setMailboxId(100); + $mailbox = new Mailbox(); + $mailbox->setId(100); + $mailbox->setAccountId(200); + $mailAccount = new MailAccount(); + $mailAccount->setId(200); + $mailAccount->setEmail('vincent@stardew-valley.edu'); + $mailAccount->setUserId('vincent'); + $account = new Account($mailAccount); + $imapMessage = $this->createMock(IMAPMessage::class); + $imapMessage->scheduling[] = ['method' => 'REPLY', 'contents' => 'VCARD']; + $addressList = $this->createMock(AddressList::class); + $address = $this->createMock(Address::class); + + $this->messageMapper->expects(self::once()) + ->method('findIMipMessagesAscending') + ->willReturn([$message]); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->willReturn($mailbox); + $this->accountService->expects(self::once()) + ->method('findById') + ->willReturn($account); + $this->mailManager->expects(self::once()) + ->method('getImapMessagesForScheduleProcessing') + ->with($account, $mailbox, [$message->getUid()]) + ->willReturn([$imapMessage]); + $imapMessage->expects(self::once()) + ->method('getUid') + ->willReturn(1); + $this->logger->expects(self::never()) + ->method('info'); + $imapMessage->expects(self::once()) + ->method('getFrom') + ->willReturn($addressList); + $addressList->expects(self::once()) + ->method('first') + ->willReturn($address); + $address->expects(self::once()) + ->method('getEmail') + ->willReturn('pam@stardew-bus-service.com'); + $imapMessage->expects(self::never()) + ->method('getInReplyTo') + ->willReturn($addressList); + $this->calendarManager->expects(self::once()) + ->method('handleIMipReply') + ->with('principals/users/vincent', + 'pam@stardew-bus-service.com', + $account->getEmail(), + $imapMessage->scheduling[0]['contents']); + $this->calendarManager->expects(self::never()) + ->method('handleIMipCancel'); + $this->messageMapper->expects(self::once()) + ->method('updateImipData'); + + $this->service->process(); + } + + public function testIsCancel(): void { + $message = new Message(); + $message->setImipMessage(true); + $message->setUid(1); + $message->setMailboxId(100); + $mailbox = new Mailbox(); + $mailbox->setId(100); + $mailbox->setAccountId(200); + $mailAccount = new MailAccount(); + $mailAccount->setId(200); + $mailAccount->setEmail('vincent@stardew-valley.edu'); + $mailAccount->setUserId('vincent'); + $account = new Account($mailAccount); + $imapMessage = $this->createMock(IMAPMessage::class); + $imapMessage->scheduling[] = ['method' => 'CANCEL', 'contents' => 'VCARD']; + $addressList = $this->createMock(AddressList::class); + $address = $this->createMock(Address::class); + + $this->messageMapper->expects(self::once()) + ->method('findIMipMessagesAscending') + ->willReturn([$message]); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->willReturn($mailbox); + $this->accountService->expects(self::once()) + ->method('findById') + ->willReturn($account); + $this->mailManager->expects(self::once()) + ->method('getImapMessagesForScheduleProcessing') + ->with($account, $mailbox, [$message->getUid()]) + ->willReturn([$imapMessage]); + $imapMessage->expects(self::once()) + ->method('getUid') + ->willReturn(1); + $this->logger->expects(self::never()) + ->method('info'); + $imapMessage->expects(self::once()) + ->method('getFrom') + ->willReturn($addressList); + $addressList->expects(self::once()) + ->method('first') + ->willReturn($address); + $address->expects(self::once()) + ->method('getEmail') + ->willReturn('pam@stardew-bus-service.com'); + $imapMessage->expects(self::once()) + ->method('getReplyTo') + ->willReturn(new AddressList([])); + $this->calendarManager->expects(self::once()) + ->method('handleIMipCancel') + ->with('principals/users/vincent', + 'pam@stardew-bus-service.com', + null, + $account->getEmail(), + $imapMessage->scheduling[0]['contents'] + ); + $this->messageMapper->expects(self::once()) + ->method('updateImipData'); + + $this->service->process(); + } +} diff --git a/tests/Unit/Service/PreprocessingServiceTest.php b/tests/Unit/Service/PreprocessingServiceTest.php new file mode 100644 index 0000000000..e181d9e0b8 --- /dev/null +++ b/tests/Unit/Service/PreprocessingServiceTest.php @@ -0,0 +1,130 @@ + + * + * @author 2022 Anna Larch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Tests\Service; + +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Account; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\Db\Message; +use OCA\Mail\Db\MessageMapper; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\IMAP\PreviewEnhancer; +use OCA\Mail\Service\PreprocessingService; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; + +class PreprocessingServiceTest extends TestCase { + /** @var MailboxMapper|MockObject */ + private $mailboxMapper; + + /** @var MessageMapper|MockObject */ + private $messageMapper; + + /** @var MockObject|LoggerInterface */ + private $logger; + + /** @var MockObject|PreviewEnhancer */ + private $previewEnhancer; + + private PreprocessingService $service; + + protected function setUp(): void { + parent::setUp(); + + $this->mailboxMapper = $this->createMock(MailboxMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->messageMapper = $this->createMock(MessageMapper::class); + $this->previewEnhancer = $this->createMock(PreviewEnhancer::class); + + $this->service = new PreprocessingService( + $this->messageMapper, + $this->logger, + $this->mailboxMapper, + $this->previewEnhancer + ); + } + + public function testNoMailboxes(): void { + $account = new Account(new MailAccount()); + $timestamp = 0; + + $this->mailboxMapper->expects(self::once()) + ->method('findAll') + ->with($account) + ->willReturn([]); + $this->messageMapper->expects(self::never()) + ->method('getUnanalyzed'); + $this->previewEnhancer->expects(self::never()) + ->method('process'); + + $this->service->process($timestamp, $account); + } + + public function testNoUnanalysed(): void { + $account = new Account(new MailAccount()); + $timestamp = 0; + $mailbox = new Mailbox(); + $mailbox->setId(1); + + $this->mailboxMapper->expects(self::once()) + ->method('findAll') + ->with($account) + ->willReturn([$mailbox]); + $this->messageMapper->expects(self::once()) + ->method('getUnanalyzed') + ->with($timestamp, [$mailbox->getId()]) + ->willReturn([]); + $this->previewEnhancer->expects(self::never()) + ->method('process'); + + $this->service->process($timestamp, $account); + } + + public function testProcessing(): void { + $account = new Account(new MailAccount()); + $timestamp = 0; + $mailbox = new Mailbox(); + $mailbox->setId(1); + $message = new Message(); + $message->setMailboxId($mailbox->getId()); + + $this->mailboxMapper->expects(self::once()) + ->method('findAll') + ->with($account) + ->willReturn([$mailbox]); + $this->messageMapper->expects(self::once()) + ->method('getUnanalyzed') + ->with($timestamp, [$mailbox->getId()]) + ->willReturn([$message]); + $this->previewEnhancer->expects(self::once()) + ->method('process') + ->with($account, $mailbox, [$message]) + ->willReturn([$message]); + + $this->service->process($timestamp, $account); + } +}