From 048e86db9f4173ff6e2c95616154c6fce2dbcfde Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Mon, 27 May 2024 16:06:11 +0200 Subject: [PATCH] feat(ocs): send a message via api Signed-off-by: Anna Larch --- appinfo/routes.php | 5 + lib/Controller/MessageApiController.php | 127 ++++++ lib/Send/Chain.php | 6 +- lib/Service/AliasesService.php | 10 + lib/Service/OutboxService.php | 10 +- .../Controller/MessageApiControllerTest.php | 362 ++++++++++++++++++ tests/Unit/Send/ChainTest.php | 3 +- 7 files changed, 517 insertions(+), 6 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 558f45849d..f49018a191 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -484,6 +484,11 @@ 'smimeCertificates' => ['url' => '/api/smime/certificates'], ], 'ocs' => [ + [ + 'name' => 'messageApi#send', + 'url' => '/message/send', + 'verb' => 'POST', + ], [ 'name' => 'messageApi#get', 'url' => '/message/{id}', diff --git a/lib/Controller/MessageApiController.php b/lib/Controller/MessageApiController.php index f8019cad96..9bfd401f28 100644 --- a/lib/Controller/MessageApiController.php +++ b/lib/Controller/MessageApiController.php @@ -7,9 +7,11 @@ */ namespace OCA\Mail\Controller; +use OCA\Mail\Db\LocalMessage; use OCA\Mail\Contracts\IDkimService; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Exception\UploadException; use OCA\Mail\Exception\SmimeDecryptException; use OCA\Mail\Http\TrapError; use OCA\Mail\IMAP\IMAPClientFactory; @@ -17,6 +19,7 @@ use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\Attachment\AttachmentService; +use OCA\Mail\Service\Attachment\UploadedFile; use OCA\Mail\Service\ItineraryService; use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; @@ -27,6 +30,7 @@ use OCP\AppFramework\Http\Attribute\BruteForceProtection; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\AppFramework\Utility\ITimeFactory; @@ -34,6 +38,8 @@ use OCP\IRequest; use OCP\IURLGenerator; use Psr\Log\LoggerInterface; +use Throwable; +use function array_merge; class MessageApiController extends OCSController { @@ -62,6 +68,127 @@ public function __construct( $this->userId = $userId; } + #[UserRateLimit(limit: 5, period: 100)] + #[NoAdminRequired] + #[NoCSRFRequired] + public function send( + int $accountId, + string $fromEmail, + string $subject, + string $body, + bool $isHtml, + array $to, + array $cc = [], + array $bcc = [], + array $references = [], + ): DataResponse { + if ($this->userId === null) { + return new DataResponse('Account not found.', Http::STATUS_NOT_FOUND); + } + + try { + $mailAccount = $this->accountService->find($this->userId, $accountId); + } catch (ClientException $e) { + $this->logger->error("Mail account #$accountId not found", ['exception' => $e]); + return new DataResponse('Account not found.', Http::STATUS_NOT_FOUND); + } + + if ($fromEmail !== $mailAccount->getEmail()) { + try { + $alias = $this->aliasesService->findByAliasAndUserId($fromEmail, $this->userId); + } catch (DoesNotExistException $e) { + $this->logger->error("Alias $fromEmail for mail account $accountId not found", ['exception' => $e]); + // Cannot send from this email as it is not configured as an alias + return new DataResponse("Could not find alias $fromEmail. Please check the logs.", Http::STATUS_NOT_FOUND); + } + } + + if (empty($to)) { + return new DataResponse('Recipients cannot be empty.', Http::STATUS_BAD_REQUEST); + } + + $attachments = $this->request->getUploadedFile('attachments'); + $numberOfAttachments = count($attachments['name'] ?? []); + $localAttachments = []; + $messageAttachments = []; + $attachmentErrors = false; + while ($numberOfAttachments > 0) { + $numberOfAttachments--; + $filedata = [ + 'name' => $attachments['name'][$numberOfAttachments], + 'type' => $attachments['type'][$numberOfAttachments], + 'size' => $attachments['size'][$numberOfAttachments], + 'tmp_name' => $attachments['tmp_name'][$numberOfAttachments], + ]; + $file = new UploadedFile($filedata); + try { + $localAttachment = $this->attachmentService->addFile($this->userId, $file); + $messageAttachments[] = $localAttachment; + $localAttachments[] = ['type' => 'local', 'id' => $localAttachment->getId()]; + } catch (UploadException $e) { + $this->logger->error('Could not convert attachment to local attachment.', ['exception' => $e]); + $attachmentErrors = true; + } + } + + if ($attachmentErrors) { + foreach ($localAttachments as $localAttachment) { + // Handle possible dangling local attachments + $this->attachmentService->deleteAttachment($this->userId, $localAttachment['id']); + } + return new DataResponse('Could not convert attachment(s) to local attachment(s). Please check the logs.', Http::STATUS_INTERNAL_SERVER_ERROR); + } + + $message = new LocalMessage(); + $message->setType(LocalMessage::TYPE_OUTGOING); + $message->setAccountId($accountId); + $message->setSubject($subject); + $message->setBody($body); + $message->setEditorBody($body); + $message->setHtml($isHtml); + $message->setSendAt($this->time->getTime()); + $message->setType(LocalMessage::TYPE_OUTGOING); + + if (isset($alias)) { + $message->setAliasId($alias->getId()); + } + if (!empty($references)) { + $message->setInReplyToMessageId($references[0]); + } + if (!empty($attachments)) { + $message->setAttachments($messageAttachments); + } + + $recipients = array_merge($to, $cc, $bcc); + foreach($recipients as $recipient) { + if (!is_array($recipient)) { + return new DataResponse('Recipient address must be an array.', Http::STATUS_BAD_REQUEST); + } + + if (!isset($recipient['email'])) { + return new DataResponse('Recipient address must contain an email address.', Http::STATUS_BAD_REQUEST); + } + } + $localMessage = $this->outboxService->saveMessage($mailAccount, $message, $to, $cc, $bcc, $localAttachments); + try { + $localMessage = $this->outboxService->sendMessage($localMessage, $mailAccount); + } catch (ServiceException $e) { + $this->logger->error('Processing error: could not send message', ['exception' => $e]); + return new DataResponse('Processing error: could not send message. Please check the logs', Http::STATUS_BAD_REQUEST); + } catch (Throwable $e) { + $this->logger->error('SMTP error: could not send message', ['exception' => $e]); + return new DataResponse('Fatal SMTP error: could not send message, and no resending is possible. Please check the mail server logs.', Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return match ($localMessage->getStatus()) { + LocalMessage::STATUS_PROCESSED => new DataResponse('', Http::STATUS_OK), + LocalMessage::STATUS_NO_SENT_MAILBOX => new DataResponse('Configuration error: Cannot send message without sent mailbox.', Http::STATUS_FORBIDDEN), + LocalMessage::STATUS_SMPT_SEND_FAIL => new DataResponse('SMTP error: could not send message. Message sending will be retried. Please check the logs.', Http::STATUS_INTERNAL_SERVER_ERROR), + LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL => new DataResponse('Email was sent but could not be copied to sent mailbox. Copying will be retried. Please check the logs.', Http::STATUS_ACCEPTED), + default => new DataResponse('An error occured. Please check the logs.', Http::STATUS_INTERNAL_SERVER_ERROR), + }; + } + /** * @param int $id * @return DataResponse diff --git a/lib/Send/Chain.php b/lib/Send/Chain.php index ce549c74d5..b8d2c97094 100644 --- a/lib/Send/Chain.php +++ b/lib/Send/Chain.php @@ -30,7 +30,7 @@ public function __construct(private SentMailboxHandler $sentMailboxHandler, * @throws Exception * @throws ServiceException */ - public function process(Account $account, LocalMessage $localMessage): void { + public function process(Account $account, LocalMessage $localMessage): LocalMessage { $handlers = $this->sentMailboxHandler; $handlers->setNext($this->antiAbuseHandler) ->setNext($this->sendHandler) @@ -50,8 +50,8 @@ public function process(Account $account, LocalMessage $localMessage): void { if ($result->getStatus() === LocalMessage::STATUS_PROCESSED) { $this->attachmentService->deleteLocalMessageAttachments($account->getUserId(), $result->getId()); $this->localMessageMapper->deleteWithRecipients($result); - return; + return $localMessage; } - $this->localMessageMapper->update($result); + return $this->localMessageMapper->update($result); } } diff --git a/lib/Service/AliasesService.php b/lib/Service/AliasesService.php index 86c55a49d3..24ed137225 100644 --- a/lib/Service/AliasesService.php +++ b/lib/Service/AliasesService.php @@ -47,6 +47,16 @@ public function find(int $aliasId, string $currentUserId): Alias { return $this->aliasMapper->find($aliasId, $currentUserId); } + /** + * @param string $aliasEmail + * @param string $userId + * @return Alias + * @throws DoesNotExistException + */ + public function findByAliasAndUserId(string $aliasEmail, string $userId): Alias { + return $this->aliasMapper->findByAlias($aliasEmail, $userId); + } + /** * @param string $userId * @param int $accountId diff --git a/lib/Service/OutboxService.php b/lib/Service/OutboxService.php index e738117ea5..87772dbad4 100644 --- a/lib/Service/OutboxService.php +++ b/lib/Service/OutboxService.php @@ -17,11 +17,13 @@ use OCA\Mail\Db\Recipient; use OCA\Mail\Events\OutboxMessageCreatedEvent; use OCA\Mail\Exception\ClientException; +use OCA\Mail\Exception\ServiceException; use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Send\Chain; use OCA\Mail\Service\Attachment\AttachmentService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; use Psr\Log\LoggerInterface; use Throwable; @@ -115,9 +117,13 @@ public function deleteMessage(string $userId, LocalMessage $message): void { $this->mapper->deleteWithRecipients($message); } + /** + * @throws Throwable + * @throws Exception + * @throws ServiceException + */ public function sendMessage(LocalMessage $message, Account $account): LocalMessage { - $this->sendChain->process($account, $message); - return $message; + return $this->sendChain->process($account, $message); } /** diff --git a/tests/Unit/Controller/MessageApiControllerTest.php b/tests/Unit/Controller/MessageApiControllerTest.php index c28ea52cfa..5f9dd22b68 100644 --- a/tests/Unit/Controller/MessageApiControllerTest.php +++ b/tests/Unit/Controller/MessageApiControllerTest.php @@ -11,12 +11,15 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; use OCA\Mail\Controller\MessageApiController; +use OCA\Mail\Db\Alias; +use OCA\Mail\Db\LocalAttachment; use OCA\Mail\Db\LocalMessage; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\Message; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Exception\UploadException; use OCA\Mail\Exception\SmimeDecryptException; use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Model\IMAPMessage; @@ -28,11 +31,13 @@ use OCA\Mail\Service\ItineraryService; use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; +use OCP\AppFramework\Db\DoesNotExistException; use OCA\Mail\Service\Search\MailSearch; use OCA\Mail\Service\TrustedSenderService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\Exception; use OCP\Files\IMimeTypeDetector; use OCP\IRequest; use OCP\IURLGenerator; @@ -87,6 +92,7 @@ protected function setUp(): void { $this->mimeTypeDetector = $this->createMock(IMimeTypeDetector::class); $this->urlGenerator = $this->createMock(IURLGenerator::class); $this->userManager = $this->createMock(IUserManager::class); + $this->aliasesService = $this->createMock(AliasesService::class); $this->attachmentService = $this->createMock(AttachmentService::class); $this->outboxService = $this->createMock(OutboxService::class); $this->logger = $this->createMock(LoggerInterface::class); @@ -407,4 +413,360 @@ public function testMailboxNotFound(): void { $this->assertEquals($expected, $actual); } + + /** + * @dataProvider mailData + */ + public function testSend($messageStatus, $expected): void { + $this->message->setStatus($messageStatus); + + $this->accountService->expects(self::once()) + ->method('find') + ->with($this->userId, $this->account->getId()) + ->willReturn($this->account); + $this->aliasesService->expects(self::never()) + ->method('findByAliasAndUserId'); + $this->request->expects(self::once()) + ->method('getUploadedFile') + ->willReturn(null); + $this->attachmentService->expects(self::never()) + ->method('addFile'); + $this->attachmentService->expects(self::never()) + ->method('deleteAttachment'); + $this->outboxService->expects(self::once()) + ->method('saveMessage'); + $this->outboxService->expects(self::once()) + ->method('sendMessage') + ->willReturn($this->message); + + $actual = $this->controller->send( + $this->accountId, + $this->fromEmail, + '', + '', + true, + [['email' => 'john@test.com']] + ); + + $this->assertEquals($expected, $actual); + } + + public function testSendNoRecipient(): void { + $this->message->setStatus(LocalMessage::STATUS_RAW); + + $this->accountService->expects(self::once()) + ->method('find') + ->with($this->userId, $this->account->getId()) + ->willReturn($this->account); + $this->aliasesService->expects(self::never()) + ->method('findByAliasAndUserId'); + $this->request->expects(self::never()) + ->method('getUploadedFile'); + $this->attachmentService->expects(self::never()) + ->method('addFile'); + $this->attachmentService->expects(self::never()) + ->method('deleteAttachment'); + $this->outboxService->expects(self::never()) + ->method('saveMessage'); + $this->outboxService->expects(self::never()) + ->method('sendMessage') + ->willReturn($this->message); + + $expected = new DataResponse('Recipients cannot be empty.', Http::STATUS_BAD_REQUEST); + $actual = $this->controller->send( + $this->accountId, + $this->fromEmail, + '', + '', + true, + [] + ); + + $this->assertEquals($expected, $actual); + } + + public function mailData() { + return [ + [LocalMessage::STATUS_PROCESSED, new DataResponse('', Http::STATUS_OK)], + [LocalMessage::STATUS_NO_SENT_MAILBOX, new DataResponse('Configuration error: Cannot send message without sent mailbox.', Http::STATUS_FORBIDDEN)], + [LocalMessage::STATUS_SMPT_SEND_FAIL, new DataResponse('SMTP error: could not send message. Message sending will be retried. Please check the logs.', Http::STATUS_INTERNAL_SERVER_ERROR)], + [LocalMessage::STATUS_IMAP_SENT_MAILBOX_FAIL, new DataResponse('Email was sent but could not be copied to sent mailbox. Copying will be retried. Please check the logs.', Http::STATUS_ACCEPTED)], + [LocalMessage::STATUS_TOO_MANY_RECIPIENTS, new DataResponse('An error occured. Please check the logs.', Http::STATUS_INTERNAL_SERVER_ERROR)], + ]; + } + + /** + * @dataProvider exceptionData + */ + public function testSendException($exception, $expected): void { + $this->accountService->expects(self::once()) + ->method('find') + ->with($this->userId, $this->account->getId()) + ->willReturn($this->account); + $this->aliasesService->expects(self::never()) + ->method('findByAliasAndUserId'); + $this->request->expects(self::once()) + ->method('getUploadedFile') + ->willReturn(null); + $this->attachmentService->expects(self::never()) + ->method('addFile'); + $this->attachmentService->expects(self::never()) + ->method('deleteAttachment'); + $this->outboxService->expects(self::once()) + ->method('saveMessage'); + $this->outboxService->expects(self::once()) + ->method('sendMessage') + ->willThrowException($exception); + + $actual = $this->controller->send( + $this->accountId, + $this->fromEmail, + '', + '', + true, + [['email' => 'john@test.com']] + ); + + $this->assertEquals($expected, $actual); + } + + public function exceptionData() { + return [ + [new ServiceException(), new DataResponse('Processing error: could not send message. Please check the logs', Http::STATUS_BAD_REQUEST)], + [new Exception(), new DataResponse('Fatal SMTP error: could not send message, and no resending is possible. Please check the mail server logs.', Http::STATUS_INTERNAL_SERVER_ERROR)], + ]; + } + + public function testWithAttachment(): void { + $this->message->setStatus(LocalMessage::STATUS_PROCESSED); + $attachments = [ + 'name' => [ + 0 => [ + 'Test' + ] + ], + 'type' => [ + 0 => [ + 'Test' + ] + ], + 'size' => [ + 0 => [ + 10 + ] + ], + 'tmp_name' => [ + 0 => [ + 'Test' + ] + ], + ]; + $localAttachment = new LocalAttachment(); + $localAttachment->setId(1); + + $this->accountService->expects(self::once()) + ->method('find') + ->with($this->userId, $this->account->getId()) + ->willReturn($this->account); + $this->aliasesService->expects(self::never()) + ->method('findByAliasAndUserId'); + $this->request->expects(self::once()) + ->method('getUploadedFile') + ->willReturn($attachments); + $this->attachmentService->expects(self::once()) + ->method('addFile') + ->willReturn($localAttachment); + $this->attachmentService->expects(self::never()) + ->method('deleteAttachment'); + $this->outboxService->expects(self::once()) + ->method('saveMessage'); + $this->outboxService->expects(self::once()) + ->method('sendMessage') + ->willReturn($this->message); + + $expected = new DataResponse('', Http::STATUS_OK); + $actual = $this->controller->send( + $this->accountId, + $this->fromEmail, + '', + '', + true, + [['email' => 'test']] + ); + + $this->assertEquals($expected, $actual); + } + + public function testWithAttachmentError(): void { + $attachments = [ + 'name' => [ + 0 => [ + 'Test' + ], + ], + 'type' => [ + 0 => [ + 'Test' + ] + ], + 'size' => [ + 0 => [ + 10 + ] + ], + 'tmp_name' => [ + 0 => [ + 'Test' + ] + ], + ]; + + $this->accountService->expects(self::once()) + ->method('find') + ->with($this->userId, $this->account->getId()) + ->willReturn($this->account); + $this->aliasesService->expects(self::never()) + ->method('findByAliasAndUserId'); + $this->request->expects(self::once()) + ->method('getUploadedFile') + ->willReturn($attachments); + $this->attachmentService->expects(self::once()) + ->method('addFile') + ->willThrowException(new UploadException()); + $this->attachmentService->expects(self::never()) + ->method('deleteAttachment'); + $this->logger->expects(self::once()) + ->method('error'); + $this->outboxService->expects(self::never()) + ->method('saveMessage'); + $this->outboxService->expects(self::never()) + ->method('sendMessage'); + + $expected = new DataResponse('Could not convert attachment(s) to local attachment(s). Please check the logs.', Http::STATUS_INTERNAL_SERVER_ERROR); + $actual = $this->controller->send( + $this->accountId, + $this->fromEmail, + '', + '', + true, + [['email' => 'john@test.com']] + ); + + $this->assertEquals($expected, $actual); + } + + public function testAlias(): void { + $aliasMail = 'john-alias@test.com'; + $accountId = 1; + $alias = new Alias(); + $alias->setId($accountId); + $alias->setName('John'); + $alias->setAccountId($accountId); + $alias->setAlias($aliasMail); + $mailAccount = new MailAccount(); + $mailAccount->setId($accountId); + $mailAccount->setEmail($this->fromEmail); + $account = new Account($mailAccount); + $this->message->setStatus(LocalMessage::STATUS_PROCESSED); + + $this->accountService->expects(self::once()) + ->method('find') + ->with($this->userId, $account->getId()) + ->willReturn($account); + $this->aliasesService->expects(self::once()) + ->method('findByAliasAndUserId') + ->willReturn($alias); + $this->request->expects(self::once()) + ->method('getUploadedFile') + ->willReturn(null); + $this->attachmentService->expects(self::never()) + ->method('addFile'); + $this->attachmentService->expects(self::never()) + ->method('deleteAttachment'); + $this->outboxService->expects(self::once()) + ->method('saveMessage'); + $this->outboxService->expects(self::once()) + ->method('sendMessage') + ->willReturn($this->message); + + $expected = new DataResponse('', Http::STATUS_OK); + $actual = $this->controller->send( + $accountId, + $aliasMail, + '', + '', + true, + [['email' => 'john@test.com']] + ); + + $this->assertEquals($expected, $actual); + } + public function testNoAlias(): void { + $aliasMail = 'john-alias@test.com'; + + $this->accountService->expects(self::once()) + ->method('find') + ->with($this->userId, $this->account->getId()) + ->willReturn($this->account); + $this->aliasesService->expects(self::once()) + ->method('findByAliasAndUserId') + ->willThrowException(new DoesNotExistException('')); + $this->logger->expects(self::once()) + ->method('error'); + $this->request->expects(self::never()) + ->method('getUploadedFile'); + $this->attachmentService->expects(self::never()) + ->method('addFile'); + $this->attachmentService->expects(self::never()) + ->method('deleteAttachment'); + $this->outboxService->expects(self::never()) + ->method('saveMessage'); + $this->outboxService->expects(self::never()) + ->method('sendMessage'); + + $expected = new DataResponse("Could not find alias $aliasMail. Please check the logs.", Http::STATUS_NOT_FOUND); + $actual = $this->controller->send( + $this->accountId, + $aliasMail, + '', + '', + true, + [] + ); + + $this->assertEquals($expected, $actual); + } + + public function testNoAccount(): void { + $this->accountService->expects(self::once()) + ->method('find') + ->with($this->userId, $this->accountId) + ->willThrowException(new ClientException()); + $this->logger->expects(self::once()) + ->method('error'); + $this->aliasesService->expects(self::never()) + ->method('findByAliasAndUserId'); + $this->request->expects(self::never()) + ->method('getUploadedFile'); + $this->attachmentService->expects(self::never()) + ->method('addFile'); + $this->attachmentService->expects(self::never()) + ->method('deleteAttachment'); + $this->outboxService->expects(self::never()) + ->method('saveMessage'); + $this->outboxService->expects(self::never()) + ->method('sendMessage'); + + $expected = new DataResponse('Account not found.', Http::STATUS_NOT_FOUND); + $actual = $this->controller->send( + $this->accountId, + $this->fromEmail, + '', + '', + true, + [] + ); + + $this->assertEquals($expected, $actual); + } } diff --git a/tests/Unit/Send/ChainTest.php b/tests/Unit/Send/ChainTest.php index c064c027bf..98dcc302d2 100644 --- a/tests/Unit/Send/ChainTest.php +++ b/tests/Unit/Send/ChainTest.php @@ -106,7 +106,8 @@ public function testProcessNotProcessed() { ->method('deleteWithRecipients'); $this->localMessageMapper->expects(self::once()) ->method('update') - ->with($expected); + ->with($expected) + ->willReturn($expected); $this->chain->process($account, $localMessage); }