From 80dadfbbd42d5188b69395e520397dd11e0401c4 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Mon, 7 Sep 2026 22:34:47 +0200 Subject: [PATCH] fix: open search hits from shared folders in the right account #923 --- components/mail/mail-app.tsx | 14 +++-- .../email-store-search-hit-account.test.ts | 53 +++++++++++++++++++ stores/email-store.ts | 21 ++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 stores/__tests__/email-store-search-hit-account.test.ts diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 56eeb12d7..cc5cae74c 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -31,7 +31,7 @@ import { usePolicyStore } from "@/stores/policy-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { connectedAccountsGrew } from "@/lib/unified-mailbox"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; -import { useEmailStore, buildUnifiedAccountClients, ArchiveMailboxNotFoundError, findArchiveMailbox } from "@/stores/email-store"; +import { useEmailStore, buildUnifiedAccountClients, ArchiveMailboxNotFoundError, findArchiveMailbox, resolveUnstampedEmailAccountId } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog"; import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster"; @@ -3289,11 +3289,15 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { ?? (viewingAccountId ? useAuthStore.getState().getClientForAccount(viewingAccountId) : undefined) ?? client; + // During an unscoped search the hit is the primary account's, not the + // selected shared folder's owner - see resolveUnstampedEmailAccountId. (#923) const accountId = listEmail?.sourceAccountId - ?? (() => { - const mailbox = viewMailboxes.find(mb => mb.id === selectedMailbox); - return mailbox?.isShared ? mailbox.accountId : undefined; - })(); + ?? resolveUnstampedEmailAccountId({ + mailboxes: viewMailboxes, + selectedMailbox, + searchActive: !!searchQuery || !isFilterEmpty(searchFilters), + searchMailboxId, + }); const fullEmail = await fetchClient.getEmail(email.id, accountId); if (fullEmail) { diff --git a/stores/__tests__/email-store-search-hit-account.test.ts b/stores/__tests__/email-store-search-hit-account.test.ts new file mode 100644 index 000000000..83b0aa101 --- /dev/null +++ b/stores/__tests__/email-store-search-hit-account.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { resolveUnstampedEmailAccountId } from '../email-store'; +import type { Mailbox } from '@/lib/jmap/types'; + +// #923: with an unscoped ("All folders") search active while a shared folder +// is selected, the hit belongs to the primary account. Deriving the owner +// from the selected shared folder made getEmail() look in the wrong account. + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', isShared: false }, + { id: 'owner-x:x-inbox', name: 'Shared Inbox', role: 'inbox', isShared: true, accountId: 'owner-x' }, +] as unknown as Mailbox[]; + +describe('resolveUnstampedEmailAccountId (#923)', () => { + it('uses the primary account for an unscoped search even with a shared folder selected', () => { + expect( + resolveUnstampedEmailAccountId({ + mailboxes, + selectedMailbox: 'owner-x:x-inbox', + searchActive: true, + searchMailboxId: '', + }), + ).toBeUndefined(); + }); + + it('keeps the shared owner when browsing the shared folder without a search', () => { + expect( + resolveUnstampedEmailAccountId({ + mailboxes, + selectedMailbox: 'owner-x:x-inbox', + searchActive: false, + searchMailboxId: '', + }), + ).toBe('owner-x'); + }); + + it('keeps the shared owner for a search scoped to the shared folder', () => { + expect( + resolveUnstampedEmailAccountId({ + mailboxes, + selectedMailbox: 'owner-x:x-inbox', + searchActive: true, + searchMailboxId: 'owner-x:x-inbox', + }), + ).toBe('owner-x'); + }); + + it('is undefined for an own folder', () => { + expect( + resolveUnstampedEmailAccountId({ mailboxes, selectedMailbox: 'inbox', searchActive: false, searchMailboxId: '' }), + ).toBeUndefined(); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 1dada2112..3b150fa07 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -601,6 +601,27 @@ export function findArchiveMailbox( ); } +/** + * JMAP accountId for opening an email that carries no source stamps. + * + * Normally the selected folder decides: a shared/group folder's owner, else + * the account's own (undefined). During an unscoped ("All folders") search + * the hits come from the primary account even while a shared folder is + * selected, so deriving the owner from that folder asks the wrong account and + * `getEmail` returns nothing. In that case the caller's own account is used + * (searching the shared owners too is a separate issue). (#923) + */ +export function resolveUnstampedEmailAccountId(opts: { + mailboxes: Mailbox[]; + selectedMailbox: string | null | undefined; + searchActive: boolean; + searchMailboxId: string; +}): string | undefined { + if (opts.searchActive && opts.searchMailboxId === '') return undefined; + const mailbox = opts.mailboxes.find(mb => mb.id === opts.selectedMailbox); + return mailbox?.isShared ? mailbox.accountId : undefined; +} + function resolveActionMailboxes(): Mailbox[] { const state = useEmailStore.getState(); if (state.viewingAccountId) {