Skip to content

Commit

Permalink
Merge pull request #8863 from nextcloud/feat/suggest-replies
Browse files Browse the repository at this point in the history
Smart replies for mail
  • Loading branch information
hamza221 authored Jan 12, 2024
2 parents cced937 + b8e09a2 commit 81e0746
Show file tree
Hide file tree
Showing 21 changed files with 390 additions and 60 deletions.
10 changes: 10 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,11 @@
'url' => '/api/messages/{id}/mdn',
'verb' => 'POST'
],
[
'name' => 'messages#smartReply',
'url' => '/api/messages/{messageId}/smartreply',
'verb' => 'GET'
],
[
'name' => 'avatars#url',
'url' => '/api/avatars/url/{email}',
Expand Down Expand Up @@ -345,6 +350,11 @@
'url' => '/api/settings/threadsummary',
'verb' => 'PUT'
],
[
'name' => 'settings#setEnabledSmartReplies',
'url' => '/api/settings/smartreply',
'verb' => 'PUT'
],
[
'name' => 'trusted_senders#setTrusted',
'url' => '/api/trustedsenders/{email}',
Expand Down
34 changes: 33 additions & 1 deletion lib/Controller/MessagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\Model\SmimeData;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
use OCA\Mail\Service\ItineraryService;
use OCA\Mail\Service\SmimeService;
use OCA\Mail\Service\SnoozeService;
Expand Down Expand Up @@ -90,6 +91,7 @@ class MessagesController extends Controller {
private IDkimService $dkimService;
private IUserPreferences $preferences;
private SnoozeService $snoozeService;
private AiIntegrationsService $aiIntegrationService;

public function __construct(string $appName,
IRequest $request,
Expand All @@ -110,7 +112,8 @@ public function __construct(string $appName,
IMAPClientFactory $clientFactory,
IDkimService $dkimService,
IUserPreferences $preferences,
SnoozeService $snoozeService) {
SnoozeService $snoozeService,
AiIntegrationsService $aiIntegrationService) {
parent::__construct($appName, $request);
$this->accountService = $accountService;
$this->mailManager = $mailManager;
Expand All @@ -130,6 +133,7 @@ public function __construct(string $appName,
$this->dkimService = $dkimService;
$this->preferences = $preferences;
$this->snoozeService = $snoozeService;
$this->aiIntegrationService = $aiIntegrationService;
}

/**
Expand Down Expand Up @@ -887,6 +891,34 @@ public function destroy(int $id): JSONResponse {
return new JSONResponse();
}

/**
* @NoAdminRequired
*
* @param int $messageId
*
* @return JSONResponse
*/
#[TrapError]
public function smartReply(int $messageId):JSONResponse {
try {
$message = $this->mailManager->getMessage($this->currentUserId, $messageId);
$mailbox = $this->mailManager->getMailbox($this->currentUserId, $message->getMailboxId());
$account = $this->accountService->find($this->currentUserId, $mailbox->getAccountId());
} catch (DoesNotExistException $e) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
try {
$replies = $this->aiIntegrationService->getSmartReply($account, $mailbox, $message, $this->currentUserId);
} catch (ServiceException $e) {
$this->logger->error('Smart reply failed: ' . $e->getMessage(), [
'exception' => $e,
]);
return new JSONResponse([], Http::STATUS_NO_CONTENT);
}
return new JSONResponse($replies);

}

/**
* @param int $id
* @param array $attachment
Expand Down
9 changes: 8 additions & 1 deletion lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\TextProcessing\FreePromptTaskType;
use OCP\TextProcessing\SummaryTaskType;
use OCP\User\IAvailabilityCoordinator;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
Expand Down Expand Up @@ -276,7 +278,12 @@ public function index(): TemplateResponse {

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

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

$this->initialStateService->provideInitialState(
Expand Down
12 changes: 3 additions & 9 deletions lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@
use OCP\AppFramework\Http\JSONResponse;
use OCP\IConfig;
use OCP\IRequest;
use OCP\TextProcessing\IManager;
use OCP\TextProcessing\SummaryTaskType;
use Psr\Container\ContainerInterface;

use function array_merge;
Expand Down Expand Up @@ -131,12 +129,8 @@ public function setEnabledThreadSummary(bool $enabled) {
$this->config->setAppValue('mail', 'enabled_thread_summary', $enabled ? 'yes' : 'no');
}

public function isLlmConfigured() {
try {
$manager = $this->container->get(IManager::class);
} catch (\Throwable $e) {
return new JSONResponse(['data' => false]);
}
return new JSONResponse(['data' => in_array(SummaryTaskType::class, $manager->getAvailableTaskTypes(), true)]);
public function setEnabledSmartReplies(bool $enabled) {
$this->config->setAppValue('mail', 'enabled_smart_reply', $enabled ? 'yes' : 'no');
}

}
82 changes: 77 additions & 5 deletions lib/Service/AiIntegrations/AiIntegrationsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@
use OCA\Mail\Account;
use OCA\Mail\Contracts\IMailManager;
use OCA\Mail\Db\Mailbox;
use OCA\Mail\Db\Message;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\Model\IMAPMessage;
use OCP\TextProcessing\FreePromptTaskType;
use OCP\TextProcessing\IManager;
use OCP\TextProcessing\SummaryTaskType;
use OCP\TextProcessing\Task;
Expand Down Expand Up @@ -64,7 +67,7 @@ public function __construct(ContainerInterface $container, Cache $cache, IMAPCli
*
* @throws ServiceException
*/
public function summarizeThread(Account $account, Mailbox $mailbox, $threadId, array $messages, string $currentUserId): null|string {
public function summarizeThread(Account $account, Mailbox $mailbox, string $threadId, array $messages, string $currentUserId): null|string {
try {
$manager = $this->container->get(IManager::class);
} catch (\Throwable $e) {
Expand All @@ -74,7 +77,7 @@ public function summarizeThread(Account $account, Mailbox $mailbox, $threadId, a
$messageIds = array_map(function ($message) {
return $message->getMessageId();
}, $messages);
$cachedSummary = $this->cache->getSummary($messageIds);
$cachedSummary = $this->cache->getValue($this->cache->buildUrlKey($messageIds));
if($cachedSummary) {
return $cachedSummary;
}
Expand All @@ -99,20 +102,89 @@ public function summarizeThread(Account $account, Mailbox $mailbox, $threadId, a
$manager->runTask($summaryTask);
$summary = $summaryTask->getOutput();

$this->cache->addSummary($messageIds, $summary);
$this->cache->addValue($this->cache->buildUrlKey($messageIds), $summary);

return $summary;
} else {
throw new ServiceException('No language model available for summary');
}
}

public function isLlmAvailable(): bool {
/**
* @return string[]
*/
public function getSmartReply(Account $account, Mailbox $mailbox, Message $message, string $currentUserId): array {
try {
$manager = $this->container->get(IManager::class);
} catch (\Throwable $e) {
throw new ServiceException('Text processing is not available in your current Nextcloud version', 0, $e);
}
if (in_array(FreePromptTaskType::class, $manager->getAvailableTaskTypes(), true)) {
$cachedReplies = $this->cache->getValue('smartReplies_'.$message->getId());
if ($cachedReplies) {
return explode("|", $cachedReplies);
}
$client = $this->clientFactory->getClient($account);
try {
$imapMessage = $this->mailManager->getImapMessage(
$client,
$account,
$mailbox,
$message->getUid(), true
);
if (!$this->isPersonalEmail($imapMessage)) {
return [];
}
$messageBody = $imapMessage->getPlainBody();

} finally {
$client->logout();
}
$prompt = "Suggest 2 replies to the following email. Each reply should be 25 characters max. Separate the replies with \"| \", like for example \"Yes! | No, I'm not available \". Do not print anything else. The email contents are : ".$messageBody."";
$task = new Task(FreePromptTaskType::class, $prompt, 'mail,', $currentUserId);
$manager->runTask($task);
$replies = array_slice(explode("|", $task->getOutput()), 0, 2);
$this->cache->addValue('smartReplies_'.$message->getUid(), implode("|", $replies));
return $replies;

} else {
throw new ServiceException('No language model available for smart replies');
}

}

public function isLlmAvailable(string $taskType): bool {
try {
$manager = $this->container->get(IManager::class);
} catch (\Throwable $e) {
return false;
}
return in_array($taskType, $manager->getAvailableTaskTypes(), true);
}

private function isPersonalEmail(IMAPMessage $imapMessage): bool {

if ($imapMessage->isOneClickUnsubscribe() || $imapMessage->getUnsubscribeUrl() !== null) {
return false;
}
return in_array(SummaryTaskType::class, $manager->getAvailableTaskTypes(), true);

$commonPatterns = [
'noreply', 'no-reply', 'notification', 'donotreply', 'donot-reply','noreply-', 'do-not-reply',
'automated', 'donotreply-', 'noreply.', 'noreply_', 'do_not_reply', 'no_reply', 'no-reply',
'automated-', 'do_not_reply', 'noreply+'
];

$senderAddress = $imapMessage->getFrom()->first()?->getEmail();

if($senderAddress !== null) {
foreach ($commonPatterns as $pattern) {
if (stripos($senderAddress, $pattern) !== false) {
return false;
}
}
}
return true;
}


}
16 changes: 8 additions & 8 deletions lib/Service/AiIntegrations/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,18 @@ public function __construct(ICacheFactory $cacheFactory) {
* @param array $ids
* @return string
*/
private function buildUrlKey(array $ids): string {
public function buildUrlKey(array $ids): string {
return base64_encode(json_encode($ids));
}


/**
* @param array $ids
*
* @return string|false the summary if cached, false if cached but no value or not cached
* @return string|false the value if cached, false if cached but no value or not cached
*/
public function getSummary(array $ids) {
$cached = $this->cache->get($this->buildUrlKey($ids));
public function getValue(string $key) {
$cached = $this->cache->get($key);

if (is_null($cached) || $cached === false) {
return false;
Expand All @@ -66,13 +66,13 @@ public function getSummary(array $ids) {
}

/**
* @param array $ids
* @param string|null $summary
* @param string $key
* @param string|null $value
*
* @return void
*/
public function addSummary(array $ids, ?string $summary): void {
$this->cache->set($this->buildUrlKey($ids), $summary === null ? false : $summary, self::CACHE_TTL);
public function addValue(string $key, ?string $value): void {
$this->cache->set($key, $value === null ? false : $value, self::CACHE_TTL);
}


Expand Down
18 changes: 16 additions & 2 deletions lib/Settings/AdminSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
use OCP\IInitialStateService;
use OCP\LDAP\ILDAPProvider;
use OCP\Settings\ISettings;
use OCP\TextProcessing\FreePromptTaskType;
use OCP\TextProcessing\SummaryTaskType;

class AdminSettings implements ISettings {
/** @var IInitialStateService */
Expand Down Expand Up @@ -98,8 +100,20 @@ public function getForm() {

$this->initialStateService->provideInitialState(
Application::APP_ID,
'enabled_llm_backend',
$this->aiIntegrationsService->isLlmAvailable()
'enabled_smart_reply',
$this->config->getAppValue('mail', 'enabled_smart_reply', 'no') === 'yes'
);

$this->initialStateService->provideInitialState(
Application::APP_ID,
'enabled_llm_free_prompt_backend',
$this->aiIntegrationsService->isLlmAvailable(FreePromptTaskType::class)
);

$this->initialStateService->provideInitialState(
Application::APP_ID,
'enabled_llm_summary_backend',
$this->aiIntegrationsService->isLlmAvailable(SummaryTaskType::class)
);

$this->initialStateService->provideLazyInitialState(
Expand Down
1 change: 1 addition & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
<referencedClass name="OCP\TextProcessing\SummaryTaskType" />
<referencedClass name="OCP\TextProcessing\Task" />
<referencedClass name="OCP\TextProcessing\SummaryTaskType" />
<referencedClass name="OCP\TextProcessing\FreePromptTaskType" />
<referencedClass name="OCP\User\Events\OutOfOfficeEndedEvent" /><!-- 28+ -->
<referencedClass name="OCP\User\Events\OutOfOfficeStartedEvent" /><!-- 28+ -->
<referencedClass name="OCP\User\Events\OutOfOfficeScheduledEvent" /><!-- 28+ -->
Expand Down
8 changes: 8 additions & 0 deletions src/components/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,11 @@ export default {
required: false,
default: () => [],
},
smartReply: {
type: String,
required: false,
default: undefined,
},
sendAt: {
type: Number,
default: undefined,
Expand Down Expand Up @@ -1069,6 +1074,9 @@ export default {
onEditorReady(editor) {
this.bodyVal = editor.getData()
this.insertSignature()
if (this.smartReply) {
this.bus.$emit('append-to-body-at-cursor', this.smartReply)
}
},
onChangeSendLater(value) {
this.sendAtVal = value ? Number.parseInt(value, 10) : undefined
Expand Down
Loading

0 comments on commit 81e0746

Please sign in to comment.