From 3dc5cfadf2e1125b66659e01dfa138556126f32c Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 19 Aug 2025 14:58:00 +0200 Subject: [PATCH 1/3] feat: store envelopes as list of threads Signed-off-by: Hamza --- lib/Contracts/IMailManager.php | 2 +- lib/Contracts/IMailSearch.php | 4 +- lib/Db/MessageMapper.php | 85 +++++++++++++++++++++++++++++-- lib/IMAP/PreviewEnhancer.php | 4 +- lib/Service/MailManager.php | 5 +- lib/Service/Search/MailSearch.php | 31 ++++++----- lib/Service/Sync/SyncService.php | 29 ++++++++--- src/components/Envelope.vue | 8 ++- src/components/EnvelopeList.vue | 52 ++++++++++--------- src/components/MailboxThread.vue | 2 +- src/service/MessageService.js | 4 +- src/store/mainStore/actions.js | 38 +++++++------- 12 files changed, 186 insertions(+), 78 deletions(-) diff --git a/lib/Contracts/IMailManager.php b/lib/Contracts/IMailManager.php index 134871189d..c3f72d1f44 100644 --- a/lib/Contracts/IMailManager.php +++ b/lib/Contracts/IMailManager.php @@ -108,7 +108,7 @@ public function getImapMessage(Horde_Imap_Client_Socket $client, * * @return Message[] */ - public function getThread(Account $account, string $threadRootId): array; + public function getThread(Account $account, string $threadRootId, string $sortOrder = IMailSearch::ORDER_NEWEST_FIRST): array; /** * @param Account $sourceAccount diff --git a/lib/Contracts/IMailSearch.php b/lib/Contracts/IMailSearch.php index 4bee40bca8..db8c8c44ee 100644 --- a/lib/Contracts/IMailSearch.php +++ b/lib/Contracts/IMailSearch.php @@ -42,7 +42,7 @@ public function findMessage(Account $account, * @param string|null $userId * @param string|null $view * - * @return Message[] + * @return Message[][] * * @throws ClientException * @throws ServiceException @@ -59,7 +59,7 @@ public function findMessages(Account $account, /** * Run a search through all mailboxes of a user. * - * @return Message[] + * @return Message[][] * * @throws ClientException * @throws ServiceException diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index 9068cb3771..b9692a20ca 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -756,10 +756,11 @@ public function deleteByUid(Mailbox $mailbox, int ...$uids): void { /** * @param Account $account * @param string $threadRootId + * @param string $sortOrder * * @return Message[] */ - public function findThread(Account $account, string $threadRootId): array { + public function findThread(Account $account, string $threadRootId, string $sortOrder): array { $qb = $this->db->getQueryBuilder(); $qb->select('messages.*') ->from($this->getTableName(), 'messages') @@ -768,7 +769,7 @@ public function findThread(Account $account, string $threadRootId): array { $qb->expr()->eq('mailboxes.account_id', $qb->createNamedParameter($account->getId(), IQueryBuilder::PARAM_INT)), $qb->expr()->eq('messages.thread_root_id', $qb->createNamedParameter($threadRootId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR) ) - ->orderBy('messages.sent_at', 'desc'); + ->orderBy('messages.sent_at', $sortOrder); return $this->findRelatedData($this->findEntities($qb), $account->getUserId()); } @@ -1273,10 +1274,11 @@ public function findByUids(Mailbox $mailbox, array $uids): array { * @param Mailbox $mailbox * @param string $userId * @param int[] $ids + * @param string $sortOrder * * @return Message[] */ - public function findByMailboxAndIds(Mailbox $mailbox, string $userId, array $ids): array { + public function findByMailboxAndIds(Mailbox $mailbox, string $userId, array $ids, string $sortOrder): array { if ($ids === []) { return []; } @@ -1288,7 +1290,7 @@ public function findByMailboxAndIds(Mailbox $mailbox, string $userId, array $ids $qb->expr()->eq('mailbox_id', $qb->createNamedParameter($mailbox->getId()), IQueryBuilder::PARAM_INT), $qb->expr()->in('id', $qb->createParameter('ids')) ) - ->orderBy('sent_at', 'desc'); + ->orderBy('sent_at', $sortOrder); $results = []; foreach (array_chunk($ids, 1000) as $chunk) { @@ -1298,6 +1300,44 @@ public function findByMailboxAndIds(Mailbox $mailbox, string $userId, array $ids return array_merge([], ...$results); } + /** + * @param Account $account + * @param Mailbox $mailbox + * @param string $userId + * @param int[] $ids + * @param string $sortOrder + * @param bool $threadingEnabled + * + * @return Message[][] + */ + public function findMessageListsByMailboxAndIds(Account $account, Mailbox $mailbox, string $userId, array $ids, string $sortOrder, bool $threadingEnabled = false): array { + if ($ids === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('mailbox_id', $qb->createNamedParameter($mailbox->getId()), IQueryBuilder::PARAM_INT), + $qb->expr()->in('id', $qb->createParameter('ids')) + ) + ->orderBy('sent_at', $sortOrder); + foreach (array_chunk($ids, 1000) as $chunk) { + $qb->setParameter('ids', $chunk, IQueryBuilder::PARAM_INT_ARRAY); + if ($threadingEnabled) { + $res = $qb->executeQuery(); + while ($row = $res->fetch()) { + $message = $this->mapRowToEntity($row); + $results[] = $this->findThread($account, $message->getThreadRootId(), $sortOrder); + } + } else { + $results[] = array_map(fn (Message $msg) => [$msg], $this->findRelatedData($this->findEntities($qb), $userId)); + } + } + return $threadingEnabled ? $results : array_merge([], ...$results); + } + /** * @param string $userId * @param int[] $ids @@ -1325,6 +1365,43 @@ public function findByIds(string $userId, array $ids, string $sortOrder): array return array_merge([], ...$results); } + + /** + * @param Account $account + * @param string $userId + * @param int[] $ids + * @param string $sortOrder + * + * @return Message[][] + */ + public function findMessageListsByIds(Account $account, string $userId, array $ids, string $sortOrder, bool $threadingEnabled = false): array { + if ($ids === []) { + return []; + } + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->in('id', $qb->createParameter('ids')) + ) + ->orderBy('sent_at', $sortOrder); + + $results = []; + foreach (array_chunk($ids, 1000) as $chunk) { + $qb->setParameter('ids', $chunk, IQueryBuilder::PARAM_INT_ARRAY); + if ($threadingEnabled) { + $res = $qb->executeQuery(); + while ($row = $res->fetch()) { + $message = $this->mapRowToEntity($row); + $results[] = $this->findThread($account, $message->getThreadRootId(), $sortOrder); + } + } else { + $results[] = array_map(fn (Message $msg) => [$msg], $this->findRelatedData($this->findEntities($qb), $userId)); + } + } + return $threadingEnabled ? $results : array_merge([], ...$results); + } + /** * @param Message[] $messages * diff --git a/lib/IMAP/PreviewEnhancer.php b/lib/IMAP/PreviewEnhancer.php index eadc094cac..89f9f7c999 100644 --- a/lib/IMAP/PreviewEnhancer.php +++ b/lib/IMAP/PreviewEnhancer.php @@ -52,9 +52,9 @@ public function __construct(IMAPClientFactory $clientFactory, } /** - * @param Message[] $messages + * @param Message[][] $messages * - * @return Message[] + * @return Message[][] */ public function process(Account $account, Mailbox $mailbox, array $messages, bool $preLoadAvatars = false, ?string $userId = null): array { $needAnalyze = array_reduce($messages, static function (array $carry, Message $message) { diff --git a/lib/Service/MailManager.php b/lib/Service/MailManager.php index 2491fe2239..d956910cba 100644 --- a/lib/Service/MailManager.php +++ b/lib/Service/MailManager.php @@ -17,6 +17,7 @@ use OCA\Mail\Account; use OCA\Mail\Attachment; use OCA\Mail\Contracts\IMailManager; +use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; @@ -236,8 +237,8 @@ public function getImapMessagesForScheduleProcessing(Account $account, } #[\Override] - public function getThread(Account $account, string $threadRootId): array { - return $this->dbMessageMapper->findThread($account, $threadRootId); + public function getThread(Account $account, string $threadRootId, string $sortOrder = IMailSearch::ORDER_NEWEST_FIRST): array { + return $this->dbMessageMapper->findThread($account, $threadRootId, $sortOrder); } #[\Override] diff --git a/lib/Service/Search/MailSearch.php b/lib/Service/Search/MailSearch.php index 16f8ada442..aee950a72b 100644 --- a/lib/Service/Search/MailSearch.php +++ b/lib/Service/Search/MailSearch.php @@ -77,7 +77,7 @@ public function findMessage(Account $account, * @param int|null $limit * @param string|null $view * - * @return Message[] + * @return Message[][] * * @throws ClientException * @throws ServiceException @@ -102,8 +102,9 @@ public function findMessages(Account $account, if ($cursor !== null) { $query->setCursor($cursor); } + $threadingEnabled = $view === self::VIEW_THREADED; if ($view !== null) { - $query->setThreaded($view === self::VIEW_THREADED); + $query->setThreaded($threadingEnabled); } // In flagged we don't want anything but flagged messages if ($mailbox->isSpecialUse(Horde_Imap_Client::SPECIALUSE_FLAGGED)) { @@ -113,17 +114,23 @@ public function findMessages(Account $account, if (!$mailbox->isSpecialUse(Horde_Imap_Client::SPECIALUSE_TRASH)) { $query->addFlag(Flag::not(Flag::DELETED)); } - - return $this->previewEnhancer->process( - $account, - $mailbox, - $this->messageMapper->findByIds($account->getUserId(), - $this->getIdsLocally($account, $mailbox, $query, $sortOrder, $limit), - $sortOrder, - ), - true, - $userId + $messages = $this->messageMapper->findMessageListsByIds($account, $account->getUserId(), + $this->getIdsLocally($account, $mailbox, $query, $sortOrder, $limit), + $sortOrder, + $threadingEnabled ); + $processedMessages = []; + foreach ($messages as $messageList) { + $processedMessages[] = $this->previewEnhancer->process( + $account, + $mailbox, + $messageList, + true, + $userId + ); + } + + return $processedMessages; } /** diff --git a/lib/Service/Sync/SyncService.php b/lib/Service/Sync/SyncService.php index 2d1d1181d9..454b3ebf8f 100644 --- a/lib/Service/Sync/SyncService.php +++ b/lib/Service/Sync/SyncService.php @@ -24,6 +24,7 @@ use OCA\Mail\IMAP\Sync\Response; use OCA\Mail\Service\Search\FilterStringParser; use OCA\Mail\Service\Search\SearchQuery; +use OCP\IAppConfig; use Psr\Log\LoggerInterface; use function array_diff; use function array_map; @@ -50,6 +51,9 @@ class SyncService { /** @var MailboxSync */ private $mailboxSync; + /** @var IAppConfig */ + private $config; + public function __construct( IMAPClientFactory $clientFactory, ImapToDbSynchronizer $synchronizer, @@ -57,7 +61,9 @@ public function __construct( MessageMapper $messageMapper, PreviewEnhancer $previewEnhancer, LoggerInterface $logger, - MailboxSync $mailboxSync) { + MailboxSync $mailboxSync, + IAppConfig $config, + ) { $this->clientFactory = $clientFactory; $this->synchronizer = $synchronizer; $this->filterStringParser = $filterStringParser; @@ -65,6 +71,7 @@ public function __construct( $this->previewEnhancer = $previewEnhancer; $this->logger = $logger; $this->mailboxSync = $mailboxSync; + $this->config = $config; } /** @@ -129,6 +136,7 @@ public function syncMailbox(Account $account, $this->mailboxSync->syncStats($client, $mailbox); + $threadingEnabled = $this->config->getValueString('mail', 'layout-message-view', 'threaded') === 'threaded'; $client->logout(); $query = $filter === null ? null : $this->filterStringParser->parse($filter); @@ -138,7 +146,8 @@ public function syncMailbox(Account $account, $knownIds ?? [], $lastMessageTimestamp, $sortOrder, - $query + $query, + $threadingEnabled ); } @@ -147,6 +156,7 @@ public function syncMailbox(Account $account, * @param Mailbox $mailbox * @param int[] $knownIds * @param SearchQuery $query + * @param bool $threadingEnabled * * @return Response * @todo does not work with text token search queries @@ -157,7 +167,8 @@ private function getDatabaseSyncChanges(Account $account, array $knownIds, ?int $lastMessageTimestamp, string $sortOrder, - ?SearchQuery $query): Response { + ?SearchQuery $query, + bool $threadingEnabled): Response { if ($knownIds === []) { $newIds = $this->messageMapper->findAllIds($mailbox); } else { @@ -169,7 +180,13 @@ private function getDatabaseSyncChanges(Account $account, $newUids = $this->messageMapper->findUidsForIds($mailbox, $newIds); $newIds = $this->messageMapper->findIdsByQuery($mailbox, $query, $order, null, $newUids); } - $new = $this->messageMapper->findByMailboxAndIds($mailbox, $account->getUserId(), $newIds); + + $new = $this->messageMapper->findMessageListsByMailboxAndIds($account, $mailbox, $account->getUserId(), $newIds, $sortOrder, $threadingEnabled); + + $newMessages = []; + foreach ($new as $messageList) { + $newMessages[] = $this->previewEnhancer->process($account, $mailbox, $messageList); + } // TODO: $changed = $this->messageMapper->findChanged($account, $mailbox, $uids); if ($query !== null) { @@ -178,7 +195,7 @@ private function getDatabaseSyncChanges(Account $account, } else { $changedIds = $knownIds; } - $changed = $this->messageMapper->findByMailboxAndIds($mailbox, $account->getUserId(), $changedIds); + $changed = $this->messageMapper->findByMailboxAndIds($mailbox, $account->getUserId(), $changedIds, $sortOrder); $stillKnownIds = array_map(static function (Message $msg) { return $msg->getId(); @@ -186,7 +203,7 @@ private function getDatabaseSyncChanges(Account $account, $vanished = array_values(array_diff($knownIds, $stillKnownIds)); return new Response( - $this->previewEnhancer->process($account, $mailbox, $new), + $newMessages, $changed, $vanished, $mailbox->getStats() diff --git a/src/components/Envelope.vue b/src/components/Envelope.vue index 53518fc416..a7a73e9bf0 100644 --- a/src/components/Envelope.vue +++ b/src/components/Envelope.vue @@ -465,7 +465,7 @@ export default { type: Boolean, default: true, }, - data: { + threadList: { type: Object, required: true, }, @@ -506,14 +506,17 @@ export default { }, mounted() { this.onWindowResize() - window.addEventListener('resize', this.onWindowResize) }, + // eslint-disable-next-line vue/order-in-components computed: { ...mapStores(useMainStore), ...mapState(useMainStore, [ 'isSnoozeDisabled', ]), + data() { + return Object.values(this.threadList)[0] + }, messageLongDate() { return messageDateTime(new Date(this.data.dateInt)) }, @@ -809,6 +812,7 @@ export default { this.setSelected(false) // Delete this.$emit('delete', this.data.databaseId) + console.log('deleting', this.data, this.layoutMessageViewThreaded) try { if (this.layoutMessageViewThreaded) { diff --git a/src/components/EnvelopeList.vue b/src/components/EnvelopeList.vue index 1ce92e4749..7812b8b8c1 100644 --- a/src/components/EnvelopeList.vue +++ b/src/components/EnvelopeList.vue @@ -113,14 +113,14 @@
{ - return a.dateInt < b.dateInt ? -1 : 1 + return Object.values(a)[0].dateInt < Object.values(b)[0].dateInt ? -1 : 1 }) } return [...this.envelopes] @@ -252,16 +252,16 @@ export default { return this.selection.length > 0 }, isAtLeastOneSelectedRead() { - return this.selectedEnvelopes.some((env) => env.flags.seen === true) + return this.selectedEnvelopes.some((env) => Object.values(env)[0].flags.seen === true) }, isAtLeastOneSelectedUnread() { - return this.selectedEnvelopes.some((env) => env.flags.seen === false) + return this.selectedEnvelopes.some((env) => Object.values(env)[0].flags.seen === false) }, isAtLeastOneSelectedImportant() { // returns true if at least one selected message is marked as important return this.selectedEnvelopes.some((env) => { return this.mainStore - .getEnvelopeTags(env.databaseId) + .getEnvelopeTags(Object.keys(env)[0].databaseId) .some((tag) => tag.imapLabel === '$label1') }) }, @@ -269,33 +269,33 @@ export default { // returns true if at least one selected message is not marked as important return this.selectedEnvelopes.some((env) => { return !this.mainStore - .getEnvelopeTags(env.databaseId) + .getEnvelopeTags(Object.keys(env)[0].databaseId) .some((tag) => tag.imapLabel === '$label1') }) }, isAtLeastOneSelectedJunk() { // returns true if at least one selected message is marked as junk return this.selectedEnvelopes.some((env) => { - return env.flags.$junk + return Object.values(env)[0].flags.$junk }) }, isAtLeastOneSelectedNotJunk() { // returns true if at least one selected message is not marked as not junk return this.selectedEnvelopes.some((env) => { - return !env.flags.$junk + return !Object.values(env)[0].flags.$junk }) }, isAtLeastOneSelectedFavorite() { - return this.selectedEnvelopes.some((env) => env.flags.flagged) + return this.selectedEnvelopes.some((env) => Object.values(env)[0].flags.flagged) }, isAtLeastOneSelectedUnFavorite() { - return this.selectedEnvelopes.some((env) => !env.flags.flagged) + return this.selectedEnvelopes.some((env) => !Object.values(env)[0].flags.flagged) }, selectedEnvelopes() { - return this.sortedEnvelops.filter((env) => this.selection.includes(env.databaseId)) + return this.sortedEnvelops.filter((env) => this.selection.includes(Object.keys(env)[0].databaseId)) }, hasMultipleAccounts() { - const mailboxIds = this.sortedEnvelops.map(envelope => envelope.mailboxId) + const mailboxIds = this.sortedEnvelops.map(envelope => Object.values(envelope)[0].mailboxId) return Array.from(new Set(mailboxIds)).length > 1 }, listTransitionName() { @@ -304,10 +304,12 @@ export default { }, watch: { sortedEnvelops(newVal, oldVal) { + const newEnvs = Object.values(newVal).map(thread => Object.values(thread)[0]) + const oldEnvs = Object.values(oldVal).map(thread => Object.values(thread)[0]) // Unselect vanished envelopes - const newIds = newVal.map((env) => env.databaseId) + const newIds = newEnvs.map((env) => env.databaseId) this.selection = this.selection.filter((id) => newIds.includes(id)) - differenceWith((a, b) => a.databaseId === b.databaseId, oldVal, newVal) + differenceWith((a, b) => a.databaseId === b.databaseId, oldEnvs, newEnvs) .forEach((env) => { env.flags.selected = false }) @@ -417,8 +419,8 @@ export default { // one of threads is selected if (indexSelectedEnvelope !== -1) { - const lastSelectedEnvelope = this.selectedEnvelopes[this.selectedEnvelopes.length - 1] - const diff = this.sortedEnvelops.filter(envelope => envelope === lastSelectedEnvelope || !this.selectedEnvelopes.includes(envelope)) + const lastSelectedEnvelope = this.selectedEnvelopes[this.selectedEnvelopes.length - 1][0] + const diff = this.sortedEnvelops.filter(envelope => envelope[0] === lastSelectedEnvelope || !this.selectedEnvelopes.includes(envelope)) const lastIndex = diff.indexOf(lastSelectedEnvelope) nextEnvelopeToNavigate = diff[lastIndex === 0 ? 1 : lastIndex - 1] } @@ -466,13 +468,13 @@ export default { this.unselectAll() }, setEnvelopeSelected(envelope, selected) { - const alreadySelected = this.selection.includes(envelope.databaseId) + const alreadySelected = this.selection.includes(envelope[0].databaseId) if (selected && !alreadySelected) { - envelope.flags.selected = true - this.selection.push(envelope.databaseId) + envelope[0].flags.selected = true + this.selection.push(envelope[0].databaseId) } else if (!selected && alreadySelected) { - envelope.flags.selected = false - this.selection.splice(this.selection.indexOf(envelope.databaseId), 1) + envelope[0].flags.selected = false + this.selection.splice(this.selection.indexOf(envelope[0].databaseId), 1) } }, onEnvelopeSelectToggle(envelope, index, selected) { @@ -528,7 +530,7 @@ export default { */ findSelectionIndex(databaseId) { for (const [index, envelope] of this.sortedEnvelops.entries()) { - if (envelope.databaseId === databaseId) { + if (envelope[0].databaseId === databaseId) { return index } } diff --git a/src/components/MailboxThread.vue b/src/components/MailboxThread.vue index e07337a9ef..50db8f126e 100644 --- a/src/components/MailboxThread.vue +++ b/src/components/MailboxThread.vue @@ -356,7 +356,7 @@ export default { } for (const envelope of envelopes) { - const date = new Date(envelope.dateInt * 1000) + const date = new Date(Object.values(envelope)[0].dateInt * 1000) if (date >= oneHourAgo) { groups.lastHour.push(envelope) } else if (date >= startOfToday) { diff --git a/src/service/MessageService.js b/src/service/MessageService.js index 69ab0a5148..c284aae8ab 100644 --- a/src/service/MessageService.js +++ b/src/service/MessageService.js @@ -62,7 +62,7 @@ export function fetchEnvelopes(accountId, mailboxId, query, cursor, limit, sort, params, }) .then((resp) => resp.data) - .then(envelopes => envelopes.map(amendEnvelopeWithIds(accountId))) + .then(envelopes => envelopes.map((messageList) => messageList.map(amendEnvelopeWithIds(accountId)))) .catch((error) => { throw convertAxiosError(error) }) @@ -95,7 +95,7 @@ export async function syncEnvelopes(accountId, id, ids, lastMessageTimestamp, qu const amend = amendEnvelopeWithIds(accountId) return { - newMessages: response.data.newMessages.map(amend), + newMessages: response.data.newMessages.map((messageList) => messageList.map(amend)), changedMessages: response.data.changedMessages.map(amend), vanishedMessages: response.data.vanishedMessages, stats: response.data.stats, diff --git a/src/store/mainStore/actions.js b/src/store/mainStore/actions.js index 0f790a1692..c529ceefcf 100644 --- a/src/store/mainStore/actions.js +++ b/src/store/mainStore/actions.js @@ -654,7 +654,7 @@ export default function mainStoreActions() { // Only commit if not undefined (not found) if (envelope) { this.addEnvelopesMutation({ - envelopes: [envelope], + envelopes: [[envelope]], }) } @@ -1853,16 +1853,7 @@ export default function mainStoreActions() { */ appendOrReplaceEnvelopeId(existing, envelope) { - if (this.getPreference('layout-message-view') === 'singleton') { - existing.push(envelope.databaseId) - } else { - const index = existing.findIndex((id) => this.envelopes[id].threadRootId === envelope.threadRootId) - if (index === -1) { - existing.push(envelope.databaseId) - } else { - existing[index] = envelope.databaseId - } - } + existing.push(envelope.databaseId) return existing }, @@ -2051,13 +2042,20 @@ export default function mainStoreActions() { const listId = normalizedEnvelopeListId(query) const orderByDateInt = orderBy(idToDateInt, this.preferences['sort-order'] === 'newest' ? 'desc' : 'asc') - envelopes.forEach((envelope) => { - const mailbox = this.mailboxes[envelope.mailboxId] + envelopes.forEach((envelopelist) => { + const mailbox = this.mailboxes[envelopelist[0].mailboxId] const existing = mailbox.envelopeLists[listId] || [] - this.normalizeTags(envelope) - Vue.set(this.envelopes, envelope.databaseId, Object.assign({}, this.envelopes[envelope.databaseId] || {}, envelope)) - Vue.set(envelope, 'accountId', mailbox.accountId) - Vue.set(mailbox.envelopeLists, listId, uniq(orderByDateInt(this.appendOrReplaceEnvelopeId(existing, envelope)))) + envelopelist.forEach((envelope) => { + this.normalizeTags(envelope) + }) + const envelopeListIndexed = [] + envelopelist.forEach((envelope) => { + envelopeListIndexed[envelope.databaseId] = envelope + }) + Vue.set(this.envelopes, envelopelist[0].databaseId, Object.assign({}, this.envelopes[envelopelist[0].databaseId] || {}, envelopeListIndexed)) + Vue.set(envelopelist[0], 'accountId', mailbox.accountId) + // TODO: Check if still neededed + Vue.set(mailbox.envelopeLists, listId, uniq(orderByDateInt(this.appendOrReplaceEnvelopeId(existing, envelopelist[0])))) if (!addToUnifiedMailboxes) { return } @@ -2070,7 +2068,7 @@ export default function mainStoreActions() { Vue.set( mailbox.envelopeLists, listId, - uniq(orderByDateInt(existing.concat([envelope.databaseId]))), + uniq(orderByDateInt(existing.concat([envelopelist[0].databaseId]))), ) }) }) @@ -2135,7 +2133,9 @@ export default function mainStoreActions() { Vue.set(envelope, 'tags', envelope.tags.filter((id) => id !== tagId)) }, removeEnvelopeMutation({ id }) { - const envelope = this.envelopes[id] + const envelope = Object.values(this.envelopes) + .flatMap(envList => Object.values(envList)) + .find(env => env.databaseId === id) if (!envelope) { console.warn('envelope ' + id + ' is unknown, can\'t remove it') return From 0100ddb7a27c9a82249a2c19816be4578990028c Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 28 Aug 2025 14:52:34 +0100 Subject: [PATCH 2/3] fixup! feat: store envelopes as list of threads Signed-off-by: Hamza --- lib/Db/MessageMapper.php | 15 +++++++++++++-- src/components/Envelope.vue | 1 - 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index b9692a20ca..baa486a6ca 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -1323,14 +1323,20 @@ public function findMessageListsByMailboxAndIds(Account $account, Mailbox $mailb $qb->expr()->in('id', $qb->createParameter('ids')) ) ->orderBy('sent_at', $sortOrder); + $results = []; foreach (array_chunk($ids, 1000) as $chunk) { $qb->setParameter('ids', $chunk, IQueryBuilder::PARAM_INT_ARRAY); if ($threadingEnabled) { $res = $qb->executeQuery(); while ($row = $res->fetch()) { $message = $this->mapRowToEntity($row); - $results[] = $this->findThread($account, $message->getThreadRootId(), $sortOrder); + if ($message->getThreadRootId() === null) { + $results[] = [$message]; + } else { + $results[] = $this->findThread($account, $message->getThreadRootId(), $sortOrder); + } } + $res->closeCursor(); } else { $results[] = array_map(fn (Message $msg) => [$msg], $this->findRelatedData($this->findEntities($qb), $userId)); } @@ -1393,8 +1399,13 @@ public function findMessageListsByIds(Account $account, string $userId, array $i $res = $qb->executeQuery(); while ($row = $res->fetch()) { $message = $this->mapRowToEntity($row); - $results[] = $this->findThread($account, $message->getThreadRootId(), $sortOrder); + if ($message->getThreadRootId() === null) { + $results[] = [$message]; + } else { + $results[] = $this->findThread($account, $message->getThreadRootId(), $sortOrder); + } } + $res->closeCursor(); } else { $results[] = array_map(fn (Message $msg) => [$msg], $this->findRelatedData($this->findEntities($qb), $userId)); } diff --git a/src/components/Envelope.vue b/src/components/Envelope.vue index a7a73e9bf0..da08fd160e 100644 --- a/src/components/Envelope.vue +++ b/src/components/Envelope.vue @@ -812,7 +812,6 @@ export default { this.setSelected(false) // Delete this.$emit('delete', this.data.databaseId) - console.log('deleting', this.data, this.layoutMessageViewThreaded) try { if (this.layoutMessageViewThreaded) { From 6e229939b7e5fa3f170d30a5ecf5573d8b7d9ae9 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 2 Sep 2025 16:41:10 +0200 Subject: [PATCH 3/3] fixup! feat: store envelopes as list of threads Signed-off-by: Hamza --- lib/Db/MessageMapper.php | 123 ++++++++++++++++++++++++++------------- 1 file changed, 83 insertions(+), 40 deletions(-) diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index baa486a6ca..861f44cb31 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -1315,33 +1315,54 @@ public function findMessageListsByMailboxAndIds(Account $account, Mailbox $mailb return []; } + $base = $this->findByMailboxAndIds($mailbox, $userId, $ids, $sortOrder); + if ($threadingEnabled === false) { + return array_map(static fn (Message $m) => [$m], $base); + } + + $threadRoots = array_unique( + array_map(static fn (Message $m) => $m->getThreadRootId(), $base) + ); + + $allThreadMsgs = []; $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from($this->getTableName()) ->where( - $qb->expr()->eq('mailbox_id', $qb->createNamedParameter($mailbox->getId()), IQueryBuilder::PARAM_INT), - $qb->expr()->in('id', $qb->createParameter('ids')) + $qb->expr()->eq('mailbox_id', $qb->createNamedParameter($mailbox->getId(), IQueryBuilder::PARAM_INT)), + $qb->expr()->in('thread_root_id', $qb->createParameter('roots')), + $qb->expr()->notIn('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)) ) ->orderBy('sent_at', $sortOrder); - $results = []; - foreach (array_chunk($ids, 1000) as $chunk) { - $qb->setParameter('ids', $chunk, IQueryBuilder::PARAM_INT_ARRAY); - if ($threadingEnabled) { - $res = $qb->executeQuery(); - while ($row = $res->fetch()) { - $message = $this->mapRowToEntity($row); - if ($message->getThreadRootId() === null) { - $results[] = [$message]; - } else { - $results[] = $this->findThread($account, $message->getThreadRootId(), $sortOrder); - } - } - $res->closeCursor(); - } else { - $results[] = array_map(fn (Message $msg) => [$msg], $this->findRelatedData($this->findEntities($qb), $userId)); + foreach (array_chunk($threadRoots, 1000) as $chunk) { + $qb->setParameter('roots', $chunk, IQueryBuilder::PARAM_STR_ARRAY); + $allThreadMsgs[] = $this->findEntities($qb); + } + $allThreadMsgs = array_merge($base, ...$allThreadMsgs); + + $enriched = $this->findRelatedData(array_values($allThreadMsgs), $userId); + + $groups = []; + foreach ($enriched as $m) { + $root = $m->getThreadRootId(); + $groups[$root][] = $m; + } + + $orderKeys = []; + foreach ($base as $m) { + $key = $m->getThreadRootId(); + if (!isset($orderKeys[$key])) { + $orderKeys[$key] = true; + } + } + + $out = []; + foreach (array_keys($orderKeys) as $k) { + if (isset($groups[$k])) { + $out[] = $groups[$k]; } } - return $threadingEnabled ? $results : array_merge([], ...$results); + return $out; } /** @@ -1384,33 +1405,55 @@ public function findMessageListsByIds(Account $account, string $userId, array $i if ($ids === []) { return []; } + + $base = $this->findByIds($userId, $ids, $sortOrder); + + if ($threadingEnabled === false) { + return array_map(static fn (Message $m) => [$m], $base); + } + + $threadRoots = array_unique( + array_map(static fn (Message $m) => $m->getThreadRootId(), $base) + ); + + $allThreadMsgs = []; $qb = $this->db->getQueryBuilder(); - $qb->select('*') - ->from($this->getTableName()) + $qb->select('m.*') + ->from($this->getTableName(), 'm') + ->join('m', 'mail_mailboxes', 'mb', $qb->expr()->eq('m.mailbox_id', 'mb.id', IQueryBuilder::PARAM_INT)) ->where( - $qb->expr()->in('id', $qb->createParameter('ids')) + $qb->expr()->eq('mb.account_id', $qb->createNamedParameter($account->getId(), IQueryBuilder::PARAM_INT)), + $qb->expr()->in('m.thread_root_id', $qb->createParameter('roots')), + $qb->expr()->notIn('m.id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)) ) - ->orderBy('sent_at', $sortOrder); + ->orderBy('m.sent_at', $sortOrder); + foreach (array_chunk($threadRoots, 1000) as $chunk) { + $qb->setParameter('roots', $chunk, IQueryBuilder::PARAM_STR_ARRAY); + $allThreadMsgs[] = $this->findEntities($qb); + } + $allThreadMsgs = array_merge($base, ...$allThreadMsgs); + + $enriched = $this->findRelatedData(array_values($allThreadMsgs), $userId); + $groups = []; + foreach ($enriched as $m) { + $root = $m->getThreadRootId(); + $groups[$root][] = $m; + } + $orderKeys = []; + foreach ($base as $m) { + $key = $m->getThreadRootId(); + if (!isset($orderKeys[$key])) { + $orderKeys[$key] = true; + } + } - $results = []; - foreach (array_chunk($ids, 1000) as $chunk) { - $qb->setParameter('ids', $chunk, IQueryBuilder::PARAM_INT_ARRAY); - if ($threadingEnabled) { - $res = $qb->executeQuery(); - while ($row = $res->fetch()) { - $message = $this->mapRowToEntity($row); - if ($message->getThreadRootId() === null) { - $results[] = [$message]; - } else { - $results[] = $this->findThread($account, $message->getThreadRootId(), $sortOrder); - } - } - $res->closeCursor(); - } else { - $results[] = array_map(fn (Message $msg) => [$msg], $this->findRelatedData($this->findEntities($qb), $userId)); + $out = []; + foreach (array_keys($orderKeys) as $k) { + if (isset($groups[$k])) { + $out[] = $groups[$k]; } } - return $threadingEnabled ? $results : array_merge([], ...$results); + return $out; } /**