Skip to content

Commit

Permalink
Merge pull request #9567 from nextcloud/feat/follow-up-reminder
Browse files Browse the repository at this point in the history
feat: follow up reminders
  • Loading branch information
st3iny authored Jun 25, 2024
2 parents 3b879b4 + b57eec0 commit a9d702c
Show file tree
Hide file tree
Showing 32 changed files with 1,495 additions and 38 deletions.
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,11 @@
'url' => '/api/out-of-office/{accountId}/follow-system',
'verb' => 'POST',
],
[
'name' => 'followUp#checkMessageIds',
'url' => '/api/follow-up/check-message-ids',
'verb' => 'POST',
],
],
'resources' => [
'accounts' => ['url' => '/api/accounts'],
Expand Down
2 changes: 2 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
use OCA\Mail\Listener\AccountSynchronizedThreadUpdaterListener;
use OCA\Mail\Listener\AddressCollectionListener;
use OCA\Mail\Listener\DeleteDraftListener;
use OCA\Mail\Listener\FollowUpClassifierListener;
use OCA\Mail\Listener\HamReportListener;
use OCA\Mail\Listener\InteractionListener;
use OCA\Mail\Listener\MailboxesSynchronizedSpecialMailboxesUpdater;
Expand Down Expand Up @@ -128,6 +129,7 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(NewMessagesSynchronized::class, MessageKnownSinceListener::class);
$context->registerEventListener(SynchronizationEvent::class, AccountSynchronizedThreadUpdaterListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(NewMessagesSynchronized::class, FollowUpClassifierListener::class);

// TODO: drop condition if nextcloud < 28 is not supported anymore
if (class_exists(OutOfOfficeStartedEvent::class)
Expand Down
106 changes: 106 additions & 0 deletions lib/BackgroundJob/FollowUpClassifierJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\BackgroundJob;

use OCA\Mail\Contracts\IMailManager;
use OCA\Mail\Db\Message;
use OCA\Mail\Db\ThreadMapper;
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;
use OCP\DB\Exception;
use Psr\Log\LoggerInterface;

class FollowUpClassifierJob extends QueuedJob {

public const PARAM_MESSAGE_ID = 'messageId';
public const PARAM_MAILBOX_ID = 'mailboxId';
public const PARAM_USER_ID = 'userId';

public function __construct(
ITimeFactory $time,
private LoggerInterface $logger,
private AccountService $accountService,
private IMailManager $mailManager,
private AiIntegrationsService $aiService,
private ThreadMapper $threadMapper,
) {
parent::__construct($time);
}

public function run($argument): void {
$messageId = $argument[self::PARAM_MESSAGE_ID];
$mailboxId = $argument[self::PARAM_MAILBOX_ID];
$userId = $argument[self::PARAM_USER_ID];

if (!$this->aiService->isLlmProcessingEnabled()) {
return;
}

try {
$mailbox = $this->mailManager->getMailbox($userId, $mailboxId);
$account = $this->accountService->find($userId, $mailbox->getAccountId());
} catch (ClientException $e) {
return;
}

$messages = $this->mailManager->getByMessageId($account, $messageId);
$messages = array_filter(
$messages,
static fn (Message $message) => $message->getMailboxId() === $mailboxId,
);
if (count($messages) === 0) {
return;
}

if (count($messages) > 1) {
$this->logger->warning('Trying to analyze multiple messages with the same message id for follow-ups');
}
$message = $messages[0];

try {
$newerMessages = $this->threadMapper->findNewerMessageIdsInThread(
$mailbox->getAccountId(),
$message,
);
} catch (Exception $e) {
$this->logger->error(
'Failed to check if a message needs a follow-up: ' . $e->getMessage(),
[ 'exception' => $e ],
);
return;
}
if (count($newerMessages) > 0) {
return;
}

$requiresFollowup = $this->aiService->requiresFollowUp(
$account,
$mailbox,
$message,
$userId,
);
if (!$requiresFollowup) {
return;
}

$this->logger->debug('Message requires follow-up: ' . $message->getId());
$tag = $this->mailManager->createTag('Follow up', '#d77000', $userId);
$this->mailManager->tagMessage(
$account,
$mailbox->getName(),
$message,
$tag,
true,
);
}
}
75 changes: 75 additions & 0 deletions lib/Controller/FollowUpController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?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\Db\MailboxMapper;
use OCA\Mail\Db\MessageMapper;
use OCA\Mail\Db\ThreadMapper;
use OCA\Mail\Http\JsonResponse;
use OCA\Mail\Http\TrapError;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\IRequest;

class FollowUpController extends Controller {

public function __construct(
string $appName,
IRequest $request,
private ?string $userId,
private ThreadMapper $threadMapper,
private MessageMapper $messageMapper,
private MailboxMapper $mailboxMapper,
) {
parent::__construct($appName, $request);
}

/**
* @param int[] $messageIds
*/
#[TrapError]
#[NoAdminRequired]
public function checkMessageIds(array $messageIds): JsonResponse {
$userId = $this->userId;
if ($userId === null) {
return JsonResponse::fail([], Http::STATUS_FORBIDDEN);
}

$mailboxes = [];

$wasFollowedUp = [];
$messages = $this->messageMapper->findByIds($userId, $messageIds, 'ASC');
foreach ($messages as $message) {
$mailboxId = $message->getMailboxId();
if (!isset($mailboxes[$mailboxId])) {
try {
$mailboxes[$mailboxId] = $this->mailboxMapper->findByUid($mailboxId, $userId);
} catch (DoesNotExistException $e) {
continue;
}
}

$newerMessageIds = $this->threadMapper->findNewerMessageIdsInThread(
$mailboxes[$mailboxId]->getAccountId(),
$message,
);
if (!empty($newerMessageIds)) {
$wasFollowedUp[] = $message->getId();
}
}

return JsonResponse::success([
'wasFollowedUp' => $wasFollowedUp,
]);
}

}
11 changes: 9 additions & 2 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ public function index(): TemplateResponse {
'search-priority-body' => $this->preferences->getPreference($this->currentUserId, 'search-priority-body', 'false'),
'start-mailbox-id' => $this->preferences->getPreference($this->currentUserId, 'start-mailbox-id'),
'tag-classified-messages' => $this->classificationSettingsService->isClassificationEnabled($this->currentUserId) ? 'true' : 'false',
'follow-up-reminders' => $this->preferences->getPreference($this->currentUserId, 'follow-up-reminders', 'true'),
]);
$this->initialStateService->provideInitialState(
'prefill_displayName',
Expand Down Expand Up @@ -266,12 +267,18 @@ public function index(): TemplateResponse {

$this->initialStateService->provideInitialState(
'llm_summaries_available',
$this->config->getAppValue('mail', 'llm_processing', 'no') === 'yes' && $this->aiIntegrationsService->isLlmAvailable(SummaryTaskType::class)
$this->aiIntegrationsService->isLlmProcessingEnabled() && $this->aiIntegrationsService->isLlmAvailable(SummaryTaskType::class)
);

$this->initialStateService->provideInitialState(
'llm_freeprompt_available',
$this->config->getAppValue('mail', 'llm_processing', 'no') === 'yes' && $this->aiIntegrationsService->isLlmAvailable(FreePromptTaskType::class)
$this->aiIntegrationsService->isLlmProcessingEnabled() && $this->aiIntegrationsService->isLlmAvailable(FreePromptTaskType::class)
);

$this->initialStateService->provideInitialState(
'llm_followup_available',
$this->aiIntegrationsService->isLlmProcessingEnabled()
&& $this->aiIntegrationsService->isLlmAvailable(FreePromptTaskType::class)
);

$this->initialStateService->provideInitialState(
Expand Down
51 changes: 51 additions & 0 deletions lib/Db/ThreadMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,55 @@ public function findMessageUidsAndMailboxNamesByAccountAndThreadRoot(MailAccount
return $rows;
}

/**
* Find message entity ids of a thread than have been sent after the given message.
* Can be used to find out if a message has been replied to or followed up.
*
* @return array<array-key, array{id: int}>
*
* @throws \OCP\DB\Exception
*/
public function findNewerMessageIdsInThread(int $accountId, Message $message): array {
$qb = $this->db->getQueryBuilder();
$qb->select('messages.id')
->from($this->tableName, 'messages')
->join('messages', 'mail_mailboxes', 'mailboxes', 'messages.mailbox_id = mailboxes.id')
->where(
// Not the message itself
$qb->expr()->neq(
'messages.message_id',
$qb->createNamedParameter($message->getMessageId(), IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
),
// Are part of the same thread
$qb->expr()->eq(
'messages.thread_root_id',
$qb->createNamedParameter($message->getThreadRootId(), IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
),
// Are sent after the message
$qb->expr()->gte(
'messages.sent_at',
$qb->createNamedParameter($message->getSentAt(), IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
),
// Belong to the same account
$qb->expr()->eq(
'mailboxes.account_id',
$qb->createNamedParameter($accountId, IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
),
);

$result = $qb->executeQuery();
$rows = array_map(static function (array $row) {
return [
'id' => (int)$row[0],
];
}, $result->fetchAll(\PDO::FETCH_NUM));
$result->closeCursor();

return $rows;
}

}
94 changes: 94 additions & 0 deletions lib/Listener/FollowUpClassifierListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Listener;

use DateInterval;
use DateTimeImmutable;
use OCA\Mail\BackgroundJob\FollowUpClassifierJob;
use OCA\Mail\Events\NewMessagesSynchronized;
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
use OCP\BackgroundJob\IJobList;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\TextProcessing\FreePromptTaskType;

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

public function __construct(
private IJobList $jobList,
private AiIntegrationsService $aiService,
) {
}

public function handle(Event $event): void {
if (!($event instanceof NewMessagesSynchronized)) {
return;
}

if (!$event->getMailbox()->isSpecialUse('sent')
&& $event->getAccount()->getMailAccount()->getSentMailboxId() !== $event->getMailbox()->getId()
) {
return;
}

if (!$this->aiService->isLlmProcessingEnabled()) {
return;
}

if (!$this->aiService->isLlmAvailable(FreePromptTaskType::class)) {
return;
}

// Do not process emails older than 14D to save some processing power
$notBefore = (new DateTimeImmutable('now'))
->sub(new DateInterval('P14D'));
$userId = $event->getAccount()->getUserId();
foreach ($event->getMessages() as $message) {
if ($message->getSentAt() < $notBefore->getTimestamp()) {
continue;
}

$isTagged = false;
foreach ($message->getTags() as $tag) {
if ($tag->getImapLabel() === '$follow_up') {
$isTagged = true;
break;
}
}
if ($isTagged) {
continue;
}

$jobArguments = [
FollowUpClassifierJob::PARAM_MESSAGE_ID => $message->getMessageId(),
FollowUpClassifierJob::PARAM_MAILBOX_ID => $message->getMailboxId(),
FollowUpClassifierJob::PARAM_USER_ID => $userId,
];
// TODO: only use scheduleAfter() once we support >= 28.0.0
if (method_exists(IJobList::class, 'scheduleAfter')) {
// Delay job a bit because there might be some replies until then and we might be able
// to skip the expensive LLM task
$timestamp = (new DateTimeImmutable('@' . $message->getSentAt()))
->add(new DateInterval('P3DT12H'))
->getTimestamp();
$this->jobList->scheduleAfter(
FollowUpClassifierJob::class,
$timestamp,
$jobArguments,
);
} else {
$this->jobList->add(FollowUpClassifierJob::class, $jobArguments);
}
}
}
}
Loading

0 comments on commit a9d702c

Please sign in to comment.