diff --git a/appinfo/info.xml b/appinfo/info.xml index 6bb3d39258..7c8cf5da31 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -34,7 +34,7 @@ The rating depends on the installed text processing backend. See [the rating ove Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud.com/blog/nextcloud-ethical-ai-rating/). ]]> - 4.2.0-alpha.0 + 4.2.1-alpha.0 agpl Christoph Wurst GretaD diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 01bea26944..4d1cf88ceb 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -47,10 +47,12 @@ use OCA\Mail\Listener\MoveJunkListener; use OCA\Mail\Listener\NewMessageClassificationListener; use OCA\Mail\Listener\NewMessagesNotifier; +use OCA\Mail\Listener\NewMessagesSummarizeListener; use OCA\Mail\Listener\OauthTokenRefreshListener; use OCA\Mail\Listener\OptionalIndicesListener; use OCA\Mail\Listener\OutOfOfficeListener; use OCA\Mail\Listener\SpamReportListener; +use OCA\Mail\Listener\TaskProcessingListener; use OCA\Mail\Listener\UserDeletedListener; use OCA\Mail\Notification\Notifier; use OCA\Mail\Provider\MailProvider; @@ -72,6 +74,7 @@ use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\DB\Events\AddMissingIndicesEvent; use OCP\IServerContainer; +use OCP\TaskProcessing\Events\TaskSuccessfulEvent; use OCP\User\Events\OutOfOfficeChangedEvent; use OCP\User\Events\OutOfOfficeClearedEvent; use OCP\User\Events\OutOfOfficeEndedEvent; @@ -133,6 +136,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(NewMessagesSynchronized::class, NewMessageClassificationListener::class); $context->registerEventListener(NewMessagesSynchronized::class, MessageKnownSinceListener::class); $context->registerEventListener(NewMessagesSynchronized::class, NewMessagesNotifier::class); + $context->registerEventListener(NewMessagesSynchronized::class, NewMessagesSummarizeListener::class); $context->registerEventListener(SynchronizationEvent::class, AccountSynchronizedThreadUpdaterListener::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); $context->registerEventListener(NewMessagesSynchronized::class, FollowUpClassifierListener::class); @@ -141,6 +145,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(OutOfOfficeChangedEvent::class, OutOfOfficeListener::class); $context->registerEventListener(OutOfOfficeClearedEvent::class, OutOfOfficeListener::class); $context->registerEventListener(OutOfOfficeScheduledEvent::class, OutOfOfficeListener::class); + $context->registerEventListener(TaskSuccessfulEvent::class, TaskProcessingListener::class); $context->registerMiddleWare(ErrorMiddleware::class); $context->registerMiddleWare(ProvisioningMiddleware::class); diff --git a/lib/Db/Message.php b/lib/Db/Message.php index 229c26f395..3376f3742f 100644 --- a/lib/Db/Message.php +++ b/lib/Db/Message.php @@ -58,6 +58,8 @@ * @method bool|null getFlagMdnsent() * @method void setPreviewText(?string $subject) * @method null|string getPreviewText() + * @method void setSummary(?string $summary) + * @method null|string getSummary() * @method void setUpdatedAt(int $time) * @method int getUpdatedAt() * @method bool isImipMessage() @@ -108,6 +110,7 @@ class Message extends Entity implements JsonSerializable { protected $flagImportant = false; protected $flagMdnsent; protected $previewText; + protected $summary; protected $imipMessage = false; protected $imipProcessed = false; protected $imipError = false; @@ -325,6 +328,7 @@ static function (Tag $tag) { 'threadRootId' => $this->getThreadRootId(), 'imipMessage' => $this->isImipMessage(), 'previewText' => $this->getPreviewText(), + 'summary' => $this->getSummary(), 'encrypted' => ($this->isEncrypted() === true), 'mentionsMe' => $this->getMentionsMe(), ]; diff --git a/lib/Listener/NewMessagesSummarizeListener.php b/lib/Listener/NewMessagesSummarizeListener.php new file mode 100644 index 0000000000..e6f37e3bc8 --- /dev/null +++ b/lib/Listener/NewMessagesSummarizeListener.php @@ -0,0 +1,50 @@ + + */ +class NewMessagesSummarizeListener implements IEventListener { + + public function __construct( + protected LoggerInterface $logger, + protected IMAPClientFactory $imapFactory, + protected AiIntegrationsService $aiService, + protected IMailManager $mailManager + ) { } + + public function handle(Event $event): void { + + if (!($event instanceof NewMessagesSynchronized)) { + return; + } + + try { + $this->aiService->summarizeMessages( + $event->getAccount(), + $event->getMessages(), + ); + } catch (ServiceException $e) { + $this->logger->error('Could not classify incoming message importance: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } +} diff --git a/lib/Listener/TaskProcessingListener.php b/lib/Listener/TaskProcessingListener.php new file mode 100644 index 0000000000..8aabe0acd3 --- /dev/null +++ b/lib/Listener/TaskProcessingListener.php @@ -0,0 +1,67 @@ + + */ +class TaskProcessingListener implements IEventListener { + + public function __construct( + protected LoggerInterface $logger, + protected MessageMapper $messageStore, + ) { } + + public function handle(Event $event): void { + + if (!($event instanceof TaskSuccessfulEvent)) { + return; + } + + $task = $event->getTask(); + + if ($task->getAppId() !== Application::APP_ID) { + return; + } + + if ($task->getTaskTypeId() !== TextToTextSummary::ID) { + return; + } + + list($type, $id) = explode(':', $task->getCustomId()); + $userId = $task->getUserId(); + $summary = $task->getOutput()['output']; + + match ($type) { + 'message' => $this->handleMessageSummary($userId, (int)$id, $summary), + }; + + } + + protected function handleMessageSummary(string $userId, int $id, string $summary) { + $messages = $this->messageStore->findByIds($userId, [$id], ''); + + if (count($messages) !== 1) { + return; + } + + $message = $messages[0]; + $message->setSummary($summary); + $this->messageStore->update($message); + } +} diff --git a/lib/Migration/Version4100Date20241209000000.php b/lib/Migration/Version4100Date20241209000000.php new file mode 100644 index 0000000000..6161edaafc --- /dev/null +++ b/lib/Migration/Version4100Date20241209000000.php @@ -0,0 +1,38 @@ +getTable('mail_messages'); + if (!$outboxTable->hasColumn('summary')) { + $outboxTable->addColumn('summary', Types::STRING, [ + 'length' => 1024, + 'notnull' => false, + ]); + } + return $schema; + } +} diff --git a/lib/Service/AiIntegrations/AiIntegrationsService.php b/lib/Service/AiIntegrations/AiIntegrationsService.php index afd41fa19d..cdaa363862 100644 --- a/lib/Service/AiIntegrations/AiIntegrationsService.php +++ b/lib/Service/AiIntegrations/AiIntegrationsService.php @@ -9,6 +9,7 @@ namespace OCA\Mail\Service\AiIntegrations; +use Horde_Imap_Client_Socket; use JsonException; use OCA\Mail\Account; use OCA\Mail\AppInfo\Application; @@ -20,6 +21,10 @@ use OCA\Mail\Model\EventData; use OCA\Mail\Model\IMAPMessage; use OCP\IConfig; +use OCP\TaskProcessing\IManager as TaskProcessingManager; +use OCP\TaskProcessing\Task as TaskProcessingTask; +use OCP\TaskProcessing\TaskTypes\TextToTextSummary; +use OCP\TaskProcessing\Exception\Exception as TaskProcessingException; use OCP\TextProcessing\FreePromptTaskType; use OCP\TextProcessing\IManager; use OCP\TextProcessing\SummaryTaskType; @@ -61,6 +66,74 @@ public function __construct(ContainerInterface $container, Cache $cache, IMAPCli $this->mailManager = $mailManager; $this->config = $config; } + + /** + * generates summary for each message + * + * @param Account $account + * @param array $messages + * @param Horde_Imap_Client_Socket $client + * + * @return null|string + * + * @throws ServiceException + */ + public function summarizeMessages(Account $account, array $messages, Horde_Imap_Client_Socket $client = null): void { + try { + $manager = $this->container->get(TaskProcessingManager::class); + } catch (\Throwable $e) { + throw new ServiceException('Task processing is not available', 0, $e); + } + try { + $manager->getPreferredProvider(TextToTextSummary::ID); + } catch (TaskProcessingException $e) { + throw new ServiceException('No text summary provider available'); + } + if (!$client) { + $client = $this->clientFactory->getClient($account); + } + try { + foreach ($messages as $entry) { + + if (!empty($entry->getSummary())) { + continue; + } + // retrieve full message from server + $userId = $account->getUserId(); + $mailboxId = $entry->getMailboxId(); + $messageLocalId = $entry->getId(); + $messageRemoteId = $entry->getUid(); + $mailbox = $this->mailManager->getMailbox($userId, $mailboxId); + $message = $this->mailManager->getImapMessage( + $client, + $account, + $mailbox, + $messageRemoteId, + true + ); + $messageBody = $message->getPlainBody(); + // construct prompt and task + $prompt = "You are tasked with formulating a helpful summary of a email message. \r\n" . + "The summary should be less than 1024 characters. \r\n" . + "Here is the ***E-MAIL*** for which you must generate a helpful summary: \r\n" . + "***START_OF_E-MAIL***\r\n$messageBody\r\n***END_OF_E-MAIL***\r\n"; + $task = new TaskProcessingTask( + TextToTextSummary::ID, + [ + 'max_tokens' => 1024, + 'input' => $prompt, + ], + Application::APP_ID, + $userId, + 'message:' . (string)$messageLocalId + ); + $manager->scheduleTask($task); + } + } finally { + $client->logout(); + } + } + /** * @param Account $account * @param string $threadId