diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php index 88b6299e21..9ff14faa47 100755 --- a/lib/Controller/MessagesController.php +++ b/lib/Controller/MessagesController.php @@ -30,11 +30,14 @@ use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\ItineraryService; +use OCA\Mail\Service\MessageOperationService; use OCA\Mail\Service\SmimeService; use OCA\Mail\Service\SnoozeService; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\JSONResponse; @@ -94,7 +97,8 @@ public function __construct(string $appName, IDkimService $dkimService, IUserPreferences $preferences, SnoozeService $snoozeService, - AiIntegrationsService $aiIntegrationService) { + AiIntegrationsService $aiIntegrationService, + private MessageOperationService $messageOperationService) { parent::__construct($appName, $request); $this->accountService = $accountService; $this->mailManager = $mailManager; @@ -782,6 +786,24 @@ public function setFlags(int $id, array $flags): JSONResponse { return new JSONResponse(); } + /** + * + * @NoAdminRequired + * + * @param array $identifiers + * @param array $flags + * + * @return JSONResponse + */ + #[FrontpageRoute(verb: 'PUT', url: '/api/messages/flags')] + #[NoAdminRequired] + #[TrapError] + public function changeFlags(array $identifiers, array $flags): JSONResponse { + return new JSONResponse( + $this->messageOperationService->changeFlags($this->currentUserId, $identifiers, $flags) + ); + } + /** * @NoAdminRequired * diff --git a/lib/Db/MailAccountMapper.php b/lib/Db/MailAccountMapper.php index 0e41bbe976..78821b5dfc 100644 --- a/lib/Db/MailAccountMapper.php +++ b/lib/Db/MailAccountMapper.php @@ -66,6 +66,25 @@ public function findById(int $id): MailAccount { return $this->findEntity($query); } + /** + * Finds all mail accounts by account ids + * + * @param array $identifiers + * + * @return array + */ + public function findByIds(array $identifiers): array { + + $cmd = $this->db->getQueryBuilder(); + $cmd->select('*') + ->from($this->getTableName()) + ->where( + $cmd->expr()->in('id', $cmd->createNamedParameter($identifiers, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY) + ); + + return $this->findEntities($cmd); + } + /** * Finds all Mail Accounts by user id existing for this user * diff --git a/lib/Db/MailboxMapper.php b/lib/Db/MailboxMapper.php index 434c63fd10..ae6b976ad8 100644 --- a/lib/Db/MailboxMapper.php +++ b/lib/Db/MailboxMapper.php @@ -113,7 +113,9 @@ public function findById(int $id): Mailbox { } /** - * @return Mailbox[] + * @param array $ids + * + * @return array * * @throws Exception */ diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index cdc0e71870..d8a637fbc1 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -178,6 +178,28 @@ public function findUidsForIds(Mailbox $mailbox, array $ids) { }, array_chunk($ids, 1000)); } + /** + * @param array $identifiers + * + * @return array + */ + public function findMailboxAndUid(array $identifiers): array { + + if ($identifiers === []) { + return []; + } + + $cmd = $this->db->getQueryBuilder(); + $cmd->select('id', 'mailbox_id', 'uid') + ->from($this->getTableName()) + ->where( + $cmd->expr()->in('id', $cmd->createNamedParameter($identifiers, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY) + ); + + return $cmd->executeQuery()->fetchAll(); + + } + /** * @param Account $account * diff --git a/lib/IMAP/MessageMapper.php b/lib/IMAP/MessageMapper.php index f076d6f4f4..b15c956969 100644 --- a/lib/IMAP/MessageMapper.php +++ b/lib/IMAP/MessageMapper.php @@ -435,6 +435,44 @@ public function removeFlag(Horde_Imap_Client_Socket $client, ); } + /** + * @throws Horde_Imap_Client_Exception + */ + public function setFlags( + Horde_Imap_Client_Socket $client, + Mailbox $mailbox, + array $uids, + array $addFlags = [], + array $removeFlags = [], + array $replaceFlags = [] + ): array { + + if (count($uids) === 0) { + return []; + } + + $cmd = []; + + if (count($replaceFlags) > 0) { + $cmd['replace'] = $replaceFlags; + } else { + if (count($addFlags) > 0) { + $cmd['add'] = $addFlags; + } + if (count($removeFlags) > 0) { + $cmd['remove'] = $removeFlags; + } + } + + if (count($cmd) > 0) { + $cmd['ids'] = new Horde_Imap_Client_Ids($uids); + $result = $client->store($mailbox->getName(), $cmd); + return $result->ids; + } + + return []; + } + /** * @param Horde_Imap_Client_Socket $client * @param Mailbox $mailbox diff --git a/lib/Service/MessageOperationService.php b/lib/Service/MessageOperationService.php new file mode 100644 index 0000000000..6ed4c0e558 --- /dev/null +++ b/lib/Service/MessageOperationService.php @@ -0,0 +1,153 @@ + [[id, uid]]] + * + * @param array $collection + * + * @return array> + */ + protected function groupByMailbox(array $collection): array { + return array_reduce($collection, function ($carry, $pair) { + if (!isset($carry[$pair['mailbox_id']])) { + $carry[$pair['mailbox_id']] = []; + } + $carry[$pair['mailbox_id']][] = ['id' => $pair['id'], 'uid' => $pair['uid']]; + return $carry; + }, []); + } + + /** + * convert mailbox collection to grouped collections by account id + * + * [mailbox] to [account_id => [mailbox]] + * + * @param array<\OCA\Mail\Db\MailBox> $collection + * + * @return array> + */ + protected function groupByAccount(array $collection) { + return array_reduce($collection, function ($carry, $entry) { + if (!isset($carry[$entry->getAccountId()])) { + $carry[$entry->getAccountId()] = []; + } + $carry[$entry->getAccountId()][] = $entry; + return $carry; + }, []); + } + + /** + * generates operation status responses for each message + * + * @param array &$results + * @param bool $value + * @param array<\OCA\Mail\Db\MailBox> $mailboxes + * @param array> $messages + */ + protected function generateResult(array &$results, bool $value, array $mailboxes, array $messages) { + foreach ($mailboxes as $mailbox) { + foreach ($messages[$mailbox->getId()] as $message) { + $results[$message['id']] = $value; + } + } + } + + /** + * Set/Unset system flags or keywords + * + * @param string $userId system user id + * @param array $identifiers message ids + * @param array $flags message flags + * + * @return array operation results + */ + public function changeFlags(string $userId, array $identifiers, array $flags): array { + + // retrieve message meta data [uid, mailbox_id] for all messages and group by mailbox id + $messages = $this->groupByMailbox($this->messageMapper->findMailboxAndUid($identifiers)); + // retrieve all mailboxes and group by account + $mailboxes = $this->groupByAccount($this->mailboxMapper->findByIds(array_keys($messages))); + // retrieve all accounts + $accounts = $this->accountMapper->findByIds(array_keys($mailboxes)); + // process every account + $results = []; + foreach ($accounts as $account) { + $account = new Account($account); + // determine if account belongs to the user and skip if not + if ($account->getUserId() != $userId) { + // add messages to results as failed + $this->generateResult($results, false, $mailboxes[$account->getId()], $messages); + continue; + } + $client = $this->clientFactory->getClient($account); + // process every mailbox + foreach ($mailboxes[$account->getId()] as $mailbox) { + try { + // check if specific flags are supported and group them by action + $addFlags = []; + $removeFlags = []; + foreach ($flags as $flag => $value) { + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN); + $imapFlags = $this->mailManager->filterFlags($client, $account, $flag, $mailbox->getName()); + if (empty($imapFlags)) { + continue; + } + if ($value) { + $addFlags = array_merge($addFlags, $imapFlags); + } else { + $removeFlags = array_merge($removeFlags, $imapFlags); + } + } + // apply flags to messages on server + $this->imapMessageMapper->setFlags( + $client, + $mailbox, + array_column($messages[$mailbox->getId()], 'uid'), + $addFlags, + $removeFlags + ); + // add messages to results as successful + $this->generateResult($results, true, [$mailbox], $messages); + } catch (Throwable $e) { + // add messages to results as failed + $this->generateResult($results, false, [$mailbox], $messages); + } + } + $client->logout(); + } + + return $results; + } + +} \ No newline at end of file diff --git a/src/components/EnvelopeList.vue b/src/components/EnvelopeList.vue index 253678177e..e2b56f66ed 100644 --- a/src/components/EnvelopeList.vue +++ b/src/components/EnvelopeList.vue @@ -67,7 +67,7 @@ }} + @click.prevent="markSelectedFavoriteOrUnfavorite"> @@ -414,12 +414,11 @@ export default { return this.selection.includes(idx) }, markSelectedSeenOrUnseen() { - const seen = !this.areAllSelectedRead - this.selectedEnvelopes.forEach((envelope) => { - this.$store.dispatch('toggleEnvelopeSeen', { - envelope, - seen, - }) + const state = !this.areAllSelectedRead + const envelopes = this.selectedEnvelopes + this.$store.dispatch('markEnvelopesSeenOrUnseen', { + envelopes, + state, }) this.unselectAll() }, @@ -442,6 +441,14 @@ export default { this.unselectAll() }, async markSelectionJunk() { + const state = true + const envelopes = this.selectedEnvelopes + this.$store.dispatch('markEnvelopesJunkOrNotJunk', { + envelopes, + state, + }) + this.unselectAll() + /* for (const envelope of this.selectedEnvelopes) { if (!envelope.flags.$junk) { await this.$store.dispatch('toggleEnvelopeJunk', { @@ -451,8 +458,17 @@ export default { } } this.unselectAll() + */ }, async markSelectionNotJunk() { + const state = false + const envelopes = this.selectedEnvelopes + this.$store.dispatch('markEnvelopesJunkOrNotJunk', { + envelopes, + state, + }) + this.unselectAll() + /* for (const envelope of this.selectedEnvelopes) { if (envelope.flags.$junk) { await this.$store.dispatch('toggleEnvelopeJunk', { @@ -462,14 +478,14 @@ export default { } } this.unselectAll() - }, - favoriteOrUnfavoriteAll() { - const favFlag = !this.areAllSelectedFavorite - this.selectedEnvelopes.forEach((envelope) => { - this.$store.dispatch('markEnvelopeFavoriteOrUnfavorite', { - envelope, - favFlag, - }) + */ + }, + markSelectedFavoriteOrUnfavorite() { + const state = !this.areAllSelectedFavorite + const envelopes = this.selectedEnvelopes + this.$store.dispatch('markEnvelopesFavoriteOrUnfavorite', { + envelopes, + state, }) this.unselectAll() }, diff --git a/src/service/MessageService.js b/src/service/MessageService.js index 7ee429e576..fd4a391b10 100644 --- a/src/service/MessageService.js +++ b/src/service/MessageService.js @@ -115,15 +115,14 @@ export async function clearCache(accountId, id) { /** * Set flags for envelope * - * @param {int} id + * @param {array} identifiers * @param {object} flags */ -export async function setEnvelopeFlags(id, flags) { - const url = generateUrl('/apps/mail/api/messages/{id}/flags', { - id, - }) +export async function setEnvelopeFlags(identifiers, flags) { + const url = generateUrl('/apps/mail/api/messages/flags') return await axios.put(url, { + identifiers, flags, }) } diff --git a/src/store/actions.js b/src/store/actions.js index a6324b58cf..26374d0db2 100644 --- a/src/store/actions.js +++ b/src/store/actions.js @@ -912,7 +912,7 @@ export default { }) try { - await setEnvelopeFlags(envelope.databaseId, { + await setEnvelopeFlags([ envelope.databaseId ], { flagged: !oldState, }) } catch (error) { @@ -960,8 +960,8 @@ export default { }) try { - await setEnvelopeFlags(envelope.databaseId, { - seen: newState, + await setEnvelopeFlags([ envelope.databaseId ], { + seen: newState }) } catch (error) { console.error('could not toggle message seen state', error) @@ -997,7 +997,7 @@ export default { } try { - await setEnvelopeFlags(envelope.databaseId, { + await setEnvelopeFlags([ envelope.databaseId ], { $junk: !oldState, $notjunk: oldState, }) @@ -1024,32 +1024,72 @@ export default { } }) }, - async markEnvelopeFavoriteOrUnfavorite({ commit, getters }, { envelope, favFlag }) { + async markEnvelopesSeenOrUnseen({ commit, getters }, { envelopes, state }) { return handleHttpAuthErrors(commit, async () => { - // Change immediately and switch back on error - const oldState = envelope.flags.flagged - commit('flagEnvelope', { - envelope, - flag: 'flagged', - value: favFlag, + try { + const identifiers = [] + envelopes.forEach((envelope) => { + identifiers.push(envelope.databaseId) + }) + await setEnvelopeFlags(identifiers, { seen: state }) + } catch (error) { + console.error('could not mark messages seen or unseen', error) + throw error + } + envelopes.forEach((envelope) => { + commit('flagEnvelope', { + envelope, + flag: 'seen', + value: state, + }) }) - + }) + }, + async markEnvelopesJunkOrNotJunk({ commit, getters }, { envelopes, state }) { + return handleHttpAuthErrors(commit, async () => { try { - await setEnvelopeFlags(envelope.databaseId, { - flagged: favFlag, + const identifiers = [] + envelopes.forEach((envelope) => { + identifiers.push(envelope.databaseId) }) + await setEnvelopeFlags(identifiers, { junk: state, notjunk: !state }) } catch (error) { - console.error('could not favorite/unfavorite message ' + envelope.uid, error) - - // Revert change + console.error('could not mark messages junk or not junk', error) + throw error + } + envelopes.forEach((envelope) => { commit('flagEnvelope', { envelope, - flag: 'flagged', - value: oldState, + flag: '$junk', + value: state, }) - + commit('flagEnvelope', { + envelope, + flag: '$notjunk', + value: !state, + }) + }) + }) + }, + async markEnvelopesFavoriteOrUnfavorite({ commit, getters }, { envelopes, state }) { + return handleHttpAuthErrors(commit, async () => { + try { + const identifiers = [] + envelopes.forEach((envelope) => { + identifiers.push(envelope.databaseId) + }) + await setEnvelopeFlags(identifiers, { flagged: state }) + } catch (error) { + console.error('could not mark messages favorite or unfavorite', error) throw error } + envelopes.forEach((envelope) => { + commit('flagEnvelope', { + envelope, + flag: 'flagged', + value: state, + }) + }) }) }, async markEnvelopeImportantOrUnimportant({ commit, dispatch, getters }, { envelope, addTag }) { diff --git a/vite.config.js b/vite.config.js index 789c28a71a..40f2edf9fb 100644 --- a/vite.config.js +++ b/vite.config.js @@ -22,6 +22,7 @@ export default createAppConfig({ config: { build: { cssCodeSplit: false, + minify: false, }, plugins: [ ckeditor5({ theme: require.resolve('@ckeditor/ckeditor5-theme-lark') }),