From 4e6f867459ff8326c1a427e3ef4ef31aed60034b Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 19 Aug 2025 14:58:00 +0200 Subject: [PATCH 1/6] 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 | 13 +++-- src/components/EnvelopeList.vue | 52 ++++++++++--------- src/components/MailboxThread.vue | 43 ++++++++++++++-- src/service/MessageService.js | 4 +- src/store/mainStore/actions.js | 27 ++++++---- 12 files changed, 228 insertions(+), 71 deletions(-) diff --git a/lib/Contracts/IMailManager.php b/lib/Contracts/IMailManager.php index 701d7ee814..27ae10c08c 100644 --- a/lib/Contracts/IMailManager.php +++ b/lib/Contracts/IMailManager.php @@ -109,7 +109,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 62797e4e9a..23ab7cf7c9 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -746,10 +746,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') @@ -758,7 +759,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()); } @@ -1223,10 +1224,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 []; } @@ -1238,7 +1240,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) { @@ -1248,6 +1250,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 @@ -1275,6 +1315,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 0f90e7f898..382fc407ad 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; @@ -237,8 +238,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 0cff18572e..3ac61b04a9 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,13 +195,13 @@ 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 fn (Message $msg) => $msg->getId(), $changed); $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 b9d16502f0..20dece6508 100644 --- a/src/components/Envelope.vue +++ b/src/components/Envelope.vue @@ -566,8 +566,7 @@ export default { type: Boolean, default: true, }, - - data: { + threadList: { type: Object, required: true, }, @@ -614,12 +613,19 @@ export default { quickActionLoading: false, } }, - + 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] + }, isRTL() { return isRTL() @@ -1154,6 +1160,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 b74ba06021..d1bf13f38e 100644 --- a/src/components/EnvelopeList.vue +++ b/src/components/EnvelopeList.vue @@ -125,15 +125,15 @@ @@ -285,7 +285,7 @@ export default { sortedEnvelops() { if (this.sortOrder === 'oldest') { return [...this.envelopes].sort((a, b) => { - return a.dateInt < b.dateInt ? -1 : 1 + return Object.values(a)[0].dateInt < Object.values(b)[0].dateInt ? -1 : 1 }) } return [...this.envelopes] @@ -297,18 +297,18 @@ export default { }, 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') }) }, @@ -317,7 +317,7 @@ 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') }) }, @@ -325,31 +325,31 @@ export default { 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 }, @@ -360,10 +360,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 }) @@ -484,8 +486,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] } @@ -534,13 +536,13 @@ export default { }, 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) } }, @@ -605,7 +607,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 1a74334e17..52975aeeea 100644 --- a/src/components/MailboxThread.vue +++ b/src/components/MailboxThread.vue @@ -473,10 +473,47 @@ export default { }, methods: { - getGroupedEnvelopes(envelopes, syncTimestamp) { - return groupEnvelopesByDate(envelopes, syncTimestamp, this.sortOrder) - }, + groupEnvelopesByDate(envelopes, syncTimestamp) { + const now = new Date(syncTimestamp) + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const startOfYesterday = new Date(startOfToday) + startOfYesterday.setDate(startOfYesterday.getDate() - 1) + const startOfLastWeek = new Date(now) + startOfLastWeek.setDate(startOfLastWeek.getDate() - 7) + const startOfLastMonth = new Date(now) + startOfLastMonth.setMonth(startOfLastMonth.getMonth() - 1) + + const groups = { + lastHour: [], + today: [], + yesterday: [], + lastWeek: [], + lastMonth: [], + older: [], + } + for (const envelope of envelopes) { + const date = new Date(Object.values(envelope)[0].dateInt * 1000) + if (date >= oneHourAgo) { + groups.lastHour.push(envelope) + } else if (date >= startOfToday) { + groups.today.push(envelope) + } else if (date >= startOfYesterday && date < startOfToday) { + groups.yesterday.push(envelope) + } else if (date >= startOfLastWeek) { + groups.lastWeek.push(envelope) + } else if (date >= startOfLastMonth) { + groups.lastMonth.push(envelope) + } else { + groups.older.push(envelope) + } + } + + return Object.fromEntries( + Object.entries(groups).filter(([_, list]) => list.length > 0), + ) + }, async fetchEnvelopes() { const existingEnvelopes = this.mainStore.getEnvelopes(this.mailbox.databaseId, this.query) if (!existingEnvelopes.length) { diff --git a/src/service/MessageService.js b/src/service/MessageService.js index 1eed0cb465..004c719b08 100644 --- a/src/service/MessageService.js +++ b/src/service/MessageService.js @@ -61,7 +61,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) }) @@ -94,7 +94,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 83059563be..4335fb4d49 100644 --- a/src/store/mainStore/actions.js +++ b/src/store/mainStore/actions.js @@ -656,7 +656,7 @@ export default function mainStoreActions() { // Only commit if not undefined (not found) if (envelope) { this.addEnvelopesMutation({ - envelopes: [envelope], + envelopes: [[envelope]], }) } @@ -2032,13 +2032,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, { ...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, { ...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 } @@ -2051,7 +2058,7 @@ export default function mainStoreActions() { Vue.set( mailbox.envelopeLists, listId, - uniq(orderByDateInt(existing.concat([envelope.databaseId]))), + uniq(orderByDateInt(existing.concat([envelopelist[0].databaseId]))), ) }) }) @@ -2116,7 +2123,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 2569eed6d532fc8a589807733b6c721aaf9a4eeb Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 28 Aug 2025 14:52:34 +0100 Subject: [PATCH 2/6] 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 23ab7cf7c9..1061e6ed3c 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -1273,14 +1273,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)); } @@ -1343,8 +1349,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 20dece6508..5b90e94fa7 100644 --- a/src/components/Envelope.vue +++ b/src/components/Envelope.vue @@ -1160,7 +1160,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 52980cdf1ea105fd5c0bee232ac623fc1cfdcb8e Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 2 Sep 2025 16:41:10 +0200 Subject: [PATCH 3/6] fixup! feat: store envelopes as list of threads Signed-off-by: Hamza --- lib/Db/MessageMapper.php | 123 +++++++++++++++++++++---------- src/components/Envelope.vue | 4 + src/components/EnvelopeList.vue | 4 +- src/components/MailboxThread.vue | 5 +- src/store/mainStore/actions.js | 4 +- 5 files changed, 93 insertions(+), 47 deletions(-) diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index 1061e6ed3c..d3e5a4b502 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -1265,33 +1265,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; } /** @@ -1334,33 +1355,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; } /** diff --git a/src/components/Envelope.vue b/src/components/Envelope.vue index 5b90e94fa7..42823cb046 100644 --- a/src/components/Envelope.vue +++ b/src/components/Envelope.vue @@ -566,6 +566,7 @@ export default { type: Boolean, default: true, }, + threadList: { type: Object, required: true, @@ -613,16 +614,19 @@ export default { quickActionLoading: false, } }, + 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] }, diff --git a/src/components/EnvelopeList.vue b/src/components/EnvelopeList.vue index d1bf13f38e..6150b2b368 100644 --- a/src/components/EnvelopeList.vue +++ b/src/components/EnvelopeList.vue @@ -360,8 +360,8 @@ 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]) + 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 = newEnvs.map((env) => env.databaseId) this.selection = this.selection.filter((id) => newIds.includes(id)) diff --git a/src/components/MailboxThread.vue b/src/components/MailboxThread.vue index 52975aeeea..3d2c509240 100644 --- a/src/components/MailboxThread.vue +++ b/src/components/MailboxThread.vue @@ -510,10 +510,9 @@ export default { } } - return Object.fromEntries( - Object.entries(groups).filter(([_, list]) => list.length > 0), - ) + return Object.fromEntries(Object.entries(groups).filter(([_, list]) => list.length > 0)) }, + async fetchEnvelopes() { const existingEnvelopes = this.mainStore.getEnvelopes(this.mailbox.databaseId, this.query) if (!existingEnvelopes.length) { diff --git a/src/store/mainStore/actions.js b/src/store/mainStore/actions.js index 4335fb4d49..c36d8be34c 100644 --- a/src/store/mainStore/actions.js +++ b/src/store/mainStore/actions.js @@ -2124,8 +2124,8 @@ export default function mainStoreActions() { }, removeEnvelopeMutation({ id }) { const envelope = Object.values(this.envelopes) - .flatMap(envList => Object.values(envList)) - .find(env => env.databaseId === id) + .flatMap((envList) => Object.values(envList)) + .find((env) => env.databaseId === id) if (!envelope) { console.warn('envelope ' + id + ' is unknown, can\'t remove it') return From 202df890909c5397473f5cf1fda77732e8e1a61f Mon Sep 17 00:00:00 2001 From: Hamza Date: Fri, 28 Nov 2025 18:27:32 +0100 Subject: [PATCH 4/6] fixup! feat: store envelopes as list of threads Signed-off-by: Hamza --- src/components/Envelope.vue | 12 +------- src/components/EnvelopeList.vue | 52 +++++++++++++++----------------- src/components/MailboxThread.vue | 40 ++---------------------- src/service/MessageService.js | 4 +-- src/store/mainStore/actions.js | 27 ++++++----------- 5 files changed, 39 insertions(+), 96 deletions(-) diff --git a/src/components/Envelope.vue b/src/components/Envelope.vue index 42823cb046..b9d16502f0 100644 --- a/src/components/Envelope.vue +++ b/src/components/Envelope.vue @@ -567,7 +567,7 @@ export default { default: true, }, - threadList: { + data: { type: Object, required: true, }, @@ -615,22 +615,12 @@ 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] - }, - isRTL() { return isRTL() }, diff --git a/src/components/EnvelopeList.vue b/src/components/EnvelopeList.vue index 6150b2b368..b74ba06021 100644 --- a/src/components/EnvelopeList.vue +++ b/src/components/EnvelopeList.vue @@ -125,15 +125,15 @@ @@ -285,7 +285,7 @@ export default { sortedEnvelops() { if (this.sortOrder === 'oldest') { return [...this.envelopes].sort((a, b) => { - return Object.values(a)[0].dateInt < Object.values(b)[0].dateInt ? -1 : 1 + return a.dateInt < b.dateInt ? -1 : 1 }) } return [...this.envelopes] @@ -297,18 +297,18 @@ export default { }, isAtLeastOneSelectedRead() { - return this.selectedEnvelopes.some((env) => Object.values(env)[0].flags.seen === true) + return this.selectedEnvelopes.some((env) => env.flags.seen === true) }, isAtLeastOneSelectedUnread() { - return this.selectedEnvelopes.some((env) => Object.values(env)[0].flags.seen === false) + return this.selectedEnvelopes.some((env) => env.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(Object.keys(env)[0].databaseId) + .getEnvelopeTags(env.databaseId) .some((tag) => tag.imapLabel === '$label1') }) }, @@ -317,7 +317,7 @@ export default { // returns true if at least one selected message is not marked as important return this.selectedEnvelopes.some((env) => { return !this.mainStore - .getEnvelopeTags(Object.keys(env)[0].databaseId) + .getEnvelopeTags(env.databaseId) .some((tag) => tag.imapLabel === '$label1') }) }, @@ -325,31 +325,31 @@ export default { isAtLeastOneSelectedJunk() { // returns true if at least one selected message is marked as junk return this.selectedEnvelopes.some((env) => { - return Object.values(env)[0].flags.$junk + return env.flags.$junk }) }, isAtLeastOneSelectedNotJunk() { // returns true if at least one selected message is not marked as not junk return this.selectedEnvelopes.some((env) => { - return !Object.values(env)[0].flags.$junk + return !env.flags.$junk }) }, isAtLeastOneSelectedFavorite() { - return this.selectedEnvelopes.some((env) => Object.values(env)[0].flags.flagged) + return this.selectedEnvelopes.some((env) => env.flags.flagged) }, isAtLeastOneSelectedUnFavorite() { - return this.selectedEnvelopes.some((env) => !Object.values(env)[0].flags.flagged) + return this.selectedEnvelopes.some((env) => !env.flags.flagged) }, selectedEnvelopes() { - return this.sortedEnvelops.filter((env) => this.selection.includes(Object.keys(env)[0].databaseId)) + return this.sortedEnvelops.filter((env) => this.selection.includes(env.databaseId)) }, hasMultipleAccounts() { - const mailboxIds = this.sortedEnvelops.map((envelope) => Object.values(envelope)[0].mailboxId) + const mailboxIds = this.sortedEnvelops.map((envelope) => envelope.mailboxId) return Array.from(new Set(mailboxIds)).length > 1 }, @@ -360,12 +360,10 @@ 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 = newEnvs.map((env) => env.databaseId) + const newIds = newVal.map((env) => env.databaseId) this.selection = this.selection.filter((id) => newIds.includes(id)) - differenceWith((a, b) => a.databaseId === b.databaseId, oldEnvs, newEnvs) + differenceWith((a, b) => a.databaseId === b.databaseId, oldVal, newVal) .forEach((env) => { env.flags.selected = false }) @@ -486,8 +484,8 @@ export default { // one of threads is selected if (indexSelectedEnvelope !== -1) { - const lastSelectedEnvelope = this.selectedEnvelopes[this.selectedEnvelopes.length - 1][0] - const diff = this.sortedEnvelops.filter((envelope) => envelope[0] === lastSelectedEnvelope || !this.selectedEnvelopes.includes(envelope)) + const lastSelectedEnvelope = this.selectedEnvelopes[this.selectedEnvelopes.length - 1] + const diff = this.sortedEnvelops.filter((envelope) => envelope === lastSelectedEnvelope || !this.selectedEnvelopes.includes(envelope)) const lastIndex = diff.indexOf(lastSelectedEnvelope) nextEnvelopeToNavigate = diff[lastIndex === 0 ? 1 : lastIndex - 1] } @@ -536,13 +534,13 @@ export default { }, setEnvelopeSelected(envelope, selected) { - const alreadySelected = this.selection.includes(envelope[0].databaseId) + const alreadySelected = this.selection.includes(envelope.databaseId) if (selected && !alreadySelected) { - envelope[0].flags.selected = true - this.selection.push(envelope[0].databaseId) + envelope.flags.selected = true + this.selection.push(envelope.databaseId) } else if (!selected && alreadySelected) { - envelope[0].flags.selected = false - this.selection.splice(this.selection.indexOf(envelope[0].databaseId), 1) + envelope.flags.selected = false + this.selection.splice(this.selection.indexOf(envelope.databaseId), 1) } }, @@ -607,7 +605,7 @@ export default { */ findSelectionIndex(databaseId) { for (const [index, envelope] of this.sortedEnvelops.entries()) { - if (envelope[0].databaseId === databaseId) { + if (envelope.databaseId === databaseId) { return index } } diff --git a/src/components/MailboxThread.vue b/src/components/MailboxThread.vue index 3d2c509240..1a74334e17 100644 --- a/src/components/MailboxThread.vue +++ b/src/components/MailboxThread.vue @@ -473,44 +473,8 @@ export default { }, methods: { - groupEnvelopesByDate(envelopes, syncTimestamp) { - const now = new Date(syncTimestamp) - const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const startOfYesterday = new Date(startOfToday) - startOfYesterday.setDate(startOfYesterday.getDate() - 1) - const startOfLastWeek = new Date(now) - startOfLastWeek.setDate(startOfLastWeek.getDate() - 7) - const startOfLastMonth = new Date(now) - startOfLastMonth.setMonth(startOfLastMonth.getMonth() - 1) - - const groups = { - lastHour: [], - today: [], - yesterday: [], - lastWeek: [], - lastMonth: [], - older: [], - } - - for (const envelope of envelopes) { - const date = new Date(Object.values(envelope)[0].dateInt * 1000) - if (date >= oneHourAgo) { - groups.lastHour.push(envelope) - } else if (date >= startOfToday) { - groups.today.push(envelope) - } else if (date >= startOfYesterday && date < startOfToday) { - groups.yesterday.push(envelope) - } else if (date >= startOfLastWeek) { - groups.lastWeek.push(envelope) - } else if (date >= startOfLastMonth) { - groups.lastMonth.push(envelope) - } else { - groups.older.push(envelope) - } - } - - return Object.fromEntries(Object.entries(groups).filter(([_, list]) => list.length > 0)) + getGroupedEnvelopes(envelopes, syncTimestamp) { + return groupEnvelopesByDate(envelopes, syncTimestamp, this.sortOrder) }, async fetchEnvelopes() { diff --git a/src/service/MessageService.js b/src/service/MessageService.js index 004c719b08..1eed0cb465 100644 --- a/src/service/MessageService.js +++ b/src/service/MessageService.js @@ -61,7 +61,7 @@ export function fetchEnvelopes(accountId, mailboxId, query, cursor, limit, sort, params, }) .then((resp) => resp.data) - .then((envelopes) => envelopes.map((messageList) => messageList.map(amendEnvelopeWithIds(accountId)))) + .then((envelopes) => envelopes.map(amendEnvelopeWithIds(accountId))) .catch((error) => { throw convertAxiosError(error) }) @@ -94,7 +94,7 @@ export async function syncEnvelopes(accountId, id, ids, lastMessageTimestamp, qu const amend = amendEnvelopeWithIds(accountId) return { - newMessages: response.data.newMessages.map((messageList) => messageList.map(amend)), + newMessages: response.data.newMessages.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 c36d8be34c..83059563be 100644 --- a/src/store/mainStore/actions.js +++ b/src/store/mainStore/actions.js @@ -656,7 +656,7 @@ export default function mainStoreActions() { // Only commit if not undefined (not found) if (envelope) { this.addEnvelopesMutation({ - envelopes: [[envelope]], + envelopes: [envelope], }) } @@ -2032,20 +2032,13 @@ export default function mainStoreActions() { const listId = normalizedEnvelopeListId(query) const orderByDateInt = orderBy(idToDateInt, this.preferences['sort-order'] === 'newest' ? 'desc' : 'asc') - envelopes.forEach((envelopelist) => { - const mailbox = this.mailboxes[envelopelist[0].mailboxId] + envelopes.forEach((envelope) => { + const mailbox = this.mailboxes[envelope.mailboxId] const existing = mailbox.envelopeLists[listId] || [] - envelopelist.forEach((envelope) => { - this.normalizeTags(envelope) - }) - const envelopeListIndexed = [] - envelopelist.forEach((envelope) => { - envelopeListIndexed[envelope.databaseId] = envelope - }) - Vue.set(this.envelopes, envelopelist[0].databaseId, { ...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])))) + this.normalizeTags(envelope) + Vue.set(this.envelopes, envelope.databaseId, { ...this.envelopes[envelope.databaseId] || {}, ...envelope }) + Vue.set(envelope, 'accountId', mailbox.accountId) + Vue.set(mailbox.envelopeLists, listId, uniq(orderByDateInt(this.appendOrReplaceEnvelopeId(existing, envelope)))) if (!addToUnifiedMailboxes) { return } @@ -2058,7 +2051,7 @@ export default function mainStoreActions() { Vue.set( mailbox.envelopeLists, listId, - uniq(orderByDateInt(existing.concat([envelopelist[0].databaseId]))), + uniq(orderByDateInt(existing.concat([envelope.databaseId]))), ) }) }) @@ -2123,9 +2116,7 @@ export default function mainStoreActions() { Vue.set(envelope, 'tags', envelope.tags.filter((id) => id !== tagId)) }, removeEnvelopeMutation({ id }) { - const envelope = Object.values(this.envelopes) - .flatMap((envList) => Object.values(envList)) - .find((env) => env.databaseId === id) + const envelope = this.envelopes[id] if (!envelope) { console.warn('envelope ' + id + ' is unknown, can\'t remove it') return From ee4ce1fe33ace5601409074f9da5d34b3e77b7f1 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 1 Dec 2025 12:53:54 +0100 Subject: [PATCH 5/6] fixup! feat: store envelopes as list of threads Signed-off-by: Hamza --- src/components/Mailbox.vue | 1 + src/service/MessageService.js | 4 +- src/store/mainStore.js | 2 + src/store/mainStore/actions.js | 148 ++++++++++++++++++++------------- 4 files changed, 95 insertions(+), 60 deletions(-) diff --git a/src/components/Mailbox.vue b/src/components/Mailbox.vue index e9afe18962..2da147570a 100644 --- a/src/components/Mailbox.vue +++ b/src/components/Mailbox.vue @@ -285,6 +285,7 @@ export default { } }, default: (error) => { + console.error(error) logger.error(`Could not fetch envelopes of folder ${this.mailbox.databaseId} (${this.searchQuery})`, { error }) this.loadingEnvelopes = false this.error = error diff --git a/src/service/MessageService.js b/src/service/MessageService.js index 1eed0cb465..35df1ce503 100644 --- a/src/service/MessageService.js +++ b/src/service/MessageService.js @@ -61,7 +61,7 @@ export function fetchEnvelopes(accountId, mailboxId, query, cursor, limit, sort, params, }) .then((resp) => resp.data) - .then((envelopes) => envelopes.map(amendEnvelopeWithIds(accountId))) + .then((data) => data.map((envelopes) => envelopes.map(amendEnvelopeWithIds(accountId)))) .catch((error) => { throw convertAxiosError(error) }) @@ -94,7 +94,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((envelopes) => envelopes.map(amend)), changedMessages: response.data.changedMessages.map(amend), vanishedMessages: response.data.vanishedMessages, stats: response.data.stats, diff --git a/src/store/mainStore.js b/src/store/mainStore.js index 8b6f03d49c..4acc015a9a 100644 --- a/src/store/mainStore.js +++ b/src/store/mainStore.js @@ -80,6 +80,8 @@ export default defineStore('main', { }, }, envelopes: {}, + threads: {}, + messageToThreadDictionnary: {}, messages: {}, newMessage: undefined, showMessageComposer: false, diff --git a/src/store/mainStore/actions.js b/src/store/mainStore/actions.js index 83059563be..888c22a4ad 100644 --- a/src/store/mainStore/actions.js +++ b/src/store/mainStore/actions.js @@ -683,17 +683,14 @@ export default function mainStoreActions() { view: this.getPreference('layout-message-view'), })), Promise.all.bind(Promise), - andThen(map(sliceToPage)), ) const fetchUnifiedEnvelopes = pipe( findIndividualMailboxes(this.getMailboxes, mailbox.specialRole), fetchIndividualLists, - andThen(combineEnvelopeLists(this.getPreference('sort-order'))), - andThen(sliceToPage), - andThen(tap((envelopes) => this.addEnvelopesMutation({ - envelopes, + andThen(tap((threadlist) => threadlist.forEach((threads) => this.addThreadsMutation({ + threads, query, - }))), + })))), ) return fetchUnifiedEnvelopes(this.getAccounts) @@ -701,9 +698,9 @@ export default function mainStoreActions() { return pipe( fetchEnvelopes, - andThen(tap((envelopes) => this.addEnvelopesMutation({ + andThen(tap((threads) => this.addThreadsMutation({ query, - envelopes, + threads, addToUnifiedMailboxes, }))), )(mailbox.accountId, mailboxId, query, undefined, PAGE_SIZE, this.getPreference('sort-order'), this.getPreference('layout-message-view'), includeCacheBuster ? mailbox.cacheBuster : undefined) @@ -797,7 +794,7 @@ export default function mainStoreActions() { const envelopes = nextLocalUnifiedEnvelopes(this.getAccounts) logger.debug('next unified page can be built locally and consists of ' + envelopes.length + ' envelopes', { addToUnifiedMailboxes }) - this.addEnvelopesMutation({ + this.addThreadsMutation({ query, envelopes, addToUnifiedMailboxes, @@ -820,25 +817,18 @@ export default function mainStoreActions() { return Promise.reject(new Error('Cannot find last envelope. Required for the mailbox cursor')) } - return fetchEnvelopes( - mailbox.accountId, - mailboxId, - query, - lastEnvelope.dateInt, - quantity, - this.getPreference('sort-order'), - this.getPreference('layout-message-view'), - ).then((envelopes) => { - logger.debug(`fetched ${envelopes.length} messages for mailbox ${mailboxId}`, { - envelopes, + return fetchEnvelopes(mailbox.accountId, mailboxId, query, lastEnvelope.dateInt, quantity, this.getPreference('sort-order'), this.getPreference('layout-message-view') + ).then((threads) => { + logger.debug(`fetched ${threads.length} messages for mailbox ${mailboxId}`, { + threads, addToUnifiedMailboxes, }) - this.addEnvelopesMutation({ + this.addThreadsMutation({ query, - envelopes, + threads, addToUnifiedMailboxes, }) - return envelopes + return threads }) }) }, @@ -891,16 +881,16 @@ export default function mainStoreActions() { const unifiedMailbox = this.getUnifiedMailbox(mailbox.specialRole) - this.addEnvelopesMutation({ - envelopes: syncData.newMessages, + this.addThreadsMutation({ + threads: syncData.newMessages, query, }) - syncData.newMessages.forEach((envelope) => { + syncData.newMessages.forEach((thread) => { if (unifiedMailbox) { - this.updateEnvelopeMutation({ + thread.forEach((envelope) => this.updateEnvelopeMutation({ envelope, - }) + })) } }) syncData.changedMessages.forEach((envelope) => { @@ -1837,7 +1827,7 @@ export default function mainStoreActions() { if (this.getPreference('layout-message-view') === 'singleton') { existing.push(envelope.databaseId) } else { - const index = existing.findIndex((id) => this.envelopes[id].threadRootId === envelope.threadRootId) + const index = existing.findIndex((id) => this.getEnvelope(id).threadRootId === envelope.threadRootId) if (index === -1) { existing.push(envelope.databaseId) } else { @@ -2018,6 +2008,37 @@ export default function mainStoreActions() { Vue.set(this.newMessage, 'type', 'outbox') Vue.set(this.newMessage.data, 'id', message.id) }, + addThreadsMutation({ + query, + threads, + addToUnifiedMailboxes = true, + }) { + if (threads.length === 0) { + return + } + const isThreaded = this.getPreference('layout-message-view') === 'threaded' + if (isThreaded) { + threads.forEach((thread) => { + const threadRootId = thread[0].threadRootId + const messages = {} + thread.forEach((message) => { + messages[message.databaseId] = message + this.messageToThreadDictionnary[message.databaseId] = threadRootId + }) + this.threads[threadRootId] = messages + }) + } else { + threads.forEach((thread) => { + this.threads[thread[0].databaseId] = { [thread[0].databaseId]: thread[0] } + }) + } + const envelopes = threads.flat() + this.addEnvelopesMutation({ + query, + envelopes, + addToUnifiedMailboxes, + }) + }, addEnvelopesMutation({ query, envelopes, @@ -2026,8 +2047,7 @@ export default function mainStoreActions() { if (envelopes.length === 0) { return } - - const idToDateInt = (id) => this.envelopes[id].dateInt + const idToDateInt = (id) => this.getEnvelope(id).dateInt const listId = normalizedEnvelopeListId(query) const orderByDateInt = orderBy(idToDateInt, this.preferences['sort-order'] === 'newest' ? 'desc' : 'asc') @@ -2036,7 +2056,6 @@ export default function mainStoreActions() { const mailbox = this.mailboxes[envelope.mailboxId] const existing = mailbox.envelopeLists[listId] || [] this.normalizeTags(envelope) - Vue.set(this.envelopes, envelope.databaseId, { ...this.envelopes[envelope.databaseId] || {}, ...envelope }) Vue.set(envelope, 'accountId', mailbox.accountId) Vue.set(mailbox.envelopeLists, listId, uniq(orderByDateInt(this.appendOrReplaceEnvelopeId(existing, envelope)))) if (!addToUnifiedMailboxes) { @@ -2057,7 +2076,7 @@ export default function mainStoreActions() { }) }, updateEnvelopeMutation({ envelope }) { - const existing = this.envelopes[envelope.databaseId] + const existing = this.getEnvelope(envelope.databaseId) if (!existing) { return } @@ -2115,8 +2134,11 @@ export default function mainStoreActions() { }) { Vue.set(envelope, 'tags', envelope.tags.filter((id) => id !== tagId)) }, + removeThreadMutation({ id }) { + Vue.delete(this.threads, id) + }, removeEnvelopeMutation({ id }) { - const envelope = this.envelopes[id] + const envelope = this.getEnvelope(id) if (!envelope) { console.warn('envelope ' + id + ' is unknown, can\'t remove it') return @@ -2139,6 +2161,19 @@ export default function mainStoreActions() { Vue.set(mailbox, 'unread', mailbox.unread - 1) } + // Remove envelope from its thread + const threadRootId = envelope.threadRootId + if (threadRootId && this.threads[threadRootId]) { + const threadEnvelopes = this.threads[threadRootId] + const threadIdx = threadEnvelopes.indexOf(id) + if (threadIdx >= 0) { + threadEnvelopes.splice(threadIdx, 1) + if (threadEnvelopes.length === 0) { + Vue.delete(this.threads, threadRootId) + } + } + } + this.accountsUnmapped[UNIFIED_ACCOUNT_ID].mailboxes .map((mailboxId) => this.mailboxes[mailboxId]) .filter((mb) => mb.specialRole && mb.specialRole === mailbox.specialRole) @@ -2163,18 +2198,6 @@ export default function mainStoreActions() { list.splice(idx, 1) } }) - - // Delete references from other threads - for (const [key, env] of Object.entries(this.envelopes)) { - if (!env.thread) { - continue - } - - const thread = env.thread.filter((threadId) => threadId !== id) - Vue.set(this.envelopes[key], 'thread', thread) - } - - Vue.delete(this.envelopes, id) }, removeEnvelopesMutation({ id }) { Vue.set(this.mailboxes[id], 'envelopeLists', []) @@ -2220,16 +2243,15 @@ export default function mainStoreActions() { id, thread, }) { + const messages = {} // Store the envelopes, merge into any existing object if one exists thread.forEach((e) => { this.normalizeTags(e) const mailbox = this.mailboxes[e.mailboxId] Vue.set(e, 'accountId', mailbox.accountId) - Vue.set(this.envelopes, e.databaseId, { ...this.envelopes[e.databaseId] || {}, ...e }) + messages[e.databaseId] = e }) - - // Store the references - Vue.set(this.envelopes[id], 'thread', thread.map((e) => e.databaseId)) + Vue.set(this.threads, thread[0].threadRootId, messages) }, removeMessageMutation({ id }) { Vue.delete(this.messages, id) @@ -2363,6 +2385,7 @@ export default function mainStoreActions() { addQuickActionLocally(quickAction) { this.quickActions.push(quickAction) }, + getPreference(key, def) { return defaultTo(def, this.preferences[key]) }, @@ -2402,29 +2425,38 @@ export default function mainStoreActions() { .filter((mailbox) => mailbox.specialRole === specialRole)) }, getEnvelope(id) { - return this.envelopes[id] + const isThreaded = this.getPreference('layout-message-view') === 'threaded' + let envelope + if (isThreaded) { + const threadRootId = this.messageToThreadDictionnary[id] + envelope = this.threads[threadRootId][id] + } else { + envelope = this.threads[id][id] + } + return envelope }, getEnvelopes(mailboxId, query) { const list = this.getMailbox(mailboxId).envelopeLists[normalizedEnvelopeListId(query)] || [] - return list.map((msgId) => this.envelopes[msgId]) + return list.map((msgId) => this.getEnvelope(msgId)) }, getEnvelopesByThreadRootId(accountId, threadRootId) { return sortBy( prop('dateInt'), - Object.values(this.envelopes).filter((envelope) => envelope.accountId === accountId && envelope.threadRootId === threadRootId), + Object.values(this.threads[threadRootId]), ) }, getMessage(id) { return this.messages[id] }, getEnvelopeThread(id) { - console.debug('get thread for envelope', id, this.envelopes[id], this.envelopes) - const thread = this.envelopes[id]?.thread ?? [] - const envelopes = thread.map((id) => this.envelopes[id]) - return sortBy(prop('dateInt'), envelopes) + const envelope = this.getEnvelope[id] + console.debug('get thread for envelope', id, envelope, this.threads) + const isThreaded = this.getPreference('layout-message-view') === 'threaded' + const envelopes = isThreaded ? this.threads[envelope.threadRootId] : this.threads[id] + return sortBy(prop('dateInt'), Object.values(envelopes)) }, getEnvelopeTags(id) { - const tags = this.envelopes[id]?.tags ?? [] + const tags = this.getEnvelope(id)?.tags ?? [] return tags.map((tagId) => this.tags[tagId]) }, getTag(id) { From 70b3b18c5bc9c1b42a8e5a4db74555d684b7fd19 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 12 Jan 2026 20:18:56 +0100 Subject: [PATCH 6/6] fixup! feat: store envelopes as list of threads Signed-off-by: Hamza --- src/store/mainStore/actions.js | 38 ++++++++++++++++++++-------------- src/util/groupedEnvelopes.js | 1 + 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/store/mainStore/actions.js b/src/store/mainStore/actions.js index 888c22a4ad..d92c4428e3 100644 --- a/src/store/mainStore/actions.js +++ b/src/store/mainStore/actions.js @@ -683,6 +683,7 @@ export default function mainStoreActions() { view: this.getPreference('layout-message-view'), })), Promise.all.bind(Promise), + andThen(map(sliceToPage)), ) const fetchUnifiedEnvelopes = pipe( findIndividualMailboxes(this.getMailboxes, mailbox.specialRole), @@ -817,8 +818,7 @@ export default function mainStoreActions() { return Promise.reject(new Error('Cannot find last envelope. Required for the mailbox cursor')) } - return fetchEnvelopes(mailbox.accountId, mailboxId, query, lastEnvelope.dateInt, quantity, this.getPreference('sort-order'), this.getPreference('layout-message-view') - ).then((threads) => { + return fetchEnvelopes(mailbox.accountId, mailboxId, query, lastEnvelope.dateInt, quantity, this.getPreference('sort-order'), this.getPreference('layout-message-view')).then((threads) => { logger.debug(`fetched ${threads.length} messages for mailbox ${mailboxId}`, { threads, addToUnifiedMailboxes, @@ -2051,8 +2051,14 @@ export default function mainStoreActions() { const listId = normalizedEnvelopeListId(query) const orderByDateInt = orderBy(idToDateInt, this.preferences['sort-order'] === 'newest' ? 'desc' : 'asc') - envelopes.forEach((envelope) => { + if (!Object.keys(this.threads).includes(envelope.threadRootId)) { + this.threads[envelope.threadRootId] = {} + } + if (!Object.keys(this.threads[envelope.threadRootId]).includes(String(envelope.databaseId))) { + this.threads[envelope.threadRootId][envelope.databaseId] = envelope + this.messageToThreadDictionnary[envelope.databaseId] = envelope.threadRootId + } const mailbox = this.mailboxes[envelope.mailboxId] const existing = mailbox.envelopeLists[listId] || [] this.normalizeTags(envelope) @@ -2164,16 +2170,13 @@ export default function mainStoreActions() { // Remove envelope from its thread const threadRootId = envelope.threadRootId if (threadRootId && this.threads[threadRootId]) { - const threadEnvelopes = this.threads[threadRootId] - const threadIdx = threadEnvelopes.indexOf(id) - if (threadIdx >= 0) { - threadEnvelopes.splice(threadIdx, 1) - if (threadEnvelopes.length === 0) { - Vue.delete(this.threads, threadRootId) - } + const thread = this.threads[threadRootId] + Vue.delete(thread, id) + Vue.delete(this.messageToThreadDictionnary, id) + if (Object.keys(this.threads[threadRootId]).length === 0) { + Vue.delete(this.threads, threadRootId) } } - this.accountsUnmapped[UNIFIED_ACCOUNT_ID].mailboxes .map((mailboxId) => this.mailboxes[mailboxId]) .filter((mb) => mb.specialRole && mb.specialRole === mailbox.specialRole) @@ -2385,7 +2388,6 @@ export default function mainStoreActions() { addQuickActionLocally(quickAction) { this.quickActions.push(quickAction) }, - getPreference(key, def) { return defaultTo(def, this.preferences[key]) }, @@ -2437,7 +2439,7 @@ export default function mainStoreActions() { }, getEnvelopes(mailboxId, query) { const list = this.getMailbox(mailboxId).envelopeLists[normalizedEnvelopeListId(query)] || [] - return list.map((msgId) => this.getEnvelope(msgId)) + return list.map((msgId) => this.getEnvelope(msgId)).filter((message) => message) }, getEnvelopesByThreadRootId(accountId, threadRootId) { return sortBy( @@ -2450,13 +2452,19 @@ export default function mainStoreActions() { }, getEnvelopeThread(id) { const envelope = this.getEnvelope[id] - console.debug('get thread for envelope', id, envelope, this.threads) const isThreaded = this.getPreference('layout-message-view') === 'threaded' const envelopes = isThreaded ? this.threads[envelope.threadRootId] : this.threads[id] return sortBy(prop('dateInt'), Object.values(envelopes)) }, getEnvelopeTags(id) { - const tags = this.getEnvelope(id)?.tags ?? [] + const envelope = this.getEnvelope(id) + if (!envelope) { + return [] + } + if (!Array.isArray(envelope.tags)) { + this.normalizeTags(envelope) + } + const tags = envelope.tags return tags.map((tagId) => this.tags[tagId]) }, getTag(id) { diff --git a/src/util/groupedEnvelopes.js b/src/util/groupedEnvelopes.js index b8aecfeb58..8e7db8a8d2 100644 --- a/src/util/groupedEnvelopes.js +++ b/src/util/groupedEnvelopes.js @@ -4,6 +4,7 @@ */ export function groupEnvelopesByDate(envelopes, syncTimestamp, sortOrder = 'newest') { + console.log('asba',envelopes) const now = new Date(syncTimestamp) const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000)