Skip to content

Commit

Permalink
Merge pull request #9749 from nextcloud/feat/add-events-and-ocs-api-f…
Browse files Browse the repository at this point in the history
…or-incoming-messages

feat(ocs): notify of new messages and provide API endpoint to retrieve its contents
  • Loading branch information
miaulalala committed Jul 10, 2024
2 parents b38ce89 + 71dbc51 commit 1ce7b4d
Show file tree
Hide file tree
Showing 12 changed files with 772 additions and 12 deletions.
19 changes: 18 additions & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -482,5 +482,22 @@
'outbox' => ['url' => '/api/outbox'],
'preferences' => ['url' => '/api/preferences'],
'smimeCertificates' => ['url' => '/api/smime/certificates'],
]
],
'ocs' => [
[
'name' => 'messageApi#get',
'url' => '/message/{id}',
'verb' => 'GET',
],
[
'name' => 'messageApi#getRaw',
'url' => '/message/{id}/raw',
'verb' => 'GET',
],
[
'name' => 'messageApi#getAttachment',
'url' => '/message/{id}/attachment/{attachmentId}',
'verb' => 'GET',
],
],
];
2 changes: 2 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
use OCA\Mail\Listener\MessageKnownSinceListener;
use OCA\Mail\Listener\MoveJunkListener;
use OCA\Mail\Listener\NewMessageClassificationListener;
use OCA\Mail\Listener\NewMessagesNotifier;
use OCA\Mail\Listener\OauthTokenRefreshListener;
use OCA\Mail\Listener\OptionalIndicesListener;
use OCA\Mail\Listener\OutOfOfficeListener;
Expand Down Expand Up @@ -127,6 +128,7 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(MessageSentEvent::class, InteractionListener::class);
$context->registerEventListener(NewMessagesSynchronized::class, NewMessageClassificationListener::class);
$context->registerEventListener(NewMessagesSynchronized::class, MessageKnownSinceListener::class);
$context->registerEventListener(NewMessagesSynchronized::class, NewMessagesNotifier::class);
$context->registerEventListener(SynchronizationEvent::class, AccountSynchronizedThreadUpdaterListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(NewMessagesSynchronized::class, FollowUpClassifierListener::class);
Expand Down
240 changes: 240 additions & 0 deletions lib/Controller/MessageApiController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\Controller;

use OCA\Mail\Contracts\IDkimService;
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Exception\SmimeDecryptException;
use OCA\Mail\Http\TrapError;
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\Model\SmimeData;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AliasesService;
use OCA\Mail\Service\Attachment\AttachmentService;
use OCA\Mail\Service\ItineraryService;
use OCA\Mail\Service\MailManager;
use OCA\Mail\Service\OutboxService;
use OCA\Mail\Service\Search\MailSearch;
use OCA\Mail\Service\TrustedSenderService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\IMimeTypeDetector;
use OCP\IRequest;
use OCP\IURLGenerator;
use Psr\Log\LoggerInterface;

class MessageApiController extends OCSController {

private ?string $userId;

public function __construct(
string $appName,
?string $userId,
IRequest $request,
private AccountService $accountService,
private AliasesService $aliasesService,
private AttachmentService $attachmentService,
private OutboxService $outboxService,
private MailSearch $mailSearch,
private MailManager $mailManager,
private IMAPClientFactory $clientFactory,
private LoggerInterface $logger,
private ITimeFactory $time,
private IURLGenerator $urlGenerator,
private IMimeTypeDetector $mimeTypeDetector,
private IDkimService $dkimService,
private ItineraryService $itineraryService,
private TrustedSenderService $trustedSenderService,
) {
parent::__construct($appName, $request);
$this->userId = $userId;
}

/**
* @param int $id
* @return DataResponse
*/
#[BruteForceProtection('mailGetMessage')]
#[NoAdminRequired]
#[NoCSRFRequired]
public function get(int $id): DataResponse {
if ($this->userId === null) {
return new DataResponse('Account not found.', Http::STATUS_NOT_FOUND);
}

try {
$message = $this->mailManager->getMessage($this->userId, $id);
$mailbox = $this->mailManager->getMailbox($this->userId, $message->getMailboxId());
$account = $this->accountService->find($this->userId, $mailbox->getAccountId());
} catch (ClientException | DoesNotExistException $e) {
$this->logger->error('Message, Account or Mailbox not found', ['exception' => $e->getMessage()]);
return new DataResponse('Account not found.', Http::STATUS_NOT_FOUND);
}

$loadBody = true;
$client = $this->clientFactory->getClient($account);
try {
$imapMessage = $this->mailManager->getImapMessage(
$client,
$account,
$mailbox,
$message->getUid(),
true
);
} catch (ServiceException $e) {
$this->logger->error('Could not connect to IMAP server', ['exception' => $e->getMessage()]);
return new DataResponse('Could not connect to IMAP server. Please check your logs.', Http::STATUS_INTERNAL_SERVER_ERROR);
} catch (SmimeDecryptException $e) {
$this->logger->warning('Message could not be decrypted', ['exception' => $e->getMessage()]);
$loadBody = false;
$imapMessage = $this->mailManager->getImapMessage(
$client,
$account,
$mailbox,
$message->getUid()
);
} finally {
$client->logout();
}

$json = $imapMessage->getFullMessage($id, $loadBody);
$itineraries = $this->itineraryService->getCached($account, $mailbox, $message->getUid());
if ($itineraries) {
$json['itineraries'] = $itineraries;
}
$json['attachments'] = array_map(function ($a) use ($id) {
return $this->enrichDownloadUrl(
$id,
$a
);
}, $json['attachments']);
$json['id'] = $message->getId();
$json['isSenderTrusted'] = $this->trustedSenderService->isSenderTrusted($this->userId, $message);

$smimeData = new SmimeData();
$smimeData->setIsEncrypted($message->isEncrypted() || $imapMessage->isEncrypted());
if ($imapMessage->isSigned()) {
$smimeData->setIsSigned(true);
$smimeData->setSignatureIsValid($imapMessage->isSignatureValid());
}
$json['smime'] = $smimeData;

$dkimResult = $this->dkimService->getCached($account, $mailbox, $message->getUid());
if (is_bool($dkimResult)) {
$json['dkimValid'] = $dkimResult;
}

$json['rawUrl'] = $this->urlGenerator->linkToOCSRouteAbsolute('mail.messageApi.getRaw', ['id' => $id]);

if(!$loadBody) {
return new DataResponse($json, Http::STATUS_PARTIAL_CONTENT);
}

return new DataResponse($json, Http::STATUS_OK);
}

#[BruteForceProtection('mailGetRawMessage')]
#[NoAdminRequired]
#[NoCSRFRequired]
public function getRaw(int $id): DataResponse {
try {
$message = $this->mailManager->getMessage($this->userId, $id);
$mailbox = $this->mailManager->getMailbox($this->userId, $message->getMailboxId());
$account = $this->accountService->find($this->userId, $mailbox->getAccountId());
} catch (ClientException | DoesNotExistException $e) {
$this->logger->error('Message, Account or Mailbox not found', ['exception' => $e->getMessage()]);
return new DataResponse($e, Http::STATUS_FORBIDDEN);
}

$client = $this->clientFactory->getClient($account);
try {
$source = $this->mailManager->getSource(
$client,
$account,
$mailbox->getName(),
$message->getUid()
);
} catch (ServiceException $e) {
$this->logger->error('Message not found on IMAP or mail server went away', ['exception' => $e->getMessage()]);
return new DataResponse($e, Http::STATUS_NOT_FOUND);
} finally {
$client->logout();
}

return new DataResponse($source, Http::STATUS_OK);
}

/**
* @param int $id
* @param array $attachment
*
* @return array
*/
private function enrichDownloadUrl(int $id, array $attachment) {
$downloadUrl = $this->urlGenerator->linkToOCSRouteAbsolute('mail.messageApi.downloadAttachment',
[
'id' => $id,
'attachmentId' => $attachment['id'],
]);
$attachment['downloadUrl'] = $downloadUrl;
return $attachment;
}

#[NoCSRFRequired]
#[NoAdminRequired]
#[TrapError]
public function getAttachment(int $id,
string $attachmentId): DataResponse {
try {
$message = $this->mailManager->getMessage($this->userId, $id);
$mailbox = $this->mailManager->getMailbox($this->userId, $message->getMailboxId());
$account = $this->accountService->find($this->userId, $mailbox->getAccountId());
} catch (DoesNotExistException | ClientException $e) {
return new DataResponse($e, Http::STATUS_FORBIDDEN);
}

try {
$attachment = $this->mailManager->getMailAttachment(
$account,
$mailbox,
$message,
$attachmentId,
);
} catch (\Horde_Imap_Client_Exception_NoSupportExtension | \Horde_Imap_Client_Exception | \Horde_Mime_Exception $e) {
$this->logger->error('Error when trying to process the attachment', ['exception' => $e]);
return new DataResponse($e, Http::STATUS_INTERNAL_SERVER_ERROR);
} catch (ServiceException | DoesNotExistException $e) {
$this->logger->error('Could not find attachment', ['exception' => $e]);
return new DataResponse($e, Http::STATUS_NOT_FOUND);
}

// Body party and embedded messages do not have a name
if ($attachment->getName() === null) {
return new DataResponse([
'name' => $attachmentId . '.eml',
'mime' => $attachment->getType(),
'size' => $attachment->getSize(),
'content' => $attachment->getContent()
]);
}

return new DataResponse([
'name' => $attachment->getName(),
'mime' => $attachment->getType(),
'size' => $attachment->getSize(),
'content' => $attachment->getContent()
]);
}
}
4 changes: 2 additions & 2 deletions lib/Db/MessageMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ public function insertBulk(Account $account, Message ...$messages): void {
$qb1->setParameter('flag_mdnsent', $message->getFlagMdnsent(), IQueryBuilder::PARAM_BOOL);
$qb1->executeStatement();

$messageId = $qb1->getLastInsertId();
$message->setId($qb1->getLastInsertId());
$recipientTypes = [
Address::TYPE_FROM => $message->getFrom(),
Address::TYPE_TO => $message->getTo(),
Expand All @@ -325,7 +325,7 @@ public function insertBulk(Account $account, Message ...$messages): void {
continue;
}

$qb2->setParameter('message_id', $messageId, IQueryBuilder::PARAM_INT);
$qb2->setParameter('message_id', $message->getId(), IQueryBuilder::PARAM_INT);
$qb2->setParameter('type', $type, IQueryBuilder::PARAM_INT);
$qb2->setParameter('label', mb_strcut($recipient->getLabel(), 0, 255), IQueryBuilder::PARAM_STR);
$qb2->setParameter('email', mb_strcut($recipient->getEmail(), 0, 255), IQueryBuilder::PARAM_STR);
Expand Down
21 changes: 21 additions & 0 deletions lib/Events/NewMessageReceivedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

namespace OCA\Mail\Events;

use OCP\EventDispatcher\Event;

class NewMessageReceivedEvent extends Event {
public function __construct(private string $uri) {
parent::__construct();
}

public function getUri(): string {
return $this->uri;
}
}
2 changes: 2 additions & 0 deletions lib/IMAP/ImapMessageFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Horde_Mime_Part;
use OCA\Mail\AddressList;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Exception\SmimeDecryptException;
use OCA\Mail\IMAP\Charset\Converter;
use OCA\Mail\Model\IMAPMessage;
use OCA\Mail\Service\Html;
Expand Down Expand Up @@ -115,6 +116,7 @@ public function withPhishingCheck(bool $value): ImapMessageFetcher {
* @throws Horde_Imap_Client_Exception_NoSupportExtension
* @throws Horde_Mime_Exception
* @throws ServiceException
* @throws SmimeDecryptException
*/
public function fetchMessage(?Horde_Imap_Client_Data_Fetch $fetch = null): IMAPMessage {
$ids = new Horde_Imap_Client_Ids($this->uid);
Expand Down
42 changes: 42 additions & 0 deletions lib/Listener/NewMessagesNotifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

namespace OCA\Mail\Listener;

use OCA\Mail\Db\Message;
use OCA\Mail\Events\NewMessageReceivedEvent;
use OCA\Mail\Events\NewMessagesSynchronized;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\EventDispatcher\IEventListener;
use OCP\IURLGenerator;

/**
* @template-implements IEventListener<Event|NewMessagesSynchronized>
*/
class NewMessagesNotifier implements IEventListener {

public function __construct(private IEventDispatcher $eventDispatcher,
private IURLGenerator $urlGenerator,
) {
}
/**
* @inheritDoc
*/
public function handle(Event $event): void {
if(!$event instanceof NewMessagesSynchronized) {
return;
}

/** @var Message $message */
foreach($event->getMessages() as $message) {
$uri = $this->urlGenerator->linkToOCSRouteAbsolute('mail.messageApi.get', ['id' => $message->getId()]);
$this->eventDispatcher->dispatchTyped(new NewMessageReceivedEvent($uri));
}
}
}
Loading

0 comments on commit 1ce7b4d

Please sign in to comment.