diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 5d8dd0279..c7fa32100 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -32,6 +32,7 @@ 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, resolveUnstampedEmailAccountId } from "@/stores/email-store"; +import { groupSearchScopeFolders } from "@/lib/search-scope-folders"; import { toast } from "@/stores/toast-store"; import { runBatchEmailAction } from "@/lib/email-action-toast"; import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog"; @@ -427,6 +428,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { [viewingAccountId, accountMailboxes, mailboxes], ); + // Search panel's Folder dropdown: own folders flat, shared/group folders + // under their owner account so a group's "Inbox" is not mistaken for the + // user's own (#1082). + const searchScopeFolders = useMemo(() => groupSearchScopeFolders(mailboxes), [mailboxes]); + // Load recent recipients (from the Sent folder) for compose autocomplete. // Runs once when the Sent mailbox is known; the store guards against reloads. useEffect(() => { @@ -3899,8 +3905,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { {/* Folder selector. Scopes the search only - it does not navigate the mail list, so it defaults to "All folders" regardless of which folder is open and keeps whatever - the user picked. The unified views already search - across every account's folders, so it is hidden there. */} + the user picked. "All folders" spans the own AND the + group/shared accounts' folders (#1082); the shared + ones are listed under their owner. The unified views + already search across every account's folders, so it + is hidden there. */} {!isUnifiedView && (
@@ -3915,11 +3924,20 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { className="w-full h-8 text-sm rounded-md border border-input bg-background px-3 text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1" > - {mailboxes.map((mb) => ( + {searchScopeFolders.own.map((mb) => ( ))} + {searchScopeFolders.shared.map((group) => ( + + {group.mailboxes.map((mb) => ( + + ))} + + ))}
)} diff --git a/lib/__tests__/search-scope-folders.test.ts b/lib/__tests__/search-scope-folders.test.ts new file mode 100644 index 000000000..d0f6f3574 --- /dev/null +++ b/lib/__tests__/search-scope-folders.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; +import { groupSearchScopeFolders } from '@/lib/search-scope-folders'; + +// The search panel's Folder dropdown lists the shared/group folders under +// their owner account instead of as bare names among the own ones (#1082). + +const mb = (over: Partial & { id: string }): Mailbox => + ({ name: over.id, isShared: false, ...over } as Mailbox); + +describe('groupSearchScopeFolders', () => { + it('keeps the own folders flat and groups shared folders by owner', () => { + const folders = [ + mb({ id: 'inbox', name: 'Inbox' }), + mb({ id: 'g1:inbox', name: 'Inbox', isShared: true, accountId: 'g1', accountName: 'chamados@server.tld' }), + mb({ id: 'sent', name: 'Sent' }), + mb({ id: 'g2:inbox', name: 'Inbox', isShared: true, accountId: 'g2', accountName: 'vendas@server.tld' }), + mb({ id: 'g1:done', name: 'Done', isShared: true, accountId: 'g1', accountName: 'chamados@server.tld' }), + ]; + + const { own, shared } = groupSearchScopeFolders(folders); + + expect(own.map((m) => m.id)).toEqual(['inbox', 'sent']); + expect(shared).toEqual([ + { ownerId: 'g1', label: 'chamados@server.tld', mailboxes: [folders[1], folders[4]] }, + { ownerId: 'g2', label: 'vendas@server.tld', mailboxes: [folders[3]] }, + ]); + }); + + it('falls back to the owner id when the shared folder carries no account name', () => { + const { shared } = groupSearchScopeFolders([ + mb({ id: 'g1:inbox', name: 'Inbox', isShared: true, accountId: 'g1' }), + ]); + + expect(shared).toEqual([{ ownerId: 'g1', label: 'g1', mailboxes: [expect.objectContaining({ id: 'g1:inbox' })] }]); + }); + + it('returns no groups for a login without shared folders', () => { + const { own, shared } = groupSearchScopeFolders([mb({ id: 'inbox' })]); + + expect(own).toHaveLength(1); + expect(shared).toEqual([]); + }); +}); diff --git a/lib/__tests__/unified-mailbox-search-accounts.test.ts b/lib/__tests__/unified-mailbox-search-accounts.test.ts new file mode 100644 index 000000000..b9c4dcb09 --- /dev/null +++ b/lib/__tests__/unified-mailbox-search-accounts.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { advancedSearchAcrossAccounts, searchAcrossAccounts, type UnifiedAccountClient } from '@/lib/unified-mailbox'; + +// The "All folders" search scope spans the login's own account and every +// group/shared account it reaches: one folder-less query per account, the +// pages merged newest first and every hit stamped with its source (#1082). + +const mb = (id: string, name: string, originalId?: string): Mailbox => + ({ id, name, role: 'inbox', unreadEmails: 0, totalEmails: 0, originalId } as unknown as Mailbox); + +function email(id: string, receivedAt: string, mailbox: string): Email { + return { + id, threadId: `t-${id}`, mailboxIds: { [mailbox]: true }, keywords: {}, + subject: id, receivedAt, from: [], to: [], preview: '', size: 1, hasAttachment: false, + } as Email; +} + +type Page = { emails: Email[]; total: number; hasMore: boolean }; +const page = (emails: Email[], total = emails.length, hasMore = false): Page => ({ emails, total, hasMore }); + +const makeAccount = ( + over: Partial & { accountId: string }, + client: Partial, +): UnifiedAccountClient => ({ + accountLabel: over.accountId, + mailboxes: [], + client: client as IJMAPClient, + clientAccountId: 'login', + jmapAccountId: over.accountId, + ...over, +}); + +const ownMailboxes = [mb('inbox', 'Inbox')]; +const groupMailboxes = [mb('group:g-inbox', 'Abrir Chamados', 'g-inbox')]; + +describe('searchAcrossAccounts', () => { + it('asks every account without a folder constraint and merges newest first', async () => { + const ownSearch = vi.fn(async () => page([email('own-1', '2026-09-10T00:00:00Z', 'inbox')])); + const groupSearch = vi.fn(async () => page([email('grp-1', '2026-09-12T00:00:00Z', 'group:g-inbox')], 7, true)); + const own = makeAccount({ accountId: 'login', jmapAccountId: 'me', mailboxes: ownMailboxes }, { searchEmails: ownSearch }); + const group = makeAccount( + { accountId: 'group', isShared: true, accountLabel: 'Team', mailboxes: groupMailboxes }, + { searchEmails: groupSearch }, + ); + + const result = await searchAcrossAccounts([own, group], 'acesso', 50, 0); + + expect(ownSearch).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0); + expect(groupSearch).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0); + expect(result.emails.map((e) => e.id)).toEqual(['grp-1', 'own-1']); + expect(result.total).toBe(8); + expect(result.hasMore).toBe(true); + expect(result.errors.size).toBe(0); + }); + + it('stamps each hit with its source account and folder so opening it routes to the owner', async () => { + const own = makeAccount({ accountId: 'login', jmapAccountId: 'me', mailboxes: ownMailboxes }, + { searchEmails: vi.fn(async () => page([email('own-1', '2026-09-10T00:00:00Z', 'inbox')])) }); + const group = makeAccount( + { accountId: 'group', isShared: true, accountLabel: 'Team', mailboxes: groupMailboxes }, + { searchEmails: vi.fn(async () => page([email('grp-1', '2026-09-12T00:00:00Z', 'group:g-inbox')])) }, + ); + + const { emails } = await searchAcrossAccounts([own, group], 'x', 50, 0); + const byId = Object.fromEntries(emails.map((e) => [e.id, e])); + + expect(byId['own-1']).toMatchObject({ + accountId: 'login', sourceClientAccountId: 'login', sourceAccountId: 'me', sourceFolder: 'Inbox', + }); + expect(byId['grp-1']).toMatchObject({ + accountId: 'group', accountLabel: 'Team', sourceClientAccountId: 'login', sourceAccountId: 'group', + sourceFolder: 'Abrir Chamados', + }); + }); + + it('keeps the other accounts\' hits and records the failure when one account fails', async () => { + const own = makeAccount({ accountId: 'login', jmapAccountId: 'me' }, + { searchEmails: vi.fn(async () => { throw new Error('boom'); }) }); + const group = makeAccount({ accountId: 'group', isShared: true }, + { searchEmails: vi.fn(async () => page([email('grp-1', '2026-09-12T00:00:00Z', 'group:g-inbox')])) }); + + const result = await searchAcrossAccounts([own, group], 'x', 50, 0); + + expect(result.emails.map((e) => e.id)).toEqual(['grp-1']); + expect(result.errors.get('login')).toBe('boom'); + }); +}); + +describe('advancedSearchAcrossAccounts', () => { + it('sends the same folder-less filter to every account at the requested page', async () => { + const filter = { operator: 'AND', conditions: [{ text: 'acesso*' }, { from: 'bob@example.com' }] }; + const ownSearch = vi.fn(async () => page([email('own-1', '2026-09-10T00:00:00Z', 'inbox')])); + const groupSearch = vi.fn(async () => page([email('grp-1', '2026-09-12T00:00:00Z', 'group:g-inbox')])); + const own = makeAccount({ accountId: 'login', jmapAccountId: 'me', mailboxes: ownMailboxes }, { advancedSearchEmails: ownSearch }); + const group = makeAccount({ accountId: 'group', isShared: true, mailboxes: groupMailboxes }, { advancedSearchEmails: groupSearch }); + + const result = await advancedSearchAcrossAccounts([own, group], filter, 25, 50); + + expect(ownSearch).toHaveBeenCalledWith(filter, undefined, 25, 50); + expect(groupSearch).toHaveBeenCalledWith(filter, 'group', 25, 50); + expect(result.emails.map((e) => e.id)).toEqual(['grp-1', 'own-1']); + expect(result.emails[0]).toMatchObject({ sourceAccountId: 'group', sourceFolder: 'Abrir Chamados' }); + }); +}); diff --git a/lib/search-scope-folders.ts b/lib/search-scope-folders.ts new file mode 100644 index 000000000..474971965 --- /dev/null +++ b/lib/search-scope-folders.ts @@ -0,0 +1,39 @@ +import type { Mailbox } from '@/lib/jmap/types'; + +export interface SearchScopeFolderGroup { + /** Owner JMAP account id; the React key. */ + ownerId: string; + /** Owner account label (`accountName`, else the owner id). */ + label: string; + mailboxes: Mailbox[]; +} + +/** + * Splits the sidebar's folder list into the login's own folders and the + * group/shared folders grouped by their owner account, for the search + * panel's Folder dropdown. + * + * Shared folders carry their owner's name in the sidebar, but the flat + * dropdown listed them by bare name, so a group's "Inbox" was + * indistinguishable from the user's own (#1082). + */ +export function groupSearchScopeFolders( + mailboxes: Mailbox[], +): { own: Mailbox[]; shared: SearchScopeFolderGroup[] } { + const own: Mailbox[] = []; + const byOwner = new Map(); + for (const mailbox of mailboxes) { + if (!mailbox.isShared) { + own.push(mailbox); + continue; + } + const ownerId = mailbox.accountId ?? ''; + let group = byOwner.get(ownerId); + if (!group) { + group = { ownerId, label: mailbox.accountName || ownerId, mailboxes: [] }; + byOwner.set(ownerId, group); + } + group.mailboxes.push(mailbox); + } + return { own, shared: Array.from(byOwner.values()) }; +} diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index f9279bd62..725d028e8 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -512,6 +512,73 @@ export async function fetchTagEmails( limit: number, position: number, order: SortLevel[] = [], +): Promise { + return fanOutAccountQuery( + accounts, + (account, jmapAccountId) => account.client.getEmails( + undefined, jmapAccountId, limit, position, keyword, true, undefined, order, + ), + compareEmails(order, { pinnedFirst: true }), + ); +} + +/** + * Text search over every folder of every given account: the "All folders" + * scope of the standard search panel. One `searchEmails` per account with no + * `inMailbox` constraint, merged newest first. + * + * The unscoped search used to ask only the login's own account, so mail in + * the group/shared accounts the same login reaches (whose folders sit right + * there in the sidebar) was silently missing from every "All folders" + * search - an ordinary "No results found" with no hint (#1082). + */ +export async function searchAcrossAccounts( + accounts: UnifiedAccountClient[], + query: string, + limit: number, + position: number, +): Promise { + return fanOutAccountQuery( + accounts, + (account, jmapAccountId) => account.client.searchEmails(query, undefined, jmapAccountId, limit, position), + newestFirst, + ); +} + +/** + * Like `searchAcrossAccounts`, with a JMAP advanced filter that was built + * WITHOUT an `inMailbox` clause (`buildJMAPFilter(query, filters, undefined)`). + */ +export async function advancedSearchAcrossAccounts( + accounts: UnifiedAccountClient[], + filter: Record, + limit: number, + position: number, +): Promise { + return fanOutAccountQuery( + accounts, + (account, jmapAccountId) => account.client.advancedSearchEmails(filter, jmapAccountId, limit, position), + newestFirst, + ); +} + +function newestFirst(a: Email, b: Email): number { + return new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime(); +} + +/** + * Runs one folder-less request per account (own entries against the client's + * primary account, shared entries against the owner's JMAP accountId), stamps + * every returned email with its source account and merges the pages under + * `sort`. A failing account lands in `errors` and does not hide the others. + */ +async function fanOutAccountQuery( + accounts: UnifiedAccountClient[], + run: ( + account: UnifiedAccountClient, + jmapAccountId: string | undefined, + ) => Promise<{ emails: Email[]; total: number; hasMore: boolean }>, + sort: (a: Email, b: Email) => number, ): Promise { const errors = new Map(); @@ -523,9 +590,7 @@ export async function fetchTagEmails( const promises = accounts.map(async (account): Promise => { const jmapAccountId = account.isShared ? account.accountId : undefined; try { - const result = await account.client.getEmails( - undefined, jmapAccountId, limit, position, keyword, true, undefined, order, - ); + const result = await run(account, jmapAccountId); return { account, result }; } catch (err) { errors.set(account.accountId, err instanceof Error ? err.message : String(err)); @@ -558,7 +623,7 @@ export async function fetchTagEmails( if (result.hasMore) anyHasMore = true; } - mergedEmails.sort(compareEmails(order, { pinnedFirst: true })); + mergedEmails.sort(sort); return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors }; } diff --git a/stores/__tests__/email-store-search-accounts.test.ts b/stores/__tests__/email-store-search-accounts.test.ts new file mode 100644 index 000000000..611fd6249 --- /dev/null +++ b/stores/__tests__/email-store-search-accounts.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useSettingsStore } from '../settings-store'; +import { useAuthStore } from '../auth-store'; +import { useMessageListTabsStore } from '../message-list-tabs-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Issue #1082: in the standard interface the search scoped to "All folders" +// (searchMailboxId "") issued a single Email/query against the login's own +// JMAP account. A group account delegated to the same login - its folders in +// the sidebar and in the Folder dropdown - was never queried, so its mail was +// missing from every unscoped search with nothing but "No results found" to +// show for it; picking the group's folder in the dropdown found the same mail. +// "All folders" now fans out over the own + group accounts like the tag +// views (#1038), and the hits carry their source stamps so opening and +// acting on them routes to the owning account. + +const ownInbox = { id: 'inbox', name: 'Inbox', role: 'inbox', isShared: false } as Mailbox; +const ownSent = { id: 'sent', name: 'Sent', role: 'sent', isShared: false } as Mailbox; +const groupInbox = { + id: 'group:inbox', originalId: 'inbox', name: 'Abrir Chamados', role: 'inbox', + isShared: true, accountId: 'group', accountName: 'chamados@server.tld', +} as Mailbox; + +function email(id: string, mailbox: string, receivedAt: string, subject: string, seen = true): Email { + return { + id, threadId: `thread-${id}`, mailboxIds: { [mailbox]: true }, + keywords: seen ? { $seen: true } : {}, + subject, preview: subject, receivedAt, + from: [{ email: 'sender@example.com' }], to: [], size: 1, hasAttachment: false, + } as Email; +} + +// "acesso" occurs only in the group account, "fatura" only in the own one. +const own = [ + email('own-1', 'inbox', '2026-09-14T00:00:00Z', 'fatura de setembro'), + email('own-2', 'sent', '2026-09-12T00:00:00Z', 'fatura enviada'), +]; +const group = [ + email('grp-1', 'group:inbox', '2026-09-13T00:00:00Z', 'acesso liberado', false), + email('grp-2', 'group:inbox', '2026-09-11T00:00:00Z', 'acesso negado'), +]; + +function makeClient() { + const rowsFor = (accountId?: string) => (accountId === 'group' ? group : own); + const matching = (accountId: string | undefined, term: string) => + rowsFor(accountId).filter(row => (row.subject ?? '').includes(term.replace(/\*$/, ''))); + const page = (rows: Email[], limit: number, position: number) => ({ + emails: rows.slice(position, position + limit), + total: rows.length, + hasMore: position + limit < rows.length, + }); + const searchEmails = vi.fn(async ( + query: string, _mailboxId?: string, accountId?: string, limit = 50, position = 0, + ) => page(matching(accountId, query), limit, position)); + const advancedSearchEmails = vi.fn(async ( + filter: Record, accountId?: string, limit = 50, position = 0, + ) => { + const text = JSON.stringify(filter).match(/"text":"([^"*]+)/)?.[1] ?? ''; + return page(matching(accountId, text), limit, position); + }); + const client = { + searchEmails, + advancedSearchEmails, + getSomeEmails: vi.fn(async () => []), + getThreads: vi.fn(async () => []), + batchMarkAsRead: vi.fn(async () => undefined), + getAccountId: () => 'me', + } as unknown as IJMAPClient & { + searchEmails: typeof searchEmails; + advancedSearchEmails: typeof advancedSearchEmails; + batchMarkAsRead: ReturnType; + }; + return client; +} + +describe('"All folders" search across the own and group accounts (#1082)', () => { + let client: ReturnType; + + beforeEach(() => { + client = makeClient(); + useAuthStore.setState({ + activeAccountId: 'login', + getClientForAccount: ((id: string) => (id === 'login' ? client : undefined)) as never, + getAllConnectedClients: (() => new Map([['login', client]])) as never, + } as never); + useMessageListTabsStore.setState(useMessageListTabsStore.getInitialState()); + useSettingsStore.setState({ + emailsPerPage: 50, messageListOrder: [], messageListOrderScope: 'inbox', emailKeywords: [], + } as never); + useEmailStore.setState({ + ...useEmailStore.getInitialState(), + selectedMailbox: ownInbox.id, + searchMailboxId: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + mailboxes: [ownInbox, ownSent, groupInbox], + }); + }); + + it('finds mail that exists only in the group account', async () => { + await useEmailStore.getState().searchEmails(client, 'acesso'); + + const state = useEmailStore.getState(); + expect(state.emails.map(e => e.id)).toEqual(['grp-1', 'grp-2']); + expect(state.totalEmails).toBe(2); + expect(state.hasMoreEmails).toBe(false); + // One folder-less query per account. + expect(client.searchEmails).toHaveBeenCalledTimes(2); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0); + }); + + it('merges own and group hits newest first and stamps each with its source', async () => { + await useEmailStore.getState().searchEmails(client, 'a'); + + const { emails } = useEmailStore.getState(); + expect(emails.map(e => e.id)).toEqual(['own-1', 'grp-1', 'own-2', 'grp-2']); + const byId = Object.fromEntries(emails.map(e => [e.id, e])); + expect(byId['own-1']).toMatchObject({ sourceClientAccountId: 'login', sourceAccountId: 'me', sourceFolder: 'Inbox' }); + expect(byId['own-2']).toMatchObject({ sourceAccountId: 'me', sourceFolder: 'Sent' }); + expect(byId['grp-1']).toMatchObject({ + sourceClientAccountId: 'login', sourceAccountId: 'group', + accountLabel: 'chamados@server.tld', sourceFolder: 'Abrir Chamados', + }); + }); + + it('runs an advanced search with a folder-less filter in every account', async () => { + useEmailStore.setState({ searchQuery: 'acesso', searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'sender@example.com' } }); + + await useEmailStore.getState().advancedSearch(client); + + const calls = client.advancedSearchEmails.mock.calls; + expect(calls).toHaveLength(2); + expect(calls.map(([, accountId]) => accountId)).toEqual([undefined, 'group']); + for (const [filter] of calls) { + expect(filter).toEqual({ + operator: 'AND', + conditions: [{ text: 'acesso*' }, { from: 'sender@example.com' }], + }); + } + expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['grp-1', 'grp-2']); + }); + + it('still asks only the chosen account when the search is scoped to a group folder', async () => { + useEmailStore.getState().setSearchMailboxId(groupInbox.id); + + await useEmailStore.getState().searchEmails(client, 'acesso'); + + expect(client.searchEmails).toHaveBeenCalledTimes(1); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'inbox', 'group', 50, 0); + }); + + it('makes a single own-account query for a login without group folders', async () => { + useEmailStore.setState({ mailboxes: [ownInbox, ownSent] }); + + await useEmailStore.getState().searchEmails(client, 'fatura'); + + expect(client.searchEmails).toHaveBeenCalledTimes(1); + expect(client.searchEmails).toHaveBeenCalledWith('fatura', undefined, undefined, 50, 0); + expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['own-1', 'own-2']); + }); + + it('paginates the fan-out from the loaded position in every account', async () => { + useSettingsStore.setState({ emailsPerPage: 1 } as never); + await useEmailStore.getState().searchEmails(client, 'a'); + expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['own-1', 'grp-1']); + expect(useEmailStore.getState().hasMoreEmails).toBe(true); + + await useEmailStore.getState().loadMoreEmails(client); + + expect(client.searchEmails).toHaveBeenCalledWith('a', undefined, undefined, 1, 2); + expect(client.searchEmails).toHaveBeenCalledWith('a', undefined, 'group', 1, 2); + expect(useEmailStore.getState().hasMoreEmails).toBe(false); + }); + + it('marks a mixed selection read in each owning account', async () => { + await useEmailStore.getState().searchEmails(client, 'a'); + useEmailStore.setState({ selectedEmailIds: new Set(['own-1', 'grp-1']) }); + + await useEmailStore.getState().batchMarkAsRead(client, true); + + expect(client.batchMarkAsRead).toHaveBeenCalledWith(['own-1'], true, 'me'); + expect(client.batchMarkAsRead).toHaveBeenCalledWith(['grp-1'], true, 'group'); + }); + + it('re-runs the fan-out when a push reports a change in the group account only', async () => { + await useEmailStore.getState().searchEmails(client, 'acesso'); + client.searchEmails.mockClear(); + client.advancedSearchEmails.mockClear(); + + await useEmailStore.getState().handleStateChange({ + '@type': 'StateChange', changed: { group: { Email: '2' } }, + }, client); + + const calls = client.advancedSearchEmails.mock.calls; + expect(calls.map(([, accountId]) => accountId)).toEqual([undefined, 'group']); + expect(calls.every(([filter]) => !JSON.stringify(filter).includes('inMailbox'))).toBe(true); + expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['grp-1', 'grp-2']); + }); +}); diff --git a/stores/__tests__/email-store-search-race.test.ts b/stores/__tests__/email-store-search-race.test.ts index 38bb48937..09fa42922 100644 --- a/stores/__tests__/email-store-search-race.test.ts +++ b/stores/__tests__/email-store-search-race.test.ts @@ -55,6 +55,7 @@ describe('quick search staleness', () => { ), advancedSearchEmails: vi.fn().mockResolvedValue({ emails: [], hasMore: false, total: 0 }), getSomeEmails: vi.fn().mockResolvedValue([]), + getAccountId: () => 'me', } as unknown as IJMAPClient; useAuthStore.setState({ diff --git a/stores/__tests__/email-store-search-scope.test.ts b/stores/__tests__/email-store-search-scope.test.ts index c25e441bc..4ae3c56b8 100644 --- a/stores/__tests__/email-store-search-scope.test.ts +++ b/stores/__tests__/email-store-search-scope.test.ts @@ -32,6 +32,7 @@ function makeClient() { searchEmails: vi.fn().mockResolvedValue({ emails: [], hasMore: false, total: 0 }), advancedSearchEmails: vi.fn().mockResolvedValue({ emails: [], hasMore: false, total: 0 }), getSomeEmails: vi.fn().mockResolvedValue([]), + getAccountId: () => 'me', } as unknown as IJMAPClient; } @@ -73,11 +74,14 @@ describe('search folder scope (#788)', () => { expect(useEmailStore.getState().searchMailboxId).toBe(''); }); - it('quick search from within a folder queries every folder', async () => { + it('quick search from within a folder queries every folder of every account', async () => { await useEmailStore.getState().searchEmails(client, 'invoice'); - // No mailbox id -> the JMAP client omits the inMailbox constraint. - expect(client.searchEmails).toHaveBeenCalledWith('invoice', '', undefined, 50, 0); + // No mailbox id -> the JMAP client omits the inMailbox constraint; the + // shared folder's owner is asked as well (#1082). + expect(client.searchEmails).toHaveBeenCalledTimes(2); + expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, undefined, 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, 'owner-x', 50, 0); }); it('advanced search from within a folder does not add an inMailbox condition', async () => { @@ -85,9 +89,13 @@ describe('search folder scope (#788)', () => { await useEmailStore.getState().advancedSearch(client); - const [filter] = (client.advancedSearchEmails as ReturnType).mock.calls[0]; - expect(filter).toEqual({ from: 'bob@example.com' }); - expect(JSON.stringify(filter)).not.toContain('inMailbox'); + const calls = (client.advancedSearchEmails as ReturnType).mock.calls; + expect(calls).toHaveLength(2); + for (const [filter] of calls) { + expect(filter).toEqual({ from: 'bob@example.com' }); + expect(JSON.stringify(filter)).not.toContain('inMailbox'); + } + expect(calls.map(([, accountId]) => accountId)).toEqual([undefined, 'owner-x']); }); it('honours an explicitly chosen folder that differs from the open one', async () => { diff --git a/stores/email-store.ts b/stores/email-store.ts index c10ac3a1a..0479355c6 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -10,7 +10,7 @@ import { emailHooks } from "@/lib/plugin-hooks"; import { resolveThreadRoute } from "@/lib/thread-routing"; import { threadKeyFor, threadIdFromKey } from "@/lib/thread-utils"; import type { ExternalSearchResult } from "@/lib/plugin-types"; -import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; +import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, searchAcrossAccounts, advancedSearchAcrossAccounts, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useMessageListTabsStore } from "@/stores/message-list-tabs-store"; @@ -612,8 +612,10 @@ export function findArchiveMailbox( * 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) + * `getEmail` returns nothing. In that case the caller's own account is used. + * (#923) Since #1082 the unscoped search stamps its hits with their source + * account (own or shared), so this only decides for the rare unstamped row, + * e.g. one a plugin added to the results. */ export function resolveUnstampedEmailAccountId(opts: { mailboxes: Mailbox[]; @@ -1011,14 +1013,28 @@ export function buildTagViewAccountClients(passedClient: IJMAPClient): UnifiedAc return built; } +/** + * Whether a search is running under the "All folders" scope (`searchMailboxId` + * "") of the standard (non-unified) list. That scope spans every folder of the + * own account AND of the group/shared accounts this login reaches, so the + * search fans out like a tag view and stamps its hits with their source + * account (#1082). A search scoped to one folder stays single-account. + */ +function isUnscopedSearchActive( + s: Pick, +): boolean { + return !s.isUnifiedView && s.searchMailboxId === '' && (!!s.searchQuery || !isFilterEmpty(s.searchFilters)); +} + /** * Whether the current list mixes messages from several accounts, so actions * must route by each email's source stamps rather than by the selected - * folder: the unified / cross-account views, and a tag view (#1038). + * folder: the unified / cross-account views, a tag view (#1038) and an + * "All folders" search (#1082). */ function isAggregateListView(): boolean { const s = useEmailStore.getState(); - return s.isUnifiedView || !!s.selectedKeyword; + return s.isUnifiedView || !!s.selectedKeyword || isUnscopedSearchActive(s); } /** @@ -1928,16 +1944,26 @@ export const useEmailStore = create((set, get) => ({ // Paginate with the same scope the search itself ran under, not the // folder that happens to be open in the list. const { searchMailboxId } = get(); - const mailboxes = resolveActionMailboxes(); - const mailbox = mailboxes.find(mb => mb.id === searchMailboxId); - const jmapMailboxId = mailbox?.originalId || searchMailboxId; - const accountId = mailbox?.isShared ? mailbox.accountId : undefined; - - if (hasFilters) { - const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); - result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, position); + if (searchMailboxId === '') { + // "All folders": the next page of the same own + group account + // fan-out the search ran (#1082). + const built = buildTagViewAccountClients(client); + result = hasFilters + ? await advancedSearchAcrossAccounts(built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, position) + : await searchAcrossAccounts(built, searchQuery, emailsPerPage, position); + set({ unifiedErrors: result.errors }); } else { - result = await effectiveClient.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position); + const mailboxes = resolveActionMailboxes(); + const mailbox = mailboxes.find(mb => mb.id === searchMailboxId); + const jmapMailboxId = mailbox?.originalId || searchMailboxId; + const accountId = mailbox?.isShared ? mailbox.accountId : undefined; + + if (hasFilters) { + const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); + result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, position); + } else { + result = await effectiveClient.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position); + } } } else if (selectedKeyword) { // Tag view: the next page of the same cross-account fan-out the @@ -2734,9 +2760,17 @@ export const useEmailStore = create((set, get) => ({ result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0); unifiedErrors = result.errors; + } else if (searchMailboxId === '') { + // "All folders" (the default): every folder of the own account AND of + // every group/shared account this login reaches - the same fan-out the + // tag views use (#1038). A single own-account query silently skipped + // the shared accounts' mail (#1082). + const built = buildTagViewAccountClients(client); + result = await searchAcrossAccounts(built, query, emailsPerPage, 0); + unifiedErrors = result.errors; + } else { - // Scope the search to the folder picked in the search panel; "" (the - // default) searches across all folders. + // Scope the search to the folder picked in the search panel. const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === searchMailboxId); // Use originalId for shared mailboxes @@ -2840,6 +2874,15 @@ export const useEmailStore = create((set, get) => ({ ); unifiedErrors = result.errors; + } else if (searchMailboxId === '') { + // "All folders": fan out over the own + group/shared accounts with a + // filter that carries no inMailbox clause (#1082). + const built = buildTagViewAccountClients(client); + result = await advancedSearchAcrossAccounts( + built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0, + ); + unifiedErrors = result.errors; + } else { const mailbox = mailboxes.find(mb => mb.id === searchMailboxId); const jmapMailboxId = mailbox?.originalId || searchMailboxId; @@ -3709,7 +3752,9 @@ export const useEmailStore = create((set, get) => ({ // accounts (#1038), so an Email change in any of them concerns them. const anyEmailChanged = Object.values(change.changed).some((c) => c?.Email); const tagViewChanged = !!get().selectedKeyword && anyEmailChanged; - if (accountChanges?.Email || syncedAccountEmailState || tagViewChanged) { + // So does an "All folders" search (#1082). + const unscopedSearchChanged = isUnscopedSearchActive(get()) && anyEmailChanged; + if (accountChanges?.Email || syncedAccountEmailState || tagViewChanged || unscopedSearchChanged) { const applied = syncedAccountEmailState ? await get().applyEmailDelta(client, syncedAccountEmailState) : false; @@ -3862,10 +3907,20 @@ export const useEmailStore = create((set, get) => ({ // A refresh while a search is active must re-run it under the // search's own folder scope, which is independent of selectedMailbox. const { searchMailboxId } = get(); - const scopeMailbox = mailboxes.find(mb => mb.id === searchMailboxId); - const filter = buildJMAPFilter(searchQuery, searchFilters, scopeMailbox?.originalId || searchMailboxId); - const scopeAccountId = scopeMailbox?.isShared ? scopeMailbox.accountId : undefined; - result = await effectiveClient.advancedSearchEmails(filter, scopeAccountId, emailsPerPage, 0); + if (searchMailboxId === '') { + // "All folders": the same own + group account fan-out the search + // ran, so a change in a group account reaches the list (#1082). + const built = buildTagViewAccountClients(client); + result = await advancedSearchAcrossAccounts( + built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0, + ); + unifiedErrors = result.errors; + } else { + const scopeMailbox = mailboxes.find(mb => mb.id === searchMailboxId); + const filter = buildJMAPFilter(searchQuery, searchFilters, scopeMailbox?.originalId || searchMailboxId); + const scopeAccountId = scopeMailbox?.isShared ? scopeMailbox.accountId : undefined; + result = await effectiveClient.advancedSearchEmails(filter, scopeAccountId, emailsPerPage, 0); + } } else { result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0, undefined, true, undefined, getMessageListOrderFor(mailbox?.role)); // This page is the new delta-sync baseline for the folder; the