Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
This commit is contained in:
Richard Steinmetz 2024-04-14 15:01:32 +02:00
Родитель 3b879b43b5
Коммит 221ff11205
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 27137D9E7D273FB2
31 изменённых файлов: 1494 добавлений и 37 удалений

Просмотреть файл

@ -465,6 +465,11 @@ return [
'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'],

Просмотреть файл

@ -40,6 +40,7 @@ use OCA\Mail\Http\Middleware\ProvisioningMiddleware;
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;
@ -128,6 +129,7 @@ class Application extends App implements IBootstrap {
$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)

Просмотреть файл

@ -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,
);
}
}

Просмотреть файл

@ -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,
]);
}
}

Просмотреть файл

@ -199,6 +199,7 @@ class PageController extends Controller {
'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',
@ -266,12 +267,18 @@ class PageController extends Controller {
$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(

Просмотреть файл

@ -55,4 +55,55 @@ class ThreadMapper extends QBMapper {
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;
}
}

Просмотреть файл

@ -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);
}
}
}
}

Просмотреть файл

@ -11,6 +11,7 @@ namespace OCA\Mail\Service\AiIntegrations;
use JsonException;
use OCA\Mail\Account;
use OCA\Mail\AppInfo\Application;
use OCA\Mail\Contracts\IMailManager;
use OCA\Mail\Db\Mailbox;
use OCA\Mail\Db\Message;
@ -18,6 +19,7 @@ use OCA\Mail\Exception\ServiceException;
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\Model\EventData;
use OCA\Mail\Model\IMAPMessage;
use OCP\IConfig;
use OCP\TextProcessing\FreePromptTaskType;
use OCP\TextProcessing\IManager;
use OCP\TextProcessing\SummaryTaskType;
@ -43,6 +45,8 @@ class AiIntegrationsService {
/** @var IMailManager */
private IMailManager $mailManager;
private IConfig $config;
private const EVENT_DATA_PROMPT_PREAMBLE = <<<PROMPT
I am scheduling an event based on an email thread and need an event title and agenda. Provide the result as JSON with keys for "title" and "agenda". For example ```{ "title": "Project kick-off meeting", "agenda": "* Introduction\\n* Project goals\\n* Next steps" }```.
@ -50,11 +54,12 @@ The email contents are:
PROMPT;
public function __construct(ContainerInterface $container, Cache $cache, IMAPClientFactory $clientFactory, IMailManager $mailManager) {
public function __construct(ContainerInterface $container, Cache $cache, IMAPClientFactory $clientFactory, IMailManager $mailManager, IConfig $config) {
$this->container = $container;
$this->cache = $cache;
$this->clientFactory = $clientFactory;
$this->mailManager = $mailManager;
$this->config = $config;
}
/**
* @param Account $account
@ -213,7 +218,74 @@ PROMPT;
} else {
throw new ServiceException('No language model available for smart replies');
}
}
/**
* Analyze whether a sender of an email expects a reply based on the email's body.
*
* @throws ServiceException
*/
public function requiresFollowUp(
Account $account,
Mailbox $mailbox,
Message $message,
string $currentUserId,
): bool {
try {
$manager = $this->container->get(IManager::class);
} catch (ContainerExceptionInterface $e) {
throw new ServiceException(
'Text processing is not available in your current Nextcloud version',
0,
$e,
);
}
if (!in_array(FreePromptTaskType::class, $manager->getAvailableTaskTypes(), true)) {
throw new ServiceException('No language model available for smart replies');
}
$client = $this->clientFactory->getClient($account);
try {
$imapMessage = $this->mailManager->getImapMessage(
$client,
$account,
$mailbox,
$message->getUid(),
true,
);
} finally {
$client->logout();
}
if (!$this->isPersonalEmail($imapMessage)) {
return false;
}
$messageBody = $imapMessage->getPlainBody();
$messageBody = str_replace('"', '\"', $messageBody);
$prompt = "Consider the following TypeScript function prototype:
---
/**
* This function takes in an email text and returns a boolean indicating whether the email author expects a response.
*
* @param emailText - string with the email text
* @returns boolean true if the email expects a reply, false if not
*/
declare function doesEmailExpectReply(emailText: string): Promise<boolean>;
---
Tell me what the function outputs for the following parameters.
emailText: \"$messageBody\"
The JSON output should be in the form: {\"expectsReply\": true}
Never return null or undefined.";
$task = new Task(FreePromptTaskType::class, $prompt, Application::APP_ID, $currentUserId);
$manager->runTask($task);
// Can't use json_decode() here because the output contains additional garbage
return preg_match('/{\s*"expectsReply"\s*:\s*true\s*}/i', $task->getOutput()) === 1;
}
public function isLlmAvailable(string $taskType): bool {
@ -225,6 +297,13 @@ PROMPT;
return in_array($taskType, $manager->getAvailableTaskTypes(), true);
}
/**
* Whether the llm_processing admin setting is enabled globally on this instance.
*/
public function isLlmProcessingEnabled(): bool {
return $this->config->getAppValue(Application::APP_ID, 'llm_processing', 'no') === 'yes';
}
private function isPersonalEmail(IMAPMessage $imapMessage): bool {
if ($imapMessage->isOneClickUnsubscribe() || $imapMessage->getUnsubscribeUrl() !== null) {

Просмотреть файл

@ -107,12 +107,14 @@ class ImapToDbSynchronizer {
$rebuildThreads = false;
$trashMailboxId = $account->getMailAccount()->getTrashMailboxId();
$snoozeMailboxId = $account->getMailAccount()->getSnoozeMailboxId();
$sentMailboxId = $account->getMailAccount()->getSentMailboxId();
$trashRetentionDays = $account->getMailAccount()->getTrashRetentionDays();
foreach ($this->mailboxMapper->findAll($account) as $mailbox) {
$syncTrash = $trashMailboxId === $mailbox->getId() && $trashRetentionDays !== null;
$syncSnooze = $snoozeMailboxId === $mailbox->getId();
$syncSent = $sentMailboxId === $mailbox->getId() || $mailbox->isSpecialUse('sent');
if (!$syncTrash && !$mailbox->isInbox() && !$syncSnooze && !$mailbox->getSyncInBackground()) {
if (!$syncTrash && !$mailbox->isInbox() && !$syncSnooze && !$mailbox->getSyncInBackground() && !$syncSent) {
$logger->debug("Skipping mailbox sync for " . $mailbox->getId());
continue;
}

Просмотреть файл

@ -83,7 +83,7 @@ class AdminSettings implements ISettings {
$this->initialStateService->provideInitialState(
Application::APP_ID,
'llm_processing',
$this->config->getAppValue('mail', 'llm_processing', 'no') === 'yes'
$this->aiIntegrationsService->isLlmProcessingEnabled(),
);
$this->initialStateService->provideInitialState(

Просмотреть файл

@ -88,10 +88,9 @@
<label for="data-collection-toggle">{{ optOutSettingsText }}</label>
</p>
</NcAppSettingsSection>
<NcAppSettingsSection id="autotagging-settings" :name="t('mail', 'Auto tagging text')">
<NcAppSettingsSection id="autotagging-settings" :name="t('mail', 'Assistance features')">
<p v-if="toggleAutoTagging" class="app-settings">
<IconLoading :size="20" />
{{ autoTaggingText }}
</p>
<p v-else class="app-settings">
<input id="auto-tagging-toggle"
@ -101,6 +100,14 @@
@change="onToggleAutoTagging">
<label for="auto-tagging-toggle">{{ autoTaggingText }}</label>
</p>
<p v-if="isFollowUpFeatureAvailable" class="app-settings">
<input id="follow-up-reminder-toggle"
class="checkbox"
type="checkbox"
:checked="useFollowUpReminders"
@change="onToggleFollowUpReminders">
<label for="follow-up-reminder-toggle">{{ followUpReminderText }}</label>
</p>
</NcAppSettingsSection>
<NcAppSettingsSection id="trusted-sender" :name="t('mail', 'Trusted senders')">
<TrustedSenders />
@ -273,6 +280,7 @@ import Logger from '../logger.js'
import SmimeCertificateModal from './smime/SmimeCertificateModal.vue'
import TrustedSenders from './TrustedSenders.vue'
import isMobile from '@nextcloud/vue/dist/Mixins/isMobile.js'
import { mapGetters } from 'vuex'
export default {
name: 'AppSettingsMenu',
@ -311,6 +319,8 @@ export default {
loadingReplySettings: false,
// eslint-disable-next-line
autoTaggingText: t('mail', 'Automatically classify importance of new email'),
// eslint-disable-next-line
followUpReminderText: t('mail', 'Remind about messages that require a reply but received none'),
toggleAutoTagging: false,
displaySmimeCertificateModal: false,
sortOrder: 'newest',
@ -319,6 +329,9 @@ export default {
}
},
computed: {
...mapGetters([
'isFollowUpFeatureAvailable',
]),
searchPriorityBody() {
return this.$store.getters.getPreference('search-priority-body', 'false') === 'true'
},
@ -334,6 +347,9 @@ export default {
useAutoTagging() {
return this.$store.getters.getPreference('tag-classified-messages', 'true') === 'true'
},
useFollowUpReminders() {
return this.$store.getters.getPreference('follow-up-reminders', 'true') === 'true'
},
allowNewMailAccounts() {
return this.$store.getters.getPreference('allow-new-accounts', true)
},
@ -464,6 +480,17 @@ export default {
this.toggleAutoTagging = false
}
},
async onToggleFollowUpReminders(e) {
try {
await this.$store.dispatch('savePreference', {
key: 'follow-up-reminders',
value: e.target.checked ? 'true' : 'false',
})
} catch (error) {
Logger.error('Could not save preferences', { error })
showError(t('mail', 'Could not update preference'))
}
},
registerProtocolHandler() {
if (window.navigator.registerProtocolHandler) {
const url

Просмотреть файл

@ -306,7 +306,9 @@
<div class="tag-group__bg"
:style="{'background-color': tag.color}" />
<span class="tag-group__label"
:style="{color: tag.color}">{{ tag.displayName }} </span>
:style="{color: tag.color}">
{{ translateTagDisplayName(tag) }}
</span>
</div>
<MoveModal v-if="showMoveModal"
:account="account"
@ -381,6 +383,8 @@ import CalendarClock from 'vue-material-design-icons/CalendarClock.vue'
import AlarmIcon from 'vue-material-design-icons/Alarm.vue'
import moment from '@nextcloud/moment'
import { mapGetters } from 'vuex'
import { FOLLOW_UP_TAG_LABEL } from '../store/constants.js'
import { translateTagDisplayName } from '../util/tag.js'
export default {
name: 'Envelope',
@ -563,9 +567,16 @@ export default {
.some((tag) => tag.imapLabel === '$label1')
},
tags() {
return this.$store.getters.getEnvelopeTags(this.data.databaseId).filter(
let tags = this.$store.getters.getEnvelopeTags(this.data.databaseId).filter(
(tag) => tag.imapLabel && tag.imapLabel !== '$label1' && !(tag.displayName.toLowerCase() in hiddenTags),
)
// Don't show follow-up tag in unified mailbox as it has its own section at the top
if (this.mailbox.isUnified) {
tags = tags.filter((tag) => tag.imapLabel !== FOLLOW_UP_TAG_LABEL)
}
return tags
},
draggableLabel() {
let label = this.data.subject
@ -673,6 +684,7 @@ export default {
},
},
methods: {
translateTagDisplayName,
setSelected(value) {
if (this.selected !== value) {
this.$emit('update:selected', value)

Просмотреть файл

@ -30,6 +30,34 @@
:bus="bus"
:open-first="mailbox.specialRole !== 'drafts'" />
<template v-else>
<div v-show="hasFollowUpEnvelopes"
class="app-content-list-item">
<SectionTitle class="section-title"
:name="t('mail', 'Follow up')" />
<Popover trigger="hover focus">
<template #trigger>
<ButtonVue type="tertiary-no-background"
:aria-label="t('mail', 'Follow up info')"
class="button">
<template #icon>
<IconInfo :size="20" />
</template>
</ButtonVue>
</template>
<p class="section-header-info">
{{ followupInfo }}
</p>
</Popover>
</div>
<Mailbox v-show="hasFollowUpEnvelopes"
:account="unifiedAccount"
:mailbox="followUpMailbox"
:search-query="appendToSearch(followUpQuery)"
:paginate="'manual'"
:is-priority-inbox="true"
:initial-page-size="followUpMessagesInitialPageSize"
:collapsible="true"
:bus="bus" />
<div v-show="hasImportantEnvelopes" class="app-content-list-item">
<SectionTitle class="section-title important"
:name="t('mail', 'Important')" />
@ -43,7 +71,7 @@
</template>
</ButtonVue>
</template>
<p class="important-info">
<p class="section-header-info">
{{ importantInfo }}
</p>
</Popover>
@ -92,7 +120,12 @@ import Mailbox from './Mailbox.vue'
import SearchMessages from './SearchMessages.vue'
import NoMessageSelected from './NoMessageSelected.vue'
import Thread from './Thread.vue'
import { UNIFIED_ACCOUNT_ID, UNIFIED_INBOX_ID } from '../store/constants.js'
import {
FOLLOW_UP_MAILBOX_ID,
PRIORITY_INBOX_ID,
UNIFIED_ACCOUNT_ID,
UNIFIED_INBOX_ID,
} from '../store/constants.js'
import {
priorityImportantQuery,
priorityOtherQuery,
@ -133,6 +166,7 @@ export default {
return {
// eslint-disable-next-line
importantInfo: t('mail', 'Messages will automatically be marked as important based on which messages you interacted with or marked as important. In the beginning you might have to manually change the importance to teach the system, but it will improve over time.'),
followupInfo: t('mail', 'Messages sent by you that require a reply but did not receive one after a couple of days will be shown here.'),
bus: mitt(),
searchQuery: undefined,
shortkeys: {
@ -160,6 +194,24 @@ export default {
unifiedInbox() {
return this.$store.getters.getMailbox(UNIFIED_INBOX_ID)
},
followUpMailbox() {
return this.$store.getters.getMailbox(FOLLOW_UP_MAILBOX_ID)
},
/**
* @return {string|undefined}
*/
followUpQuery() {
const tag = this.$store.getters.getFollowUpTag
if (!tag) {
logger.warn('No follow-up tag available')
return undefined
}
const notAfter = new Date()
notAfter.setDate(notAfter.getDate() - 4)
const dateToTimestamp = (date) => Math.round(date.getTime() / 1000)
return `tags:${tag.id} end:${dateToTimestamp(notAfter)}`
},
hasEnvelopes() {
if (this.mailbox.isPriorityInbox) {
return this.$store.getters.getEnvelopes(this.mailbox.databaseId, this.appendToSearch(priorityImportantQuery)).length > 0
@ -170,6 +222,24 @@ export default {
hasImportantEnvelopes() {
return this.$store.getters.getEnvelopes(this.unifiedInbox.databaseId, this.appendToSearch(priorityImportantQuery)).length > 0
},
/**
* @return {boolean}
*/
hasFollowUpEnvelopes() {
// TODO: remove this version check once we only support >= 27.1
const [major, minor] = OC.config.version.split('.').map(parseInt)
if (major < 27 || (major === 27 && minor < 1)) {
return false
}
if (!this.followUpQuery) {
return false
}
return this.$store.getters
.getEnvelopes(FOLLOW_UP_MAILBOX_ID, this.followUpQuery)
.length > 0
},
importantMessagesInitialPageSize() {
if (window.innerHeight > 900) {
return 7
@ -179,6 +249,12 @@ export default {
}
return 3
},
/**
* @return {number}
*/
followUpMessagesInitialPageSize() {
return 5
},
showThread() {
return (this.mailbox.isPriorityInbox === true || this.hasEnvelopes)
&& this.$route.name === 'message'
@ -198,8 +274,18 @@ export default {
},
},
watch: {
$route() {
async $route(to) {
this.handleMailto()
if (to.name === 'mailbox' && to.params.mailboxId === PRIORITY_INBOX_ID) {
await this.onPriorityMailboxOpened()
}
},
async hasFollowUpEnvelopes(value) {
if (!value) {
return
}
await this.onPriorityMailboxOpened()
},
mailbox() {
clearTimeout(this.startMailboxTimer)
@ -209,13 +295,18 @@ export default {
created() {
this.handleMailto()
},
mounted() {
async mounted() {
setTimeout(this.saveStartMailbox, START_MAILBOX_DEBOUNCE)
},
beforeUnmount() {
clearTimeout(this.startMailboxTimer)
},
methods: {
async onPriorityMailboxOpened() {
logger.debug('Priority inbox was opened')
await this.$store.dispatch('checkFollowUpReminders', { query: this.followUpQuery })
},
deleteMessage(id) {
this.bus.emit('delete', id)
},
@ -323,7 +414,7 @@ export default {
z-index: 1;
}
.important-info {
.section-header-info {
max-width: 230px;
padding: 16px;
}

Просмотреть файл

@ -54,7 +54,7 @@
<template #icon>
<ReplyIcon />
</template>
{{ multipleRecipients ? t('mail','Reply all') :t('mail','Reply') }}
{{ replyButtonLabel }}
</NcButton>
</div>
</div>
@ -107,10 +107,10 @@ export default {
type: Array,
default: () => [],
},
multipleRecipients: {
replyButtonLabel: {
required: true,
type: Boolean,
},
type: String,
}
},
computed: {
from() {

Просмотреть файл

@ -9,7 +9,7 @@
color: convertHex(tag.color, 1),
'background-color': convertHex(tag.color, 0.15)
}">
{{ tag.displayName }}
{{ translateTagDisplayName(tag) }}
</button>
<Actions :force-menu="true">
<NcActionButton v-if="renameTagLabel"
@ -60,6 +60,7 @@ import { NcColorPicker, NcActions as Actions, NcActionButton, NcActionText as Ac
import { showInfo } from '@nextcloud/dialogs'
import DeleteIcon from 'vue-material-design-icons/Delete.vue'
import IconEdit from 'vue-material-design-icons/Pencil.vue'
import { translateTagDisplayName } from '../util/tag.js'
export default {
name: 'TagItem',
@ -96,6 +97,7 @@ export default {
}
},
methods: {
translateTagDisplayName,
deleteTag() {
this.$emit('delete-tag', this.tag)
},

Просмотреть файл

@ -7,6 +7,18 @@
<div ref="envelope"
class="envelope"
:class="{'envelope--expanded' : expanded }">
<div v-if="showFollowUpHeader"
class="envelope__follow-up-header">
<span class="envelope__follow-up-header__date">
{{ t('mail', "You've sent this message on {date}", { date: formattedSentAt }) }}
</span>
<div class="envelope__follow-up-header__actions">
<NcButton @click="onDisableFollowUpReminder">
{{ t('mail', 'Disable reminder') }}
</NcButton>
</div>
</div>
<div class="envelope__header">
<div class="envelope__header__avatar">
<Avatar v-if="envelope.from && envelope.from[0]"
@ -58,7 +70,9 @@
<div class="tag-group__bg"
:style="{'background-color': tag.color}" />
<span class="tag-group__label"
:style="{color: tag.color}">{{ tag.displayName }} </span>
:style="{color: tag.color}">
{{ translateTagDisplayName(tag) }}
</span>
</div>
</div>
<div class="envelope__header__left__unsubscribe">
@ -189,10 +203,10 @@
:envelope="envelope"
:message="message"
:full-height="fullHeight"
:smart-replies="smartReplies"
:multiple-recipients="hasMultipleRecipients"
:smart-replies="showFollowUpHeader ? [] : smartReplies"
:reply-button-label="replyButtonLabel"
@load="loading = LOADING_DONE"
@reply="onReply" />
@reply="(body) => onReply(body, showFollowUpHeader)" />
<Error v-else-if="error"
:error="error.message || t('mail', 'Not found')"
message=""
@ -265,6 +279,9 @@ import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import useOutboxStore from '../store/outboxStore.js'
import { mapStores } from 'pinia'
import moment from '@nextcloud/moment'
import { translateTagDisplayName } from '../util/tag.js'
import { FOLLOW_UP_TAG_LABEL } from '../store/constants.js'
// Ternary loading state
const LOADING_DONE = 0
@ -496,6 +513,43 @@ export default {
return t('mail', 'This message contains an unverified digital S/MIME signature. The message might have been changed since it was sent or the certificate of the signer is untrusted.')
},
/**
* A human readable representation of envelope's sent date (without the time).
*
* @return {string}
*/
formattedSentAt() {
return moment(this.envelope.dateInt * 1000).format('LL')
},
/**
* @return {boolean}
*/
showFollowUpHeader() {
// TODO: remove this version check once we only support >= 27.1
const [major, minor] = OC.config.version.split('.').map(parseInt)
if (major < 27 || (major === 27 && minor < 1)) {
return false
}
const tags = this.$store.getters.getEnvelopeTags(this.envelope.databaseId)
return tags.some((tag) => tag.imapLabel === FOLLOW_UP_TAG_LABEL)
},
/**
* Translated label for the reply button.
*
* @return {string}
*/
replyButtonLabel() {
if (this.showFollowUpHeader) {
return t('mail', 'Follow up')
}
if (this.hasMultipleRecipients) {
return t('mail', 'Reply all')
}
return t('mail', 'Reply')
},
},
watch: {
expanded(expanded) {
@ -533,6 +587,7 @@ export default {
window.removeEventListener('resize', this.redrawMenuBar)
},
methods: {
translateTagDisplayName,
redrawMenuBar() {
this.$nextTick(() => {
this.recomputeMenuSize++
@ -580,7 +635,7 @@ export default {
}
// Fetch smart replies
if (this.enabledSmartReply && this.message && !['trash', 'junk'].includes(this.mailbox.specialRole)) {
if (this.enabledSmartReply && this.message && !['trash', 'junk'].includes(this.mailbox.specialRole) && !this.showFollowUpHeader) {
this.smartReplies = await smartReply(this.envelope.databaseId)
}
},
@ -620,12 +675,13 @@ export default {
const top = this.$el.getBoundingClientRect().top - globalHeader - threadHeader
window.scrollTo({ top })
},
onReply(body = '') {
onReply(body = '', followUp = false) {
this.$store.dispatch('startComposerSession', {
reply: {
mode: this.hasMultipleRecipients ? 'replyAll' : 'reply',
data: this.envelope,
smartReply: body,
followUp,
},
})
},
@ -689,6 +745,11 @@ export default {
return t('mail', 'Could not archive message')
}
},
async onDisableFollowUpReminder() {
await this.$store.dispatch('clearFollowUpReminder', {
envelope: this.envelope,
})
},
async unsubscribeViaOneClick() {
try {
this.unsubscribing = true
@ -865,6 +926,24 @@ export default {
padding-bottom: 0;
}
&__follow-up-header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 15px;
padding: 10px;
&__date {
flex-shrink: 1;
}
&__actions {
flex-shrink: 0;
display: flex;
gap: 5px;
}
}
&__header {
position: relative;
display: flex;

Просмотреть файл

@ -102,6 +102,10 @@ store.commit('savePreference', {
key: 'layout-mode',
value: getPreferenceFromPage('layout-mode'),
})
store.commit('savePreference', {
key: 'follow-up-reminders',
value: getPreferenceFromPage('follow-up-reminders'),
})
const accountSettings = loadState('mail', 'account-settings')
const accounts = loadState('mail', 'accounts', [])
@ -111,6 +115,7 @@ const disableScheduledSend = loadState('mail', 'disable-scheduled-send')
const disableSnooze = loadState('mail', 'disable-snooze')
const googleOauthUrl = loadState('mail', 'google-oauth-url', null)
const microsoftOauthUrl = loadState('mail', 'microsoft-oauth-url', null)
const followUpFeatureAvailable = loadState('mail', 'llm_followup_available', false)
accounts.map(fixAccountId).forEach((account) => {
const settings = accountSettings.find(settings => settings.accountId === account.id)
@ -133,6 +138,7 @@ store.commit('setScheduledSendingDisabled', disableScheduledSend)
store.commit('setSnoozeDisabled', disableSnooze)
store.commit('setGoogleOauthUrl', googleOauthUrl)
store.commit('setMicrosoftOauthUrl', microsoftOauthUrl)
store.commit('setFollowUpFeatureAvailable', followUpFeatureAvailable)
const smimeCertificates = loadState('mail', 'smime-certificates', [])
store.commit('setSmimeCertificates', smimeCertificates)

Просмотреть файл

@ -0,0 +1,20 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
/**
* Check wheter the given message database ids have been replied to.
*
* @param {number[]} messageIds The message database ids to check.
* @return {Promise<{wasFollowedUp: number[]}>} The ids that have been replied to and no longer need to be tracked as a follow-up reminder.
*/
export async function checkMessageIds(messageIds) {
const url = generateUrl('/apps/mail/api/follow-up/check-message-ids')
const response = await axios.post(url, { messageIds })
return response.data.data
}

Просмотреть файл

@ -76,7 +76,7 @@ import {
updateAccount as updateSieveAccount,
updateActiveScript,
} from '../service/SieveService.js'
import { PAGE_SIZE, UNIFIED_INBOX_ID } from './constants.js'
import { FOLLOW_UP_TAG_LABEL, PAGE_SIZE, UNIFIED_INBOX_ID } from './constants.js'
import * as ThreadService from '../service/ThreadService.js'
import {
getPrioritySearchQueries,
@ -102,6 +102,7 @@ import {
} from '../service/caldavService.js'
import * as SmimeCertificateService from '../service/SmimeCertificateService.js'
import useOutboxStore from './outboxStore.js'
import * as FollowUpService from '../service/FollowUpService.js'
const sliceToPage = slice(0, PAGE_SIZE)
@ -379,10 +380,14 @@ export default {
if (reply.mode === 'reply') {
logger.debug('Show simple reply composer', { reply })
let to = original.replyTo !== undefined ? original.replyTo : reply.data.from
if (reply.followUp) {
to = reply.data.to
}
commit('startComposerSession', {
data: {
accountId: reply.data.accountId,
to: original.replyTo !== undefined ? original.replyTo : reply.data.from,
to,
cc: [],
subject: buildReplySubject(reply.data.subject),
body: data.body,
@ -1491,4 +1496,30 @@ export default {
logger.error('Could not set layouts', { error })
}
},
async clearFollowUpReminder({ commit, dispatch }, { envelope }) {
await dispatch('removeEnvelopeTag', {
envelope,
imapLabel: FOLLOW_UP_TAG_LABEL,
})
commit('removeEnvelopeFromFollowUpMailbox', {
id: envelope.databaseId,
})
},
async checkFollowUpReminders({ dispatch, getters }) {
const envelopes = getters.getFollowUpReminderEnvelopes
const messageIds = envelopes.map((envelope) => envelope.databaseId)
if (messageIds.length === 0) {
return
}
const data = await FollowUpService.checkMessageIds(messageIds)
for (const messageId of data.wasFollowedUp) {
const envelope = getters.getEnvelope(messageId)
if (!envelope) {
continue
}
await dispatch('clearFollowUpReminder', { envelope })
}
},
}

Просмотреть файл

@ -8,6 +8,7 @@ import { TOAST_UNDO_TIMEOUT } from '@nextcloud/dialogs'
export const UNIFIED_ACCOUNT_ID = 0
export const UNIFIED_INBOX_ID = 'unified'
export const PRIORITY_INBOX_ID = 'priority'
export const FOLLOW_UP_MAILBOX_ID = 'follow-up'
export const PAGE_SIZE = 20
export const UNDO_DELAY = TOAST_UNDO_TIMEOUT
export const EDITOR_MODE_HTML = 'richtext'
@ -15,3 +16,5 @@ export const EDITOR_MODE_TEXT = 'plaintext'
export const STATUS_RAW = 0
export const STATUS_IMAP_SENT_MAILBOX_FAIL = 11
export const STATUS_SMTP_ERROR = 13
export const FOLLOW_UP_TAG_LABEL = '$follow_up'

Просмотреть файл

@ -5,7 +5,7 @@
import { defaultTo, head, prop, sortBy } from 'ramda'
import { UNIFIED_ACCOUNT_ID } from './constants.js'
import { FOLLOW_UP_TAG_LABEL, UNIFIED_ACCOUNT_ID } from './constants.js'
import { normalizedEnvelopeListId } from './normalization.js'
import { getCalendarHome } from '../service/caldavService.js'
import toCalendar from './calendar.js'
@ -97,6 +97,16 @@ export const getters = {
getTags: (state) => {
return state.tagList.map(tagId => state.tags[tagId])
},
getFollowUpTag: (state) => {
return Object.values(state.tags).find((tag) => tag.imapLabel === FOLLOW_UP_TAG_LABEL)
},
getFollowUpReminderEnvelopes: (state) => {
return Object.values(state.envelopes)
.filter((envelope) => envelope.tags
?.map((tagId) => state.tags[tagId])
.some((tag) => tag.imapLabel === FOLLOW_UP_TAG_LABEL),
)
},
isScheduledSendingDisabled: (state) => state.isScheduledSendingDisabled,
isSnoozeDisabled: (state) => state.isSnoozeDisabled,
googleOauthUrl: (state) => state.googleOauthUrl,
@ -140,4 +150,5 @@ export const getters = {
},
isOneLineLayout: (state) => state.list,
hasFetchedInitialEnvelopes: (state) => state.hasFetchedInitialEnvelopes,
isFollowUpFeatureAvailable: (state) => state.followUpFeatureAvailable,
}

Просмотреть файл

@ -10,6 +10,7 @@ import {
UNIFIED_ACCOUNT_ID,
UNIFIED_INBOX_ID,
PRIORITY_INBOX_ID,
FOLLOW_UP_MAILBOX_ID,
} from './constants.js'
import actions from './actions.js'
import { getters } from './getters.js'
@ -30,7 +31,7 @@ export default new Store({
id: UNIFIED_ACCOUNT_ID,
accountId: UNIFIED_ACCOUNT_ID,
isUnified: true,
mailboxes: [PRIORITY_INBOX_ID, UNIFIED_INBOX_ID],
mailboxes: [PRIORITY_INBOX_ID, UNIFIED_INBOX_ID, FOLLOW_UP_MAILBOX_ID],
aliases: [],
collapsed: false,
emailAddress: '',
@ -70,6 +71,20 @@ export default new Store({
envelopeLists: {},
name: 'PRIORITY INBOX',
},
[FOLLOW_UP_MAILBOX_ID]: {
id: FOLLOW_UP_MAILBOX_ID,
databaseId: FOLLOW_UP_MAILBOX_ID,
accountId: 0,
attributes: ['\\subscribed'],
isUnified: true,
path: '',
specialUse: ['sent'],
specialRole: 'sent',
unread: 0,
mailboxes: [],
envelopeLists: {},
name: 'FOLLOW UP REMINDERS',
},
},
envelopes: {},
messages: {},
@ -90,6 +105,7 @@ export default new Store({
calendars: [],
smimeCertificates: [],
hasFetchedInitialEnvelopes: false,
followUpFeatureAvailable: false,
},
getters,
mutations,

Просмотреть файл

@ -11,7 +11,7 @@ import Vue from 'vue'
import { sortMailboxes } from '../imap/MailboxSorter.js'
import { normalizedEnvelopeListId } from './normalization.js'
import { UNIFIED_ACCOUNT_ID } from './constants.js'
import { FOLLOW_UP_MAILBOX_ID, UNIFIED_ACCOUNT_ID } from './constants.js'
const transformMailboxName = (account, mailbox) => {
// Add all mailboxes (including submailboxes to state, but only toplevel to account
@ -406,6 +406,15 @@ export default {
Vue.set(state.mailboxes[id], 'envelopeLists', [])
})
},
removeEnvelopeFromFollowUpMailbox(state, { id }) {
const filteredLists = {}
const mailbox = state.mailboxes[FOLLOW_UP_MAILBOX_ID]
for (const listId of Object.keys(mailbox.envelopeLists)) {
filteredLists[listId] = mailbox.envelopeLists[listId]
.filter((idInList) => id !== idInList)
}
Vue.set(state.mailboxes[FOLLOW_UP_MAILBOX_ID], 'envelopeLists', filteredLists)
},
addMessage(state, { message }) {
Vue.set(state.messages, message.databaseId, message)
},
@ -495,4 +504,7 @@ export default {
setHasFetchedInitialEnvelopes(state, hasFetchedInitialEnvelopes) {
state.hasFetchedInitialEnvelopes = hasFetchedInitialEnvelopes
},
setFollowUpFeatureAvailable(state, followUpFeatureAvailable) {
state.followUpFeatureAvailable = followUpFeatureAvailable
},
}

Просмотреть файл

@ -22,6 +22,9 @@ global.OC = {
},
},
isUserAdmin: () => false,
config: {
version: '9999.0.0',
},
}
/**

21
src/util/tag.js Normal file
Просмотреть файл

@ -0,0 +1,21 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { translate as t } from '@nextcloud/l10n'
import { FOLLOW_UP_TAG_LABEL } from '../store/constants.js'
/**
* Translate the display name of special tags or leave them as is if the user renamed them.
*
* @param {{displayName: string, imapLabel: string}} tag The original display name.
* @return {string} The translated or original display name.
*/
export function translateTagDisplayName(tag) {
if (tag.imapLabel === FOLLOW_UP_TAG_LABEL && tag.displayName === 'Follow up') {
return t('mail', 'Follow up')
}
return tag.displayName
}

Просмотреть файл

@ -15,4 +15,5 @@ script('mail', 'mail');
<input type="hidden" id="tag-classified-messages" value="<?php p($_['tag-classified-messages']); ?>">
<input type="hidden" id="search-priority-body" value="<?php p($_['search-priority-body']); ?>">
<input type="hidden" id="layout-mode" value="<?php p($_['layout-mode']); ?>">
<input type="hidden" id="follow-up-reminders" value="<?php p($_['follow-up-reminders']); ?>">

Просмотреть файл

@ -160,7 +160,7 @@ class PageControllerTest extends TestCase {
$account1 = $this->createMock(Account::class);
$account2 = $this->createMock(Account::class);
$mailbox = $this->createMock(Mailbox::class);
$this->preferences->expects($this->exactly(8))
$this->preferences->expects($this->exactly(9))
->method('getPreference')
->willReturnMap([
[$this->userId, 'account-settings', '[]', json_encode([])],
@ -171,6 +171,7 @@ class PageControllerTest extends TestCase {
[$this->userId, 'search-priority-body', 'false', 'false'],
[$this->userId, 'start-mailbox-id', null, '123'],
[$this->userId, 'layout-mode', 'vertical-split', 'vertical-split'],
[$this->userId, 'follow-up-reminders', 'true', 'true'],
]);
$this->classificationSettingsService->expects(self::once())
->method('isClassificationEnabled')
@ -247,7 +248,7 @@ class PageControllerTest extends TestCase {
['version', '0.0.0', '26.0.0'],
['app.mail.attachment-size-limit', 0, 123],
]);
$this->config->expects($this->exactly(8))
$this->config->expects($this->exactly(6))
->method('getAppValue')
->withConsecutive(
[ 'mail', 'installed_version' ],
@ -256,8 +257,6 @@ class PageControllerTest extends TestCase {
['mail', 'microsoft_oauth_tenant_id' ],
['core', 'backgroundjobs_mode', 'ajax' ],
['mail', 'allow_new_mail_accounts', 'yes'],
['mail', 'llm_processing', 'no'],
['mail', 'llm_processing', 'no'],
)->willReturnOnConsecutiveCalls(
$this->returnValue('1.2.3'),
$this->returnValue(''),
@ -267,7 +266,9 @@ class PageControllerTest extends TestCase {
$this->returnValue('yes'),
$this->returnValue('no')
);
$this->aiIntegrationsService->expects(self::exactly(3))
->method('isLlmProcessingEnabled')
->willReturn(false);
$user->method('getUID')
->will($this->returnValue('jane'));
@ -289,7 +290,7 @@ class PageControllerTest extends TestCase {
->method('getLoginCredentials')
->willReturn($loginCredentials);
$this->initialState->expects($this->exactly(16))
$this->initialState->expects($this->exactly(17))
->method('provideInitialState')
->withConsecutive(
['debug', true],
@ -307,6 +308,7 @@ class PageControllerTest extends TestCase {
['allow-new-accounts', true],
['llm_summaries_available', false],
['llm_freeprompt_available', false],
['llm_followup_available', false],
['smime-certificates', []],
);
@ -320,6 +322,7 @@ class PageControllerTest extends TestCase {
'tag-classified-messages' => 'false',
'search-priority-body' => 'false',
'layout-mode' => 'vertical-split',
'follow-up-reminders' => 'true',
]);
$csp = new ContentSecurityPolicy();
$csp->addAllowedFrameDomain('\'self\'');

Просмотреть файл

@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Unit\Job;
use ChristophWurst\Nextcloud\Testing\TestCase;
use OCA\Mail\Account;
use OCA\Mail\BackgroundJob\FollowUpClassifierJob;
use OCA\Mail\Contracts\IMailManager;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Db\Mailbox;
use OCA\Mail\Db\Message;
use OCA\Mail\Db\Tag;
use OCA\Mail\Db\ThreadMapper;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
use OCP\AppFramework\Utility\ITimeFactory;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
class FollowUpClassifierJobTest extends TestCase {
private FollowUpClassifierJob $job;
/** @var ITimeFactory|MockObject */
private $time;
/** @var LoggerInterface|MockObject */
private $logger;
/** @var AccountService|MockObject */
private $accountService;
/** @var IMailManager|MockObject */
private $mailManager;
/** @var AiIntegrationsService|MockObject */
private $aiService;
/** @var ThreadMapper|MockObject */
private $threadMapper;
protected function setUp(): void {
parent::setUp();
$this->time = $this->createMock(ITimeFactory::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->accountService = $this->createMock(AccountService::class);
$this->mailManager = $this->createMock(IMailManager::class);
$this->aiService = $this->createMock(AiIntegrationsService::class);
$this->threadMapper = $this->createMock(ThreadMapper::class);
$this->job = new FollowUpClassifierJob(
$this->time,
$this->logger,
$this->accountService,
$this->mailManager,
$this->aiService,
$this->threadMapper,
);
}
public function testRun(): void {
$argument = [
'messageId' => '<message1@foo.bar>',
'mailboxId' => 200,
'userId' => 'user',
];
$mailbox = new Mailbox();
$mailbox->setId(200);
$mailbox->setAccountId(100);
$mailbox->setName('sent');
$mailAccount = new MailAccount();
$mailAccount->setId(100);
$account = new Account($mailAccount);
$message = new Message();
$message->setMailboxId(200);
$message->setMessageId('<message1@foo.bar>');
$messages = [$message];
$tag = new Tag();
$tag->setImapLabel('$follow_up');
$this->aiService->expects(self::once())
->method('isLlmProcessingEnabled')
->willReturn(true);
$this->mailManager->expects(self::once())
->method('getMailbox')
->with('user', 200)
->willReturn($mailbox);
$this->accountService->expects(self::once())
->method('find')
->with('user', 100)
->willReturn($account);
$this->mailManager->expects(self::once())
->method('getByMessageId')
->with($account, '<message1@foo.bar>')
->willReturn($messages);
$this->threadMapper->expects(self::once())
->method('findNewerMessageIdsInThread')
->with(100, $message)
->willReturn([]);
$this->aiService->expects(self::once())
->method('requiresFollowUp')
->with($account, $mailbox, $message, 'user')
->willReturn(true);
$this->mailManager->expects(self::once())
->method('createTag')
->with('Follow up', '#d77000', 'user')
->willReturn($tag);
$this->mailManager->expects(self::once())
->method('tagMessage')
->with($account, 'sent', $message, $tag, true);
$this->job->run($argument);
}
public function testRunLlmProcessingDisabled(): void {
$argument = [
'messageId' => '<message1@foo.bar>',
'mailboxId' => 200,
'userId' => 'user',
];
$mailbox = new Mailbox();
$mailbox->setId(200);
$mailbox->setAccountId(100);
$mailbox->setName('sent');
$mailAccount = new MailAccount();
$mailAccount->setId(100);
$this->aiService->expects(self::once())
->method('isLlmProcessingEnabled')
->willReturn(false);
$this->mailManager->expects(self::never())
->method('getMailbox');
$this->accountService->expects(self::never())
->method('find');
$this->mailManager->expects(self::never())
->method('getByMessageId');
$this->threadMapper->expects(self::never())
->method('findNewerMessageIdsInThread');
$this->aiService->expects(self::never())
->method('requiresFollowUp');
$this->mailManager->expects(self::never())
->method('createTag');
$this->mailManager->expects(self::never())
->method('tagMessage');
$this->job->run($argument);
}
public function testRunNoMessages(): void {
$argument = [
'messageId' => '<message1@foo.bar>',
'mailboxId' => 200,
'userId' => 'user',
];
$mailbox = new Mailbox();
$mailbox->setId(200);
$mailbox->setAccountId(100);
$mailbox->setName('sent');
$mailAccount = new MailAccount();
$mailAccount->setId(100);
$account = new Account($mailAccount);
$messages = [];
$this->aiService->expects(self::once())
->method('isLlmProcessingEnabled')
->willReturn(true);
$this->mailManager->expects(self::once())
->method('getMailbox')
->with('user', 200)
->willReturn($mailbox);
$this->accountService->expects(self::once())
->method('find')
->with('user', 100)
->willReturn($account);
$this->mailManager->expects(self::once())
->method('getByMessageId')
->with($account, '<message1@foo.bar>')
->willReturn($messages);
$this->threadMapper->expects(self::never())
->method('findNewerMessageIdsInThread');
$this->aiService->expects(self::never())
->method('requiresFollowUp');
$this->mailManager->expects(self::never())
->method('createTag');
$this->mailManager->expects(self::never())
->method('tagMessage');
$this->job->run($argument);
}
public function testRunMultipleMessages(): void {
$argument = [
'messageId' => '<message1@foo.bar>',
'mailboxId' => 200,
'userId' => 'user',
];
$mailbox = new Mailbox();
$mailbox->setId(200);
$mailbox->setAccountId(100);
$mailbox->setName('sent');
$mailAccount = new MailAccount();
$mailAccount->setId(100);
$account = new Account($mailAccount);
$message = new Message();
$message->setMailboxId(200);
$message->setMessageId('<message1@foo.bar>');
$message2 = new Message();
$message2->setMailboxId(200);
$message2->setMessageId('<message2@foo.bar>');
$messages = [$message, $message2];
$tag = new Tag();
$tag->setImapLabel('$follow_up');
$this->aiService->expects(self::once())
->method('isLlmProcessingEnabled')
->willReturn(true);
$this->mailManager->expects(self::once())
->method('getMailbox')
->with('user', 200)
->willReturn($mailbox);
$this->accountService->expects(self::once())
->method('find')
->with('user', 100)
->willReturn($account);
$this->mailManager->expects(self::once())
->method('getByMessageId')
->with($account, '<message1@foo.bar>')
->willReturn($messages);
$this->threadMapper->expects(self::once())
->method('findNewerMessageIdsInThread')
->with(100, $message)
->willReturn([]);
$this->aiService->expects(self::once())
->method('requiresFollowUp')
->with($account, $mailbox, $message, 'user')
->willReturn(true);
$this->mailManager->expects(self::once())
->method('createTag')
->with('Follow up', '#d77000', 'user')
->willReturn($tag);
$this->mailManager->expects(self::once())
->method('tagMessage')
->with($account, 'sent', $message, $tag, true);
$this->job->run($argument);
}
public function testRunCreateTag(): void {
$argument = [
'messageId' => '<message1@foo.bar>',
'mailboxId' => 200,
'userId' => 'user',
];
$mailbox = new Mailbox();
$mailbox->setId(200);
$mailbox->setAccountId(100);
$mailbox->setName('sent');
$mailAccount = new MailAccount();
$mailAccount->setId(100);
$account = new Account($mailAccount);
$message = new Message();
$message->setMailboxId(200);
$message->setMessageId('<message1@foo.bar>');
$messages = [$message];
$tag = new Tag();
$tag->setImapLabel('$follow_up');
$this->aiService->expects(self::once())
->method('isLlmProcessingEnabled')
->willReturn(true);
$this->mailManager->expects(self::once())
->method('getMailbox')
->with('user', 200)
->willReturn($mailbox);
$this->accountService->expects(self::once())
->method('find')
->with('user', 100)
->willReturn($account);
$this->mailManager->expects(self::once())
->method('getByMessageId')
->with($account, '<message1@foo.bar>')
->willReturn($messages);
$this->threadMapper->expects(self::once())
->method('findNewerMessageIdsInThread')
->with(100, $message)
->willReturn([]);
$this->aiService->expects(self::once())
->method('requiresFollowUp')
->with($account, $mailbox, $message, 'user')
->willReturn(true);
$this->mailManager->expects(self::once())
->method('createTag')
->with('Follow up', '#d77000', 'user')
->willReturn($tag);
$this->mailManager->expects(self::once())
->method('tagMessage')
->with($account, 'sent', $message, $tag, true);
$this->job->run($argument);
}
public function testRunNoFollowUp(): void {
$argument = [
'messageId' => '<message1@foo.bar>',
'mailboxId' => 200,
'userId' => 'user',
];
$mailbox = new Mailbox();
$mailbox->setId(200);
$mailbox->setAccountId(100);
$mailbox->setName('sent');
$mailAccount = new MailAccount();
$mailAccount->setId(100);
$account = new Account($mailAccount);
$message = new Message();
$message->setMailboxId(200);
$message->setMessageId('<message1@foo.bar>');
$messages = [$message];
$tag = new Tag();
$tag->setImapLabel('$follow_up');
$this->aiService->expects(self::once())
->method('isLlmProcessingEnabled')
->willReturn(true);
$this->mailManager->expects(self::once())
->method('getMailbox')
->with('user', 200)
->willReturn($mailbox);
$this->accountService->expects(self::once())
->method('find')
->with('user', 100)
->willReturn($account);
$this->mailManager->expects(self::once())
->method('getByMessageId')
->with($account, '<message1@foo.bar>')
->willReturn($messages);
$this->threadMapper->expects(self::once())
->method('findNewerMessageIdsInThread')
->with(100, $message)
->willReturn([]);
$this->aiService->expects(self::once())
->method('requiresFollowUp')
->with($account, $mailbox, $message, 'user')
->willReturn(false);
$this->mailManager->expects(self::never())
->method('createTag');
$this->mailManager->expects(self::never())
->method('tagMessage');
$this->job->run($argument);
}
public function testRunFollowedUp(): void {
$argument = [
'messageId' => '<message1@foo.bar>',
'mailboxId' => 200,
'userId' => 'user',
];
$mailbox = new Mailbox();
$mailbox->setId(200);
$mailbox->setAccountId(100);
$mailbox->setName('sent');
$mailAccount = new MailAccount();
$mailAccount->setId(100);
$account = new Account($mailAccount);
$message = new Message();
$message->setMailboxId(200);
$message->setMessageId('<message1@foo.bar>');
$messages = [$message];
$tag = new Tag();
$tag->setImapLabel('$follow_up');
$this->aiService->expects(self::once())
->method('isLlmProcessingEnabled')
->willReturn(true);
$this->mailManager->expects(self::once())
->method('getMailbox')
->with('user', 200)
->willReturn($mailbox);
$this->accountService->expects(self::once())
->method('find')
->with('user', 100)
->willReturn($account);
$this->mailManager->expects(self::once())
->method('getByMessageId')
->with($account, '<message1@foo.bar>')
->willReturn($messages);
$this->threadMapper->expects(self::once())
->method('findNewerMessageIdsInThread')
->with(100, $message)
->willReturn([201]);
$this->aiService->expects(self::never())
->method('requiresFollowUp');
$this->mailManager->expects(self::never())
->method('createTag');
$this->mailManager->expects(self::never())
->method('tagMessage');
$this->job->run($argument);
}
}

Просмотреть файл

@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Unit\Listener;
use ChristophWurst\Nextcloud\Testing\TestCase;
use DateInterval;
use DateTimeImmutable;
use OCA\Mail\Account;
use OCA\Mail\BackgroundJob\FollowUpClassifierJob;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Db\Mailbox;
use OCA\Mail\Db\Message;
use OCA\Mail\Db\Tag;
use OCA\Mail\Events\NewMessagesSynchronized;
use OCA\Mail\Listener\FollowUpClassifierListener;
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
use OCP\BackgroundJob\IJobList;
use OCP\TextProcessing\FreePromptTaskType;
class FollowUpClassifierListenerTest extends TestCase {
private FollowUpClassifierListener $listener;
/** @var IJobList|MockObject */
private $jobList;
/** @var AiIntegrationsService|MockObject */
private $aiService;
protected function setUp(): void {
parent::setUp();
$this->jobList = $this->createMock(IJobList::class);
$this->aiService = $this->createMock(AiIntegrationsService::class);
$this->listener = new FollowUpClassifierListener(
$this->jobList,
$this->aiService,
);
}
public function testHandle(): void {
$sentAt = new DateTimeImmutable('now');
$scheduleAfterTimestamp = $sentAt->add(new DateInterval('P3DT12H'))->getTimestamp();
$mailAccount = new MailAccount();
$mailAccount->setId(100);
$mailAccount->setUserId('user');
$mailAccount->setSentMailboxId(200);
$account = new Account($mailAccount);
$mailbox = new Mailbox();
$mailbox->setId(200);
$mailbox->setSpecialUse('["sent"]');
$message = new Message();
$message->setMessageId('<message1@foo.bar>');
$message->setThreadRootId('<message1@foo.bar>');
$message->setSentAt($sentAt->getTimestamp());
$message->setMailboxId(200);
$message->setTags([]);
$event = new NewMessagesSynchronized($account, $mailbox, [$message]);
$this->aiService->expects(self::once())
->method('isLlmProcessingEnabled')
->willReturn(true);
$this->aiService->expects(self::once())
->method('isLlmAvailable')
->with(FreePromptTaskType::class)
->willReturn(true);
// TODO: only assert scheduleAfter() once we support >= 28.0.0
if (method_exists(IJobList::class, 'scheduleAfter')) {
$this->jobList->expects(self::once())
->method('scheduleAfter')
->with(FollowUpClassifierJob::class, $scheduleAfterTimestamp, [
FollowUpClassifierJob::PARAM_MESSAGE_ID => '<message1@foo.bar>',
FollowUpClassifierJob::PARAM_MAILBOX_ID => 200,
FollowUpClassifierJob::PARAM_USER_ID => 'user',
]);
$this->jobList->expects(self::never())
->method('add');
} else {
$this->jobList->expects(self::once())
->method('add')
->with(FollowUpClassifierJob::class, [
FollowUpClassifierJob::PARAM_MESSAGE_ID => '<message1@foo.bar>',
FollowUpClassifierJob::PARAM_MAILBOX_ID => 200,
FollowUpClassifierJob::PARAM_USER_ID => 'user',
]);
}
$this->listener->handle($event);
}
public function testHandleLlmProcessingDisabled(): void {
$sentAt = new DateTimeImmutable('now');
$mailAccount = new MailAccount();
$mailAccount->setId(100);
$mailAccount->setUserId('user');
$mailAccount->setSentMailboxId(200);
$account = new Account($mailAccount);
$mailbox = new Mailbox();
$mailbox->setId(200);
$mailbox->setSpecialUse('["sent"]');
$message = new Message();
$message->setMessageId('<message1@foo.bar>');
$message->setThreadRootId('<message1@foo.bar>');
$message->setSentAt($sentAt->getTimestamp());
$message->setMailboxId(200);
$message->setTags([]);
$event = new NewMessagesSynchronized($account, $mailbox, [$message]);
$this->aiService->expects(self::once())
->method('isLlmProcessingEnabled')
->willReturn(false);
$this->aiService->expects(self::never())
->method('isLlmAvailable');
// TODO: only assert scheduleAfter() once we support >= 28.0.0
if (method_exists(IJobList::class, 'scheduleAfter')) {
$this->jobList->expects(self::never())
->method('scheduleAfter');
}
$this->jobList->expects(self::never())
->method('add');
$this->listener->handle($event);
}
public function testHandleLlmTaskUnavailable(): void {
$sentAt = new DateTimeImmutable('now');
$mailAccount = new MailAccount();
$mailAccount->setId(100);
$mailAccount->setUserId('user');
$mailAccount->setSentMailboxId(200);
$account = new Account($mailAccount);
$mailbox = new Mailbox();
$mailbox->setId(200);
$mailbox->setSpecialUse('["sent"]');
$message = new Message();
$message->setMessageId('<message1@foo.bar>');
$message->setThreadRootId('<message1@foo.bar>');
$message->setSentAt($sentAt->getTimestamp());
$message->setMailboxId(200);
$message->setTags([]);
$event = new NewMessagesSynchronized($account, $mailbox, [$message]);
$this->aiService->expects(self::once())
->method('isLlmProcessingEnabled')
->willReturn(true);
$this->aiService->expects(self::once())
->method('isLlmAvailable')
->with(FreePromptTaskType::class)
->willReturn(false);
// TODO: only assert scheduleAfter() once we support >= 28.0.0
if (method_exists(IJobList::class, 'scheduleAfter')) {
$this->jobList->expects(self::never())
->method('scheduleAfter');
}
$this->jobList->expects(self::never())
->method('add');
$this->listener->handle($event);
}
public function testHandleSkipTagged(): void {
$sentAt = new DateTimeImmutable('now');
$followUpTag = new Tag();
$followUpTag->setImapLabel('$follow_up');
$mailAccount = new MailAccount();
$mailAccount->setId(100);
$mailAccount->setUserId('user');
$mailAccount->setSentMailboxId(200);
$account = new Account($mailAccount);
$mailbox = new Mailbox();
$mailbox->setId(200);
$mailbox->setSpecialUse('["sent"]');
$message = new Message();
$message->setMessageId('<message1@foo.bar>');
$message->setThreadRootId('<message1@foo.bar>');
$message->setSentAt($sentAt->getTimestamp());
$message->setMailboxId(200);
$message->setTags([$followUpTag]);
$event = new NewMessagesSynchronized($account, $mailbox, [$message]);
$this->aiService->expects(self::once())
->method('isLlmProcessingEnabled')
->willReturn(true);
$this->aiService->expects(self::once())
->method('isLlmAvailable')
->with(FreePromptTaskType::class)
->willReturn(true);
// TODO: only assert scheduleAfter() once we support >= 28.0.0
if (method_exists(IJobList::class, 'scheduleAfter')) {
$this->jobList->expects(self::never())
->method('scheduleAfter');
}
$this->jobList->expects(self::never())
->method('add');
$this->listener->handle($event);
}
public function testHandleSkipOld(): void {
$sentAt = (new DateTimeImmutable('now'))
->sub(new \DateInterval('P14DT1S'));
$mailAccount = new MailAccount();
$mailAccount->setId(100);
$mailAccount->setUserId('user');
$mailAccount->setSentMailboxId(200);
$account = new Account($mailAccount);
$mailbox = new Mailbox();
$mailbox->setId(200);
$mailbox->setSpecialUse('["sent"]');
$message = new Message();
$message->setMessageId('<message1@foo.bar>');
$message->setThreadRootId('<message1@foo.bar>');
$message->setSentAt($sentAt->getTimestamp());
$message->setMailboxId(200);
$message->setTags([]);
$event = new NewMessagesSynchronized($account, $mailbox, [$message]);
$this->aiService->expects(self::once())
->method('isLlmProcessingEnabled')
->willReturn(true);
$this->aiService->expects(self::once())
->method('isLlmAvailable')
->with(FreePromptTaskType::class)
->willReturn(true);
// TODO: only assert scheduleAfter() once we support >= 28.0.0
if (method_exists(IJobList::class, 'scheduleAfter')) {
$this->jobList->expects(self::never())
->method('scheduleAfter');
}
$this->jobList->expects(self::never())
->method('add');
$this->listener->handle($event);
}
}

Просмотреть файл

@ -21,6 +21,7 @@ use OCA\Mail\Model\IMAPMessage;
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
use OCA\Mail\Service\AiIntegrations\Cache;
use OCP\AppFramework\QueryException;
use OCP\IConfig;
use OCP\TextProcessing\FreePromptTaskType;
use OCP\TextProcessing\IManager;
use OCP\TextProcessing\SummaryTaskType;
@ -51,6 +52,8 @@ class AiIntegrationsServiceTest extends TestCase {
/** @var IMailManager|MockObject */
private $mailManager;
/** @var IConfig|MockObject */
private $config;
protected function setUp(): void {
parent::setUp();
@ -64,11 +67,13 @@ class AiIntegrationsServiceTest extends TestCase {
$this->cache = $this->createMock(Cache::class);
$this->clientFactory = $this->createMock(IMAPClientFactory::class);
$this->mailManager = $this->createMock(IMailManager::class);
$this->config = $this->createMock(IConfig::class);
$this->aiIntegrationsService = new AiIntegrationsService(
$this->container,
$this->cache,
$this->clientFactory,
$this->mailManager
$this->mailManager,
$this->config,
);
}
@ -178,6 +183,25 @@ class AiIntegrationsServiceTest extends TestCase {
}
public function isLlmProcessingEnabledDataProvider(): array {
return [
['no', false],
['yes', true],
];
}
/**
* @dataProvider isLlmProcessingEnabledDataProvider
*/
public function testIsLlmProcessingEnabled(string $appConfigValue, bool $expected) {
$this->config->expects(self::once())
->method('getAppValue')
->with('mail', 'llm_processing', 'no')
->willReturn($appConfigValue);
$this->assertEquals($expected, $this->aiIntegrationsService->isLlmProcessingEnabled());
}
public function testCached() {
$account = new Account(new MailAccount());
$mailbox = new Mailbox();

Просмотреть файл

@ -579,6 +579,28 @@ class MailManagerTest extends TestCase {
self::assertEquals('#0082c9', $tag->getColor());
}
public function testCreateTagForFollowUp(): void {
$this->tagMapper->expects(self::once())
->method('getTagByImapLabel')
->willThrowException(new DoesNotExistException('Computer says no'));
$this->tagMapper->expects(self::once())
->method('insert')
->willReturnCallback(static function (Tag $tag) {
self::assertEquals('admin', $tag->getUserId());
self::assertEquals('Follow up', $tag->getDisplayName());
self::assertEquals('$follow_up', $tag->getImapLabel());
self::assertEquals('#d77000', $tag->getColor());
return $tag;
});
$tag = $this->manager->createTag('Follow up', '#d77000', 'admin');
self::assertEquals('admin', $tag->getUserId());
self::assertEquals('Follow up', $tag->getDisplayName());
self::assertEquals('$follow_up', $tag->getImapLabel());
self::assertEquals('#d77000', $tag->getColor());
}
public function testUpdateTag(): void {
$existingTag = new Tag();
$existingTag->setId(100);