diff --git a/.github/workflows/psalm-matrix.yml b/.github/workflows/psalm-matrix.yml index 2bcaae6142..8e8c6d8376 100644 --- a/.github/workflows/psalm-matrix.yml +++ b/.github/workflows/psalm-matrix.yml @@ -42,7 +42,7 @@ jobs: # do not stop on another job's failure fail-fast: false matrix: - ocp-version: [ 'dev-stable31', 'dev-stable32', 'dev-master' ] + ocp-version: [ 'dev-stable32', 'dev-master' ] name: static-psalm-analysis ${{ matrix.ocp-version }} steps: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0968bc2f73..62a0191b56 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: php-versions: ['8.2', '8.3'] - nextcloud-versions: ['master', 'stable31'] + nextcloud-versions: ['master'] include: - php-versions: '8.4' nextcloud-versions: 'master' diff --git a/appinfo/info.xml b/appinfo/info.xml index 059adc950c..c76945ffb7 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -57,6 +57,7 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud OCA\Mail\BackgroundJob\CleanupJob + OCA\Mail\BackgroundJob\ContextChat\SubmitContentJob OCA\Mail\BackgroundJob\OutboxWorkerJob OCA\Mail\BackgroundJob\IMipMessageJob OCA\Mail\BackgroundJob\DraftsJob diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3004b98093..baffb9d26c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -11,6 +11,7 @@ namespace OCA\Mail\AppInfo; use Horde_Translation; +use OCA\Mail\ContextChat\ContextChatProvider; use OCA\Mail\Contracts\IAttachmentService; use OCA\Mail\Contracts\IAvatarService; use OCA\Mail\Contracts\IDkimService; @@ -73,6 +74,7 @@ use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\ContextChat\Events\ContentProviderRegisterEvent; use OCP\DB\Events\AddMissingIndicesEvent; use OCP\IServerContainer; use OCP\TaskProcessing\Events\TaskSuccessfulEvent; @@ -168,6 +170,13 @@ public function register(IRegistrationContext $context): void { Horde_Translation::setHandler('Horde_Imap_Client', new HordeTranslationHandler()); Horde_Translation::setHandler('Horde_Mime', new HordeTranslationHandler()); Horde_Translation::setHandler('Horde_Smtp', new HordeTranslationHandler()); + + // Added in version 5.6.0 + if (class_exists(ContentProviderRegisterEvent::class)) { + $context->registerEventListener(ContentProviderRegisterEvent::class, ContextChatProvider::class); + $context->registerEventListener(NewMessagesSynchronized::class, ContextChatProvider::class); + $context->registerEventListener(MessageDeletedEvent::class, ContextChatProvider::class); + } } #[\Override] diff --git a/lib/BackgroundJob/ContextChat/ScheduleJob.php b/lib/BackgroundJob/ContextChat/ScheduleJob.php new file mode 100644 index 0000000000..81ac4dd8b7 --- /dev/null +++ b/lib/BackgroundJob/ContextChat/ScheduleJob.php @@ -0,0 +1,81 @@ +setInterval(60 * 60 * 24); + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + } + + #[\Override] + protected function run($argument): void { + $accountId = $argument['accountId']; + + if (!$this->contentManager->isContextChatAvailable()) { + return; + } + + try { + $account = $this->accountService->findById($accountId); + } catch (DoesNotExistException $e) { + $this->logger->debug('Could not find account <' . $accountId . '> removing from jobs'); + $this->jobList->remove(self::class, $argument); + return; + } + + if (!$this->contextChatSettingsService->isIndexingEnabled($account->getUserId())) { + $this->logger->debug("indexing is turned off for account $accountId"); + return; + } + + try { + $mailboxes = $this->mailManager->getMailboxes($account); + } catch (ServiceException $e) { + $this->logger->debug('Could not find mailboxes for account <' . $accountId . '>'); + return; + } + + foreach ($mailboxes as $mailbox) { + try { + $this->taskService->findByMailboxId($mailbox->getId()); + $this->taskService->updateOrCreate($mailbox->getId(), 0); + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { + $this->logger->warning('Could not schedule context chat indexing tasks for mailbox <' . $mailbox->getId() . '>'); + } + } + } +} diff --git a/lib/BackgroundJob/ContextChat/SubmitContentJob.php b/lib/BackgroundJob/ContextChat/SubmitContentJob.php new file mode 100644 index 0000000000..366c6c44dd --- /dev/null +++ b/lib/BackgroundJob/ContextChat/SubmitContentJob.php @@ -0,0 +1,171 @@ +setAllowParallelRuns(false); + $this->setInterval(ContextChatProvider::CONTEXT_CHAT_JOB_INTERVAL); + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + } + + #[\Override] + protected function run($argument): void { + if (!$this->contentManager->isContextChatAvailable()) { + return; + } + + try { + $task = $this->taskService->findNext(); + } catch (Exception $e) { + $this->logger->warning('Exception occurred when trying to fetch next task', ['exception' => $e]); + return; + } catch (DoesNotExistException $e) { + // nothing to be done, let's defer to the next iteration of this job + return; + } catch (MultipleObjectsReturnedException $e) { + $this->logger->warning('Multiple tasks found for context chat. This is unexpected.', ['exception' => $e]); + return; + } + + try { + $mailbox = $this->mailboxMapper->findById($task->getMailboxId()); + } catch (ServiceException $e) { + $this->logger->warning('Multiple mailboxes found for context chat task, but only one expected. ERROR!', ['exception' => $e]); + return; + } catch (DoesNotExistException $e) { + // mailbox does not exist, lets wait for this task to be removed + return; + } + + $processMailsAfter = $this->time->getTime() - ContextChatProvider::CONTEXT_CHAT_MESSAGE_MAX_AGE; + $messageIds = $this->messageMapper->findIdsAfter($mailbox, $task->getLastMessageId(), $processMailsAfter, ContextChatProvider::CONTEXT_CHAT_IMPORT_MAX_ITEMS); + + if (empty($messageIds)) { + try { + $this->taskService->delete($task->getId()); + } catch (MultipleObjectsReturnedException|Exception $e) { + $this->logger->warning('Exception occurred when trying to delete task', ['exception' => $e]); + } + return; + } + + try { + $account = $this->accountService->findById($mailbox->getAccountId()); + } catch (DoesNotExistException $e) { + // well, what do you know. Then let's just skip this and wait for the next iteration of this job. tasks should be cascade deleted anyway + return; + } + + $messages = $this->messageMapper->findByIds($account->getUserId(), $messageIds, 'asc', 'id'); + + if (empty($messages)) { + try { + $this->taskService->delete($task->getId()); + } catch (MultipleObjectsReturnedException|Exception $e) { + $this->logger->warning('Exception occurred when trying to delete task', ['exception' => $e]); + } + return; + } + + + $client = $this->clientFactory->getClient($account); + $items = []; + + try { + $startTime = $this->time->getTime(); + foreach ($messages as $message) { + if ($this->time->getTime() - $startTime > ContextChatProvider::CONTEXT_CHAT_JOB_INTERVAL) { + break; + } + try { + $imapMessage = $this->mailManager->getImapMessage($client, $account, $mailbox, $message->getUid(), true); + } catch (ServiceException $e) { + // couldn't load message, let's skip it. Retrying would be too costly + continue; + } catch (SmimeDecryptException $e) { + // encryption problem, skip this message + continue; + } + + + // Skip encrypted messages + if ($imapMessage->isEncrypted()) { + continue; + } + + + $fullMessage = $imapMessage->getFullMessage($imapMessage->getUid(), true); + + + $items[] = new ContentItem( + $mailbox->getId() . ':' . $message->getId(), + $this->contextChatProvider->getId(), + $imapMessage->getSubject(), + $fullMessage['body'] ?? '', + 'E-Mail', + $imapMessage->getSentDate(), + [$account->getUserId()], + ); + } + } catch (\Throwable $e) { + $this->logger->warning('Exception occurred when trying to fetch messages for context chat', ['exception' => $e]); + } finally { + try { + $client->close(); + } catch (\Horde_Imap_Client_Exception $e) { + $this->logger->debug('Failed to close IMAP client', ['exception' => $e]); + } + } + + if (count($items) > 0) { + $this->contentManager->submitContent($this->contextChatProvider->getAppId(), $items); + } + + try { + $this->taskService->setLastMessage($task->getMailboxId(), $message?->getId() ?? $messageIds[0]); + } catch (MultipleObjectsReturnedException|Exception $e) { + $this->logger->warning('Exception occurred when trying to update task', ['exception' => $e]); + } + } +} diff --git a/lib/ContextChat/ContextChatProvider.php b/lib/ContextChat/ContextChatProvider.php new file mode 100644 index 0000000000..6ecd656704 --- /dev/null +++ b/lib/ContextChat/ContextChatProvider.php @@ -0,0 +1,123 @@ + + */ +class ContextChatProvider implements IContentProvider, IEventListener { + + public const CONTEXT_CHAT_MESSAGE_MAX_AGE = 31557600; // 60 * 60 * 24 * 365.25 (1 year) + public const CONTEXT_CHAT_IMPORT_MAX_ITEMS = 1000; + public const CONTEXT_CHAT_JOB_INTERVAL = 300; // 60 * 5 (5 minutes) + + public function __construct( + private TaskService $taskService, + private AccountService $accountService, + private MailManager $mailManager, + private MessageMapper $messageMapper, + private IURLGenerator $urlGenerator, + private IUserManager $userManager, + private IContentManager $contentManager, + private IJobList $jobList, + ) { + } + + public function handle(Event $event): void { + if (!$this->contentManager->isContextChatAvailable()) { + return; + } + + if ($event instanceof ContentProviderRegisterEvent) { + $this->contentManager->registerContentProvider($this->getAppId(), $this->getId(), self::class); + return; + } + + if ($event instanceof NewMessagesSynchronized) { + $messageIds = array_map(static fn (Message $m): int => $m->getId(), $event->getMessages()); + + // Ensure that there are messages to sync + if (count($messageIds) === 0) { + return; + } + + $mailboxId = $event->getMailbox()->getId(); + + $this->taskService->updateOrCreate($mailboxId, min($messageIds)); + return; + } + + if ($event instanceof MessageDeletedEvent) { + $this->contentManager->deleteContent($this->getAppId(), $this->getId(), [strval($event->getMessageId())]); + return; + } + } + + /** + * The ID of the provider + * + * @return string + * @since 5.2.0 + */ + public function getId(): string { + return 'mail'; + } + + /** + * The ID of the app making the provider avaialble + * + * @return string + * @since 5.2.0 + */ + public function getAppId(): string { + return Application::APP_ID; + } + + /** + * The absolute URL to the content item + * + * @param string $id + * @return string + * @since 5.2.0 + */ + public function getItemUrl(string $id): string { + [$mailboxId, $messageId] = explode(':', $id); + if (!$mailboxId || !$messageId) { + return $this->urlGenerator->linkToRouteAbsolute('mail.page.thread', [ 'mailboxId' => $mailboxId, 'id' => 'error']); + } + return $this->urlGenerator->linkToRouteAbsolute('mail.page.thread', [ 'mailboxId' => $mailboxId, 'id' => $messageId ]); + } + + /** + * Starts the initial import of content items into context chat + * + * @return void + * @since 5.2.0 + */ + public function triggerInitialImport(): void { + } +} diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 0d099c0800..59eb0df4c6 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -19,6 +19,7 @@ use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\AliasesService; +use OCA\Mail\Service\ContextChat\ContextChatSettingsService; use OCA\Mail\Service\InternalAddressService; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\QuickActionsService; @@ -72,6 +73,7 @@ class PageController extends Controller { private IAvailabilityCoordinator $availabilityCoordinator; private InternalAddressService $internalAddressService; private QuickActionsService $quickActionsService; + private ContextChatSettingsService $contextChatSettingsService; public function __construct( string $appName, @@ -97,6 +99,7 @@ public function __construct( IAvailabilityCoordinator $availabilityCoordinator, QuickActionsService $quickActionsService, private IAppManager $appManager, + ContextChatSettingsService $contextChatSettingsService, ) { parent::__construct($appName, $request); @@ -120,6 +123,7 @@ public function __construct( $this->internalAddressService = $internalAddressService; $this->availabilityCoordinator = $availabilityCoordinator; $this->quickActionsService = $quickActionsService; + $this->contextChatSettingsService = $contextChatSettingsService; } /** @@ -224,6 +228,7 @@ public function index(): TemplateResponse { 'start-mailbox-id' => $this->preferences->getPreference($this->currentUserId, 'start-mailbox-id'), 'follow-up-reminders' => $this->preferences->getPreference($this->currentUserId, 'follow-up-reminders', 'true'), 'sort-favorites' => $this->preferences->getPreference($this->currentUserId, 'sort-favorites', 'false'), + 'index-context-chat' => $this->contextChatSettingsService->isIndexingEnabled($this->currentUserId) ? 'true' : 'false', ]); $this->initialStateService->provideInitialState( 'prefill_displayName', @@ -314,6 +319,11 @@ public function index(): TemplateResponse { && $this->aiIntegrationsService->isLlmAvailable(FreePromptTaskType::class) ); + $this->initialStateService->provideInitialState( + 'context_chat_available', + $this->appManager->isEnabledForUser('context_chat') + ); + $this->initialStateService->provideInitialState( 'smime-certificates', array_map( diff --git a/lib/Db/ContextChat/Task.php b/lib/Db/ContextChat/Task.php new file mode 100644 index 0000000000..cee1744b65 --- /dev/null +++ b/lib/Db/ContextChat/Task.php @@ -0,0 +1,41 @@ +addType('mailboxId', 'integer'); + $this->addType('lastMessageId', 'integer'); + } + + #[\Override] + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'mailboxId' => $this->getMailboxId(), + 'lastMessageId' => $this->getLastMessageId(), + ]; + } +} diff --git a/lib/Db/ContextChat/TaskMapper.php b/lib/Db/ContextChat/TaskMapper.php new file mode 100644 index 0000000000..ea87f4d434 --- /dev/null +++ b/lib/Db/ContextChat/TaskMapper.php @@ -0,0 +1,78 @@ + + */ +class TaskMapper extends QBMapper { + /** + * @param IDBConnection $db + */ + public function __construct(IDBConnection $db) { + parent::__construct($db, 'mail_cc_tasks'); + } + + /** + * @return Task + * @throws DoesNotExistException + * @throws Exception + * @throws MultipleObjectsReturnedException + */ + public function findNext(): Task { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->orderBy('id', 'ASC') + ->setMaxResults(1); + return $this->findEntity($qb); + } + + /** + * @param int $id + * @return Task + * @throws DoesNotExistException + * @throws Exception + * @throws MultipleObjectsReturnedException + */ + public function findById(int $id): Task { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)) + ); + return $this->findEntity($qb); + } + + /** + * @param int $mailboxId + * @return Task + * @throws DoesNotExistException + * @throws Exception + * @throws MultipleObjectsReturnedException + */ + public function findByMailbox(int $mailboxId): Task { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('mailbox_id', $qb->createNamedParameter($mailboxId, IQueryBuilder::PARAM_INT)) + ); + return $this->findEntity($qb); + } +} diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index 62797e4e9a..63c961bfd3 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -1255,7 +1255,7 @@ public function findByMailboxAndIds(Mailbox $mailbox, string $userId, array $ids * * @return Message[] */ - public function findByIds(string $userId, array $ids, string $sortOrder): array { + public function findByIds(string $userId, array $ids, string $sortOrder, string $orderBy = 'sent_at'): array { if ($ids === []) { return []; } @@ -1265,7 +1265,7 @@ public function findByIds(string $userId, array $ids, string $sortOrder): array ->where( $qb->expr()->in('id', $qb->createParameter('ids')) ) - ->orderBy('sent_at', $sortOrder); + ->orderBy($orderBy, $sortOrder); $results = []; foreach (array_chunk($ids, 1000) as $chunk) { @@ -1717,4 +1717,27 @@ public function deleteDuplicateUids(): void { $result->closeCursor(); } + + /** + * Find n message IDs that are higher than $afterId and sent after $sentAfter + * @param Mailbox $mailbox + * @param int $afterId + * @param int $sentAfter + * @param int $limit + * @return int[] + */ + public function findIdsAfter(Mailbox $mailbox, int $afterId, int $sentAfter, int $limit) : array { + $qb = $this->db->getQueryBuilder(); + $qb->select('m.id') + ->from($this->getTableName(), 'm') + ->where( + $qb->expr()->eq('mailbox_id', $qb->createNamedParameter($mailbox->getId(), IQueryBuilder::PARAM_INT)), + $qb->expr()->gt('id', $qb->createNamedParameter($afterId, IQueryBuilder::PARAM_INT)), + $qb->expr()->gt('sent_at', $qb->createNamedParameter($sentAfter, IQueryBuilder::PARAM_INT)), + ) + ->orderBy('id', 'asc') + ->setMaxResults($limit); + + return $this->findIds($qb); + } } diff --git a/lib/Migration/Version5200Date20250728000000.php b/lib/Migration/Version5200Date20250728000000.php new file mode 100644 index 0000000000..fb19932098 --- /dev/null +++ b/lib/Migration/Version5200Date20250728000000.php @@ -0,0 +1,61 @@ +hasTable('mail_cc_tasks')) { + $table = $schema->createTable('mail_cc_tasks'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('mailbox_id', Types::INTEGER, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('last_message_id', Types::INTEGER, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['mailbox_id'], 'mail_cc_tasks_uniq'); + if ($schema->hasTable('mail_mailboxes')) { + $table->addForeignKeyConstraint( + $schema->getTable('mail_mailboxes'), + ['mailbox_id'], + ['id'], + [ + 'onDelete' => 'CASCADE', + ] + ); + } + } + + return $schema; + } + + +} diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index e4db6afd20..1839d82b44 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -12,6 +12,7 @@ use OCA\Mail\Account; use OCA\Mail\AppInfo\Application; +use OCA\Mail\BackgroundJob\ContextChat\ScheduleJob; use OCA\Mail\BackgroundJob\PreviewEnhancementProcessingJob; use OCA\Mail\BackgroundJob\QuotaJob; use OCA\Mail\BackgroundJob\RepairSyncJob; @@ -239,6 +240,7 @@ public function scheduleBackgroundJobs(int $accountId): void { $this->scheduleBackgroundJob(TrainImportanceClassifierJob::class, $now, $arguments); $this->scheduleBackgroundJob(PreviewEnhancementProcessingJob::class, $now, $arguments); $this->scheduleBackgroundJob(QuotaJob::class, $now, $arguments); + $this->scheduleBackgroundJob(ScheduleJob::class, $now, $arguments); $inThreeDays = $now + (3 * 86400); $this->scheduleBackgroundJob(RepairSyncJob::class, $inThreeDays, $arguments); diff --git a/lib/Service/ContextChat/ContextChatSettingsService.php b/lib/Service/ContextChat/ContextChatSettingsService.php new file mode 100644 index 0000000000..a234d88ad4 --- /dev/null +++ b/lib/Service/ContextChat/ContextChatSettingsService.php @@ -0,0 +1,63 @@ +config->getAppValue( + Application::APP_ID, + 'index_context_chat_default', + 'no', + ); + $preference = $this->preferences->getPreference( + $userId, + 'index-context-chat', + $appConfig !== 'no' ? 'true' : 'false', + ); + return $preference === 'true'; + } + + /** + * Whether to index mails by default for all users that did not yet toggle the + * preference themselves. + */ + public function isIndexingEnabledByDefault(): bool { + return $this->config->getAppValue( + Application::APP_ID, + 'index_context_chat_default', + 'no' + ) !== 'no'; + } + + /** + * Enable or disable the indexing of mails for all users that did not yet toggle + * the preference themselves. + */ + public function setIndexingEnabledByDefault(bool $enabledByDefault): void { + $this->config->setAppValue( + Application::APP_ID, + 'index_context_chat_default', + $enabledByDefault ? 'yes' : 'no', + ); + } +} diff --git a/lib/Service/ContextChat/TaskService.php b/lib/Service/ContextChat/TaskService.php new file mode 100644 index 0000000000..45bda9edd5 --- /dev/null +++ b/lib/Service/ContextChat/TaskService.php @@ -0,0 +1,108 @@ +taskMapper->findNext(); + } + + /** + * @param int $mailboxId + * @return Task + * @throws DoesNotExistException + * @throws Exception + * @throws MultipleObjectsReturnedException + */ + public function findByMailboxId(int $mailboxId): Task { + return $this->taskMapper->findByMailbox($mailboxId); + } + + /** + * Update job, or create it if it doesn't exist + * + * @param int $mailboxId + * @param int $lastMessageId + * @return Task + * @throws Exception|\OCP\AppFramework\Db\MultipleObjectsReturnedException + */ + public function updateOrCreate(int $mailboxId, int $lastMessageId): Task { + try { + $entity = $this->taskMapper->findByMailbox($mailboxId); + } catch (DoesNotExistException) { + $entity = new Task(); + $entity->setMailboxId($mailboxId); + $entity->setLastMessageId($lastMessageId); + + return $this->taskMapper->insert($entity); + } + + if ($lastMessageId >= $entity->getLastMessageId()) { + // Existing job already starts at an earlier message, so updating the database is not needed + return $entity; + } + + $entity->setLastMessageId($lastMessageId); + return $this->taskMapper->update($entity); + } + + /** + * Updates a task to set the new last message id + * @param int $mailboxId + * @param int $lastMessageId + * @return Task + */ + public function setLastMessage(int $mailboxId, int $lastMessageId): Task { + try { + $entity = $this->taskMapper->findByMailbox($mailboxId); + } catch (DoesNotExistException) { + $entity = new Task(); + $entity->setMailboxId($mailboxId); + $entity->setLastMessageId($lastMessageId); + + return $this->taskMapper->insert($entity); + } + + $entity->setLastMessageId($lastMessageId); + return $this->taskMapper->update($entity); + } + + /** + * @param int $jobId + * @return Task|null + * @throws Exception + * @throws MultipleObjectsReturnedException + */ + public function delete(int $jobId): ?Task { + try { + $entity = $this->taskMapper->findById($jobId); + } catch (DoesNotExistException) { + return null; + } + return $this->taskMapper->delete($entity); + } +} diff --git a/src/components/AppSettingsMenu.vue b/src/components/AppSettingsMenu.vue index eb4d9610d3..56ec72590c 100755 --- a/src/components/AppSettingsMenu.vue +++ b/src/components/AppSettingsMenu.vue @@ -200,6 +200,16 @@ + + + + {{ contextChatText }} + + + { const settings = accountSettings.find((settings) => settings.accountId === account.id) @@ -126,6 +131,7 @@ export default function initAfterAppCreation() { mainStore.setGoogleOauthUrlMutation(googleOauthUrl) mainStore.setMicrosoftOauthUrlMutation(microsoftOauthUrl) mainStore.setFollowUpFeatureAvailableMutation(followUpFeatureAvailable) + mainStore.setContextChatFeatureAvailableMutation(contextChatFeatureAvailable) const smimeCertificates = loadState('mail', 'smime-certificates', []) mainStore.setSmimeCertificatesMutation(smimeCertificates) diff --git a/src/store/mainStore.js b/src/store/mainStore.js index 8b6f03d49c..dba4d9a882 100644 --- a/src/store/mainStore.js +++ b/src/store/mainStore.js @@ -99,6 +99,7 @@ export default defineStore('main', { smimeCertificates: [], hasFetchedInitialEnvelopes: false, followUpFeatureAvailable: false, + contextChatFeatureAvailable: false, internalAddress: [], hasCurrentUserPrincipalAndCollections: false, showAccountSettings: null, diff --git a/src/store/mainStore/actions.js b/src/store/mainStore/actions.js index 27388e9b9c..45d9285599 100644 --- a/src/store/mainStore/actions.js +++ b/src/store/mainStore/actions.js @@ -2301,6 +2301,9 @@ export default function mainStoreActions() { setFollowUpFeatureAvailableMutation(followUpFeatureAvailable) { this.followUpFeatureAvailable = followUpFeatureAvailable }, + setContextChatFeatureAvailableMutation(contextChatFeatureAvailable) { + this.contextChatFeatureAvailable = contextChatFeatureAvailable + }, hasCurrentUserPrincipalAndCollectionsMutation(hasCurrentUserPrincipalAndCollections) { this.hasCurrentUserPrincipalAndCollections = hasCurrentUserPrincipalAndCollections }, diff --git a/tests/Integration/Db/MessageMapperTest.php b/tests/Integration/Db/MessageMapperTest.php index a2e0822fe9..014cd1ae0d 100644 --- a/tests/Integration/Db/MessageMapperTest.php +++ b/tests/Integration/Db/MessageMapperTest.php @@ -30,6 +30,11 @@ class MessageMapperTest extends TestCase { /** @var IDBConnection */ private $db; + /** @var ITimeFactory */ + private $time; + + private int $timestamp = 1234567890; + /** @var MessageMapper */ private $mapper; @@ -37,12 +42,13 @@ protected function setUp(): void { parent::setUp(); $this->db = \OC::$server->getDatabaseConnection(); - $timeFactory = $this->createMock(ITimeFactory::class); + $this->time = $this->createMock(ITimeFactory::class); + $this->time->method('getTime')->willReturnCallback(fn () => $this->timestamp); $tagMapper = $this->createMock(TagMapper::class); $performanceLogger = $this->createMock(PerformanceLogger::class); $this->mapper = new MessageMapper( $this->db, - $timeFactory, + $this->time, $tagMapper, $performanceLogger ); @@ -61,7 +67,22 @@ private function insertMessage(int $uid, int $mailbox_id): void { 'message_id' => $qb->createNamedParameter(''), 'mailbox_id' => $qb->createNamedParameter($mailbox_id, IQueryBuilder::PARAM_INT), 'subject' => $qb->createNamedParameter('TEST'), - 'sent_at' => $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT), + 'sent_at' => $qb->createNamedParameter($this->time->getTime(), IQueryBuilder::PARAM_INT), + 'in_reply_to' => $qb->createNamedParameter('<>') + ]); + $insert->executeStatement(); + } + + private function insertMessageWithId(int $id, int $mailbox_id): void { + $qb = $this->db->getQueryBuilder(); + $insert = $qb->insert($this->mapper->getTableName()) + ->values([ + 'id' => $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), + 'uid' => $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), + 'message_id' => $qb->createNamedParameter(''), + 'mailbox_id' => $qb->createNamedParameter($mailbox_id, IQueryBuilder::PARAM_INT), + 'subject' => $qb->createNamedParameter('TEST'), + 'sent_at' => $qb->createNamedParameter($this->time->getTime(), IQueryBuilder::PARAM_INT), 'in_reply_to' => $qb->createNamedParameter('<>') ]); $insert->executeStatement(); @@ -78,7 +99,7 @@ public function testResetInReplyTo() : void { 'message_id' => $qb->createNamedParameter(''), 'mailbox_id' => $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT), 'subject' => $qb->createNamedParameter('TEST'), - 'sent_at' => $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT), + 'sent_at' => $qb->createNamedParameter($this->time->getTime(), IQueryBuilder::PARAM_INT), 'in_reply_to' => $qb->createNamedParameter('<>') ]); $insert->executeStatement(); @@ -227,4 +248,26 @@ public function testDeleteDuplicateUids(): void { self::assertCount(1, $this->mapper->findByUids($mailbox2, [104])); self::assertCount(1, $this->mapper->findByUids($mailbox3, [105])); } + + public function testFindIdsAfter() : void { + $mailbox = new Mailbox(); + $mailbox->setId(4); + $this->timestamp = 1234567890; + array_map(function ($i) use ($mailbox) { + $this->insertMessageWithId($i, $mailbox->getId()); + }, range(1, 5)); + $this->timestamp = 1234567891 + 100; + array_map(function ($i) use ($mailbox) { + $this->insertMessageWithId($i, $mailbox->getId()); + }, range(6, 10)); + + $mails = $this->mapper->findIdsAfter($mailbox, 2, 0, 5); + $this->assertEquals([3,4,5,6,7], $mails); + + $mails = $this->mapper->findIdsAfter($mailbox, 2, 1234567890, 5); + $this->assertEquals([6,7,8,9,10], $mails); + + $mails = $this->mapper->findIdsAfter($mailbox, 2, 1234567890 + 200, 5); + $this->assertEquals([], $mails); + } } diff --git a/tests/Unit/BackgroundJob/ContextChat/SubmitContentJobTest.php b/tests/Unit/BackgroundJob/ContextChat/SubmitContentJobTest.php new file mode 100644 index 0000000000..a4d0f21cc6 --- /dev/null +++ b/tests/Unit/BackgroundJob/ContextChat/SubmitContentJobTest.php @@ -0,0 +1,386 @@ +markTestSkipped(); + } + + $this->time = $this->createMock(ITimeFactory::class); + $this->taskService = $this->createMock(TaskService::class); + $this->accountService = $this->createMock(AccountService::class); + $this->mailManager = $this->createMock(MailManager::class); + $this->messageMapper = $this->createMock(MessageMapper::class); + $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->contextChatProvider = $this->createMock(ContextChatProvider::class); + $this->contentManager = $this->createMock(IContentManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->mailboxMapper = $this->createMock(MailboxMapper::class); + + $this->submitContentJob = new SubmitContentJob( + $this->time, + $this->taskService, + $this->accountService, + $this->mailManager, + $this->messageMapper, + $this->imapClientFactory, + $this->contextChatProvider, + $this->contentManager, + $this->logger, + $this->mailboxMapper, + ); + } + + public function provideEvents(): array { + $account = new Account(new MailAccount()); + $mailbox = new Mailbox(); + $messages = []; + $messages[] = new Message(); + $messages[] = new Message(); + + return [ + 'handle ContentProviderRegisterEvent' => [new \OCP\ContextChat\Events\ContentProviderRegisterEvent($this->createMock(IContentManager::class))], + 'handle NewMessagesSynchronized' => [new NewMessagesSynchronized($account, $mailbox, $messages)], + 'handle MessageDeletedEvent' => [new MessageDeletedEvent($account, $mailbox, 1)], + ]; + } + + + public function testRunWithoutContextChat(): void { + $this->contentManager->expects($this->once()) + ->method('isContextChatAvailable') + ->willReturn(false); + $this->time->expects($this->any())->method('getTime') + ->willReturn(12 * 60 * 60); + $this->taskService->expects($this->never())->method('findNext'); + $this->mailboxMapper->expects($this->never())->method('findById'); + $this->submitContentJob->setLastRun(0); + $this->submitContentJob->start($this->createMock(IJobList::class)); + } + + public function testRunWithContextChat(): void { + $this->contentManager->expects($this->once()) + ->method('isContextChatAvailable') + ->willReturn(true); + $task = new Task(); + $task->setLastMessageId(0); + $task->setMailboxId(1); + $task->setId(1); + $this->taskService->expects($this->once())->method('findNext')->willReturn($task); + $mailbox = new Mailbox(); + $mailbox->setId(1); + $mailbox->setAccountId(5); + $this->mailboxMapper->expects($this->once())->method('findById')->willReturn($mailbox); + $this->time->expects($this->any())->method('getTime') + ->willReturn( + // returned when Job#start asks + 12 * 60 * 60, + 12 * 60 * 60, + // returned when filtering messages + ContextChatProvider::CONTEXT_CHAT_MESSAGE_MAX_AGE, + // returned before processing messages + 0, + // returned on first message + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ); + $this->messageMapper->expects($this->once())->method('findIdsAfter') + ->with($mailbox, 0, 0, ContextChatProvider::CONTEXT_CHAT_IMPORT_MAX_ITEMS)->willReturn([2]); + $account = $this->createMock(Account::class); + $account->expects($this->any())->method('getUserId')->willReturn('user123'); + $this->accountService->expects($this->once())->method('findById')->willReturn($account); + $message = new Message(); + $message->setId(2); + $message->setUid(2); + $this->messageMapper->expects($this->once())->method('findByIds')->willReturn([$message]); + $client = $this->createMock(\Horde_Imap_Client_Socket::class); + $this->imapClientFactory->expects($this->once())->method('getClient')->willReturn($client); + $imapMessage = $this->createMock(IMAPMessage::class); + $this->mailManager->expects($this->once())->method('getImapMessage')->willReturn($imapMessage); + $imapMessage->expects($this->once())->method('isEncrypted')->willReturn(false); + $imapMessage->expects($this->once())->method('getUid')->willReturn(12); + $imapMessage->expects($this->once())->method('getFullMessage')->willReturn(['body' => 'full message']); + $imapMessage->expects($this->once())->method('getSubject')->willReturn('subject'); + $sent = new \Horde_Imap_Client_DateTime('2025-01-01 00:00:00'); + $imapMessage->expects($this->once())->method('getSentDate')->willReturn($sent); + $client->expects($this->once())->method('close'); + $this->contextChatProvider->expects($this->once())->method('getAppId')->willReturn('mail'); + $this->contextChatProvider->expects($this->once())->method('getId')->willReturn('mail'); + $this->contentManager->expects($this->once())->method('submitContent'); + $this->taskService->expects($this->once())->method('setLastMessage')->with($task->getMailboxId(), 2); + + $this->submitContentJob->setLastRun(0); + $this->submitContentJob->start($this->createMock(IJobList::class)); + } + + public function testRunWithContextChatWithNoMessagesToProcess(): void { + $this->contentManager->expects($this->once()) + ->method('isContextChatAvailable') + ->willReturn(true); + $task = new Task(); + $task->setLastMessageId(0); + $task->setMailboxId(1); + $task->setId(1); + $this->taskService->expects($this->once())->method('findNext')->willReturn($task); + $mailbox = new Mailbox(); + $mailbox->setId(1); + $mailbox->setAccountId(5); + $this->mailboxMapper->expects($this->once())->method('findById')->willReturn($mailbox); + $this->time->expects($this->any())->method('getTime') + ->willReturn( + // returned when Job#start asks + 12 * 60 * 60, + 12 * 60 * 60, + // returned when filtering messages + ContextChatProvider::CONTEXT_CHAT_MESSAGE_MAX_AGE, + ContextChatProvider::CONTEXT_CHAT_MESSAGE_MAX_AGE, + ContextChatProvider::CONTEXT_CHAT_MESSAGE_MAX_AGE, + ContextChatProvider::CONTEXT_CHAT_MESSAGE_MAX_AGE, + ContextChatProvider::CONTEXT_CHAT_MESSAGE_MAX_AGE + ); + $this->messageMapper->expects($this->once())->method('findIdsAfter') + ->with($mailbox, 0, 0, ContextChatProvider::CONTEXT_CHAT_IMPORT_MAX_ITEMS)->willReturn([]); + $this->taskService->expects($this->once())->method('delete')->with($task->getId()); + $this->messageMapper->expects($this->never())->method('findByIds'); + + $this->submitContentJob->setLastRun(0); + $this->submitContentJob->start($this->createMock(IJobList::class)); + } + + public function testRunWithContextChatWithTimeout(): void { + $this->contentManager->expects($this->once()) + ->method('isContextChatAvailable') + ->willReturn(true); + $task = new Task(); + $task->setLastMessageId(0); + $task->setMailboxId(1); + $task->setId(1); + $this->taskService->expects($this->once())->method('findNext')->willReturn($task); + $mailbox = new Mailbox(); + $mailbox->setId(1); + $mailbox->setAccountId(5); + $this->mailboxMapper->expects($this->once())->method('findById')->willReturn($mailbox); + $this->time->expects($this->any())->method('getTime') + ->willReturn( + // returned when Job#start asks + 12 * 60 * 60, + 12 * 60 * 60, + // returned when filtering messages + ContextChatProvider::CONTEXT_CHAT_MESSAGE_MAX_AGE, + // returned before processing messages + 0, + // returned on first message -- will prevent message from being processed + ContextChatProvider::CONTEXT_CHAT_JOB_INTERVAL + 100, + ContextChatProvider::CONTEXT_CHAT_JOB_INTERVAL + 100, + ContextChatProvider::CONTEXT_CHAT_JOB_INTERVAL + 100, + ContextChatProvider::CONTEXT_CHAT_JOB_INTERVAL + 100, + ContextChatProvider::CONTEXT_CHAT_JOB_INTERVAL + 100 + ); + $this->messageMapper->expects($this->once())->method('findIdsAfter') + ->with($mailbox, 0, 0, ContextChatProvider::CONTEXT_CHAT_IMPORT_MAX_ITEMS)->willReturn([1]); + $account = $this->createMock(Account::class); + $account->expects($this->any())->method('getUserId')->willReturn('user123'); + $this->accountService->expects($this->once())->method('findById')->with()->willReturn($account); + $message = new Message(); + $this->messageMapper->expects($this->once())->method('findByIds')->willReturn([$message]); + $client = $this->createMock(\Horde_Imap_Client_Socket::class); + $this->imapClientFactory->expects($this->once())->method('getClient')->willReturn($client); + $this->mailManager->expects($this->never())->method('getImapMessage'); // will not get called because the job takes too long already + $client->expects($this->once())->method('close'); + + $this->submitContentJob->setLastRun(0); + $this->submitContentJob->start($this->createMock(IJobList::class)); + } + + public function testRunWithContextChatWithEncryptedMessage(): void { + $this->contentManager->expects($this->once()) + ->method('isContextChatAvailable') + ->willReturn(true); + $task = new Task(); + $task->setLastMessageId(0); + $task->setMailboxId(1); + $task->setId(1); + $this->taskService->expects($this->once())->method('findNext')->willReturn($task); + $mailbox = new Mailbox(); + $mailbox->setId(1); + $mailbox->setAccountId(5); + $this->mailboxMapper->expects($this->once())->method('findById')->willReturn($mailbox); + $this->time->expects($this->any())->method('getTime') + ->willReturn( + // returned when Job#start asks + 12 * 60 * 60, + 12 * 60 * 60, + // returned when filtering messages + ContextChatProvider::CONTEXT_CHAT_MESSAGE_MAX_AGE, + // returned before processing messages + 0, + // returned on first message + 0, + 0, + 0, + 0, + ); + $this->messageMapper->expects($this->once())->method('findIdsAfter') + ->with($mailbox, 0, 0, ContextChatProvider::CONTEXT_CHAT_IMPORT_MAX_ITEMS)->willReturn([1]); + $account = $this->createMock(Account::class); + $account->expects($this->any())->method('getUserId')->willReturn('user123'); + $this->accountService->expects($this->once())->method('findById')->with()->willReturn($account); + $message = new Message(); + $message->setId(1); + $message->setUid(1); + $this->messageMapper->expects($this->once())->method('findByIds')->willReturn([$message]); + $client = $this->createMock(\Horde_Imap_Client_Socket::class); + $this->imapClientFactory->expects($this->once())->method('getClient')->willReturn($client); + $imapMessage = $this->createMock(IMAPMessage::class); + $this->mailManager->expects($this->once())->method('getImapMessage')->willReturn($imapMessage); + $imapMessage->expects($this->once())->method('isEncrypted')->willReturn(true); + $imapMessage->expects($this->never())->method('getFullMessage'); + $client->expects($this->once())->method('close'); + + $this->submitContentJob->setLastRun(0); + $this->submitContentJob->start($this->createMock(IJobList::class)); + } + + public function testRunWithContextChatWithFindNextTaskException(): void { + $this->contentManager->expects($this->once()) + ->method('isContextChatAvailable') + ->willReturn(true); + + $this->time->expects($this->any())->method('getTime')->willReturn(60 * 60 * 12); + + $this->taskService->expects($this->once())->method('findNext')->willThrowException(new \OCP\DB\Exception('An error')); + $this->contentManager->expects($this->never())->method('submitContent'); + $this->imapClientFactory->expects($this->never())->method('getClient'); + + $this->submitContentJob->setLastRun(0); + $this->submitContentJob->start($this->createMock(IJobList::class)); + } + + public function testRunWithContextChatWithFindNextTaskException2(): void { + $this->contentManager->expects($this->once()) + ->method('isContextChatAvailable') + ->willReturn(true); + + $this->time->expects($this->any())->method('getTime')->willReturn(60 * 60 * 12); + + $this->taskService->expects($this->once())->method('findNext')->willThrowException(new DoesNotExistException('ERROR')); + $this->contentManager->expects($this->never())->method('submitContent'); + $this->imapClientFactory->expects($this->never())->method('getClient'); + + $this->submitContentJob->setLastRun(0); + $this->submitContentJob->start($this->createMock(IJobList::class)); + } + + public function testRunWithContextChatWithFindByIdException1(): void { + $this->contentManager->expects($this->once()) + ->method('isContextChatAvailable') + ->willReturn(true); + + $this->time->expects($this->any())->method('getTime')->willReturn(60 * 60 * 12); + + $task = new Task(); + $task->setLastMessageId(0); + $task->setMailboxId(1); + $task->setId(1); + $this->taskService->expects($this->once())->method('findNext')->willReturn($task); + $mailbox = new Mailbox(); + $mailbox->setId(1); + $mailbox->setAccountId(5); + $this->mailboxMapper->expects($this->once())->method('findById')->willThrowException(new \OCA\Mail\Exception\ServiceException()); + $this->contentManager->expects($this->never())->method('submitContent'); + $this->imapClientFactory->expects($this->never())->method('getClient'); + + $this->submitContentJob->setLastRun(0); + $this->submitContentJob->start($this->createMock(IJobList::class)); + } + + public function testRunWithContextChatWithFindByIdException2(): void { + $this->contentManager->expects($this->once()) + ->method('isContextChatAvailable') + ->willReturn(true); + + $this->time->expects($this->any())->method('getTime')->willReturn(60 * 60 * 12); + + $task = new Task(); + $task->setLastMessageId(0); + $task->setMailboxId(1); + $task->setId(1); + $this->taskService->expects($this->once())->method('findNext')->willReturn($task); + $mailbox = new Mailbox(); + $mailbox->setId(1); + $mailbox->setAccountId(5); + $this->mailboxMapper->expects($this->once())->method('findById')->willThrowException(new DoesNotExistException('ERROR')); + $this->contentManager->expects($this->never())->method('submitContent'); + $this->imapClientFactory->expects($this->never())->method('getClient'); + + $this->submitContentJob->setLastRun(0); + $this->submitContentJob->start($this->createMock(IJobList::class)); + } +} diff --git a/tests/Unit/ContextChat/ContextChatProviderTest.php b/tests/Unit/ContextChat/ContextChatProviderTest.php new file mode 100644 index 0000000000..83b15c2589 --- /dev/null +++ b/tests/Unit/ContextChat/ContextChatProviderTest.php @@ -0,0 +1,166 @@ +markTestSkipped(); + } + + $this->taskService = $this->createMock(TaskService::class); + $this->accountService = $this->createMock(AccountService::class); + $this->mailManager = $this->createMock(MailManager::class); + $this->messageMapper = $this->createMock(MessageMapper::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->contentManager = $this->createMock(IContentManager::class); + $this->jobList = $this->createMock(IJobList::class); + + $this->contextChatProvider = new ContextChatProvider( + $this->taskService, + $this->accountService, + $this->mailManager, + $this->messageMapper, + $this->urlGenerator, + $this->userManager, + $this->contentManager, + $this->jobList, + ); + } + + public function provideEvents(): array { + $account = new Account(new MailAccount()); + $mailbox = new Mailbox(); + $mailbox->setId(1); + $messages = []; + $messages[] = new Message(); + $messages[0]->setId(1); + $messages[] = new Message(); + $messages[1]->setId(2); + + if (class_exists(\OCP\ContextChat\Events\ContentProviderRegisterEvent::class)) { + return [ + 'handle ContentProviderRegisterEvent' => [new \OCP\ContextChat\Events\ContentProviderRegisterEvent($this->createMock(IContentManager::class))], + 'handle NewMessagesSynchronized' => [new NewMessagesSynchronized($account, $mailbox, $messages)], + 'handle MessageDeletedEvent' => [new MessageDeletedEvent($account, $mailbox, 1)], + ]; + } + + return [ + 'handle NewMessagesSynchronized' => [new NewMessagesSynchronized($account, $mailbox, $messages)], + 'handle MessageDeletedEvent' => [new MessageDeletedEvent($account, $mailbox, 1)], + ]; + } + + /** + * @dataProvider provideEvents + */ + public function testHandleWithoutContextChat($event): void { + $this->contentManager->expects($this->once()) + ->method('isContextChatAvailable') + ->willReturn(false); + $this->contentManager->expects($this->never())->method('registerContentProvider'); + $this->contentManager->expects($this->never())->method('deleteContent'); + $this->taskService->expects($this->never())->method('updateOrCreate'); + $this->contextChatProvider->handle($event); + } + + /** + * @dataProvider provideEvents + */ + public function testHandleWithContextChat($event) { + $this->contentManager->expects($this->once()) + ->method('isContextChatAvailable') + ->willReturn(true); + + if ($event instanceof ContentProviderRegisterEvent) { + $this->contentManager->expects($this->once()) + ->method('registerContentProvider'); + } + + if ($event instanceof NewMessagesSynchronized) { + $this->taskService->expects($this->once()) + ->method('updateOrCreate'); + } + + if ($event instanceof MessageDeletedEvent) { + $this->contentManager->expects($this->once()) + ->method('deleteContent'); + } + + $this->contextChatProvider->handle($event); + } + + public function testGetId(): void { + $this->assertEquals('mail', $this->contextChatProvider->getId()); + } + + public function testGetAppId(): void { + $this->assertEquals('mail', $this->contextChatProvider->getAppId()); + } + + public function testGetItemUrl(): void { + $this->urlGenerator->expects($this->once())->method('linkToRouteAbsolute')->willReturnCallback(function ($route, $args) { + $this->assertEquals('mail.page.thread', $route); + $this->assertEquals(1, $args['mailboxId']); + $this->assertEquals(2, $args['id']); + return 'http://localhost/apps/mail/box/1/thread/2'; + }); + $itemUrl = $this->contextChatProvider->getItemUrl('1:2'); + $this->assertEquals('http://localhost/apps/mail/box/1/thread/2', $itemUrl); + } +} diff --git a/tests/Unit/Controller/PageControllerTest.php b/tests/Unit/Controller/PageControllerTest.php index 6322272c07..662e847e05 100644 --- a/tests/Unit/Controller/PageControllerTest.php +++ b/tests/Unit/Controller/PageControllerTest.php @@ -19,6 +19,7 @@ use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\AliasesService; +use OCA\Mail\Service\ContextChat\ContextChatSettingsService; use OCA\Mail\Service\InternalAddressService; use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; @@ -111,6 +112,8 @@ class PageControllerTest extends TestCase { private IAvailabilityCoordinator&MockObject $availabilityCoordinator; private IAppManager $appManager; + private ContextChatSettingsService $contextChatSettingsService; + protected function setUp(): void { parent::setUp(); @@ -138,6 +141,8 @@ protected function setUp(): void { $this->quickActionsService = $this->createMock(QuickActionsService::class); $this->appManager = $this->createMock(IAppManager::class); $this->appManager->method('getAppVersion')->willReturn('0.0.1-dev.0'); + $this->contextChatSettingsService = $this->createMock(ContextChatSettingsService::class); + $this->contextChatSettingsService->method('isIndexingEnabled')->willReturn(true); $this->controller = new PageController( $this->appName, @@ -163,6 +168,7 @@ protected function setUp(): void { $this->availabilityCoordinator, $this->quickActionsService, $this->appManager, + $this->contextChatSettingsService, ); } @@ -294,6 +300,8 @@ public function testIndex(): void { $this->equalTo('email'), $this->equalTo('')) ->will($this->returnValue('jane@doe.cz')); + $this->appManager->method('isEnabledForUser')->willReturn(true); + $loginCredentials = $this->createMock(ICredentials::class); $loginCredentials->expects($this->once()) ->method('getPassword') @@ -310,7 +318,7 @@ public function testIndex(): void { ->method('findAll') ->with($this->userId) ->willReturn([]); - $this->initialState->expects($this->exactly(25)) + $this->initialState->expects($this->exactly(26)) ->method('provideInitialState') ->withConsecutive( ['debug', true], @@ -335,7 +343,8 @@ public function testIndex(): void { 'layout-mode' => 'vertical-split', 'layout-message-view' => 'threaded', 'follow-up-reminders' => 'true', - 'sort-favorites' => 'false' + 'sort-favorites' => 'false', + 'index-context-chat' => 'true', ]], ['prefill_displayName', 'Jane Doe'], ['prefill_email', 'jane@doe.cz'], @@ -348,6 +357,7 @@ public function testIndex(): void { ['llm_translation_enabled', false], ['llm_freeprompt_available', false], ['llm_followup_available', false], + ['context_chat_available', true], ['smime-certificates', []], ['enable-system-out-of-office', true], ); diff --git a/tests/Unit/Service/AccountServiceTest.php b/tests/Unit/Service/AccountServiceTest.php index 89ebe555c0..d2d1f2bfc0 100644 --- a/tests/Unit/Service/AccountServiceTest.php +++ b/tests/Unit/Service/AccountServiceTest.php @@ -180,7 +180,7 @@ public function testSave() { $this->jobList->method('has') ->willReturn(false); - $this->jobList->expects($this->exactly(5)) + $this->jobList->expects($this->exactly(6)) ->method('scheduleAfter'); $this->config->expects(self::once()) @@ -245,7 +245,7 @@ public function testScheduleBackgroundJobs(): void { ->willReturn(1755850409); $this->jobList->method('has') ->willReturnCallback(fn ($job) => $job === SyncJob::class || $job === QuotaJob::class); - $this->jobList->expects($this->exactly(3)) + $this->jobList->expects($this->exactly(4)) ->method('scheduleAfter'); $this->accountService->scheduleBackgroundJobs($mailAccountId);