diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index 725d028e8..a9db07bf1 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -512,11 +512,12 @@ export async function fetchTagEmails( limit: number, position: number, order: SortLevel[] = [], + extraFilter?: Record, ): Promise { return fanOutAccountQuery( accounts, (account, jmapAccountId) => account.client.getEmails( - undefined, jmapAccountId, limit, position, keyword, true, undefined, order, + undefined, jmapAccountId, limit, position, keyword, true, extraFilter, order, ), compareEmails(order, { pinnedFirst: true }), ); diff --git a/stores/__tests__/email-store-multi-account.test.ts b/stores/__tests__/email-store-multi-account.test.ts index 02a5cacbb..0f83bd241 100644 --- a/stores/__tests__/email-store-multi-account.test.ts +++ b/stores/__tests__/email-store-multi-account.test.ts @@ -105,6 +105,19 @@ describe('useEmailStore multi-account state', () => { expect(state.expandedThreadIds.size).toBe(0); }); + it('selectAccountMailbox drops a search scope belonging to the account being left', () => { + // The scope names a folder of the previous account and resolves against + // resolveActionMailboxes(), which now returns the new account's list. Kept, + // it would query that foreign folder id against the wrong account and come + // back empty, while the dropdown has no matching option and reads "All + // folders". (#1082) + useEmailStore.setState({ searchMailboxId: 'a-archive' }); + + useEmailStore.getState().selectAccountMailbox('account-b', 'b-inbox'); + + expect(useEmailStore.getState().searchMailboxId).toBe(''); + }); + it('selectAccountMailbox with null accountId switches back to the active account', () => { useEmailStore.getState().selectAccountMailbox('account-b', 'b-inbox'); expect(useEmailStore.getState().viewingAccountId).toBe('account-b'); diff --git a/stores/__tests__/email-store-search-in-tag-view.test.ts b/stores/__tests__/email-store-search-in-tag-view.test.ts new file mode 100644 index 000000000..8da83933a --- /dev/null +++ b/stores/__tests__/email-store-search-in-tag-view.test.ts @@ -0,0 +1,136 @@ +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'; + +// Selecting a tag does not clear an active search (`handleTagSelect` leaves +// searchQuery/searchFilters alone, and `selectKeyword` leaves selectedMailbox), +// so a tag view and a search can be active together. The paths disagreed about +// which one wins: fetchEmails and refreshCurrentMailbox tested `selectedKeyword` +// first (tag fan-out, query dropped), while searchEmails / advancedSearch / +// loadMoreEmails tested the search first (all-folders fan-out, tag dropped). +// A push-triggered refresh therefore silently replaced a search result list +// with the whole tag, and page 2 appended rows the tag did not contain. +// +// A search inside a tag view now NARROWS the tag everywhere. + +const ownInbox = { id: 'inbox', name: 'Inbox', role: 'inbox', isShared: false } as Mailbox; +const groupInbox = { + id: 'group:inbox', originalId: 'inbox', name: 'Team', role: 'inbox', + isShared: true, accountId: 'group', accountName: 'team@example.org', +} as Mailbox; + +function email(id: string, mailbox: string, receivedAt: string, subject: string): Email { + return { + id, threadId: `thread-${id}`, mailboxIds: { [mailbox]: true }, + keywords: { $seen: true, '$label:red': true }, + subject, preview: subject, receivedAt, + from: [{ email: 'sender@example.com' }], to: [], size: 1, hasAttachment: false, + } as Email; +} + +const tagged: Record = { + me: [email('own-1', 'inbox', '2026-09-14T00:00:00Z', 'invoice red')], + group: [email('grp-1', 'group:inbox', '2026-09-13T00:00:00Z', 'invoice team')], +}; + +function makeClient() { + // getEmails is the tag-view call: (mailboxId, accountId, limit, position, + // hasKeyword, pinnedFirst, extraFilter, order). + const getEmails = vi.fn(async ( + _mailboxId?: string, accountId?: string, limit = 50, position = 0, + hasKeyword?: string, _pinnedFirst?: boolean, extraFilter?: Record, + ) => { + let rows = hasKeyword ? (tagged[accountId ?? 'me'] ?? []) : []; + if (extraFilter) { + // Match either a free-text condition or a `subject` field filter. + const blob = JSON.stringify(extraFilter); + const term = (blob.match(/"text":"([^"*]+)/) ?? blob.match(/"subject":"([^"*]+)/))?.[1] ?? ''; + if (term) rows = rows.filter(r => (r.subject ?? '').includes(term)); + } + return { + emails: rows.slice(position, position + limit), + total: rows.length, + hasMore: position + limit < rows.length, + }; + }); + return { + getEmails, + searchEmails: vi.fn(async () => ({ emails: [], total: 0, hasMore: false })), + advancedSearchEmails: vi.fn(async () => ({ emails: [], total: 0, hasMore: false })), + getSomeEmails: vi.fn(async () => []), + getThreads: vi.fn(async () => []), + getAccountId: () => 'me', + } as unknown as IJMAPClient & { getEmails: typeof getEmails }; +} + +/** The `extraFilter` of every tag-view getEmails call. */ +const filtersOf = (c: ReturnType) => + c.getEmails.mock.calls.filter(args => !!args[4]).map(args => args[6]); + +describe('searching inside a tag view narrows the tag', () => { + 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, + selectedKeyword: 'red', + searchMailboxId: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + mailboxes: [ownInbox, groupInbox], + }); + }); + + it('keeps the keyword and applies the query, instead of searching all folders', async () => { + await useEmailStore.getState().searchEmails(client, 'team'); + + expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['grp-1']); + // The tag fan-out ran (one keyword query per account), not a folder search. + expect(client.searchEmails).not.toHaveBeenCalled(); + expect(client.getEmails).toHaveBeenCalled(); + for (const f of filtersOf(client)) expect(JSON.stringify(f)).toContain('team'); + }); + + it('carries the query into a field-filter search too', async () => { + useEmailStore.setState({ + searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS, subject: 'team' }, + }); + await useEmailStore.getState().advancedSearch(client); + + expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['grp-1']); + expect(client.advancedSearchEmails).not.toHaveBeenCalled(); + }); + + it('carries the query into a refresh, so a push does not widen the list', async () => { + useEmailStore.setState({ searchQuery: 'team' }); + await useEmailStore.getState().refreshCurrentMailbox(client); + + expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['grp-1']); + const applied = filtersOf(client); + expect(applied.length).toBeGreaterThan(0); + for (const f of applied) expect(JSON.stringify(f)).toContain('team'); + }); + + it('still lists the whole tag when no search is active', async () => { + await useEmailStore.getState().refreshCurrentMailbox(client); + + expect(useEmailStore.getState().emails.map(e => e.id).sort()).toEqual(['grp-1', 'own-1']); + for (const f of filtersOf(client)) expect(f).toBeUndefined(); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 0479355c6..32837becb 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -5,7 +5,7 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store"; import { useCalendarStore } from "@/stores/calendar-store"; import type { SortLevel } from "@/lib/message-list-order"; -import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils"; +import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty, toWildcardQuery } from "@/lib/jmap/search-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import { resolveThreadRoute } from "@/lib/thread-routing"; import { threadKeyFor, threadIdFromKey } from "@/lib/thread-utils"; @@ -1436,6 +1436,13 @@ export const useEmailStore = create((set, get) => ({ selectAccountMailbox: (accountId, mailboxId) => set({ viewingAccountId: accountId, selectedMailbox: mailboxId, + // The search panel's folder scope names a folder of the account we are + // leaving. It resolves against `resolveActionMailboxes()`, which now + // returns the NEW account's list, so the id no longer matches: the search + // would query that foreign folder id against the wrong account and come + // back empty, while the dropdown (which renders no matching option) reads + // "All folders". Reset it so the scope matches what is shown. (#1082) + searchMailboxId: "", isLoadingMore: false, selectedEmail: null, selectedEmailIds: new Set(), @@ -1736,7 +1743,17 @@ export const useEmailStore = create((set, get) => ({ if (selectedKeyword) { const order = getMessageListOrderFor(null); const built = buildTagViewAccountClients(client); - const result = await fetchTagEmails(built, `$label:${selectedKeyword}`, emailsPerPage, 0, order); + // Selecting a tag keeps an active search (`handleTagSelect` does not + // clear it), and the search paths narrow the tag rather than replace + // it — so honour the query here too, or the list would flip between + // narrowed and whole depending on which path last ran. + const { searchQuery: tagQuery, searchFilters: tagFilters } = get(); + const tagFilter = !isFilterEmpty(tagFilters) + ? buildJMAPFilter(tagQuery, tagFilters, undefined) + : tagQuery + ? { text: toWildcardQuery(tagQuery) } + : undefined; + const result = await fetchTagEmails(built, `$label:${selectedKeyword}`, emailsPerPage, 0, order, tagFilter); const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails); if (!isCurrentView()) return; set({ @@ -1940,7 +1957,19 @@ export const useEmailStore = create((set, get) => ({ const { searchFilters } = get(); const hasFilters = !isFilterEmpty(searchFilters); - if (searchQuery || hasFilters) { + if ((searchQuery || hasFilters) && selectedKeyword) { + // Searching inside a tag view narrows the tag (see searchEmails), so + // page 2 must carry the same keyword AND the same query — otherwise it + // appends unrelated all-folders hits to a tag list. + const built = buildTagViewAccountClients(client); + result = await fetchTagEmails( + built, `$label:${selectedKeyword}`, emailsPerPage, position, getMessageListOrderFor(null), + hasFilters + ? buildJMAPFilter(searchQuery, searchFilters, undefined) + : { text: toWildcardQuery(searchQuery) }, + ); + set({ unifiedErrors: result.errors }); + } else if (searchQuery || hasFilters) { // Paginate with the same scope the search itself ran under, not the // folder that happens to be open in the list. const { searchMailboxId } = get(); @@ -2741,7 +2770,7 @@ export const useEmailStore = create((set, get) => ({ searchAbortController: controller, }); // Clear emails for loading state try { - const { isUnifiedView, unifiedRole, crossView, searchMailboxId, searchFilters } = get(); + const { isUnifiedView, unifiedRole, crossView, searchMailboxId, searchFilters, selectedKeyword } = get(); const emailsPerPage = useSettingsStore.getState().emailsPerPage; let result; @@ -2760,6 +2789,19 @@ export const useEmailStore = create((set, get) => ({ result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0); unifiedErrors = result.errors; + } else if (selectedKeyword) { + // A tag view takes precedence over the folder, as it does in + // fetchEmails and refreshCurrentMailbox — so searching inside one + // NARROWS the tag rather than silently replacing the list with an + // unrelated all-folders result that the next push refresh would throw + // away again. + const built = buildTagViewAccountClients(client); + result = await fetchTagEmails( + built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null), + { text: toWildcardQuery(query) }, + ); + 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 @@ -2829,7 +2871,7 @@ export const useEmailStore = create((set, get) => ({ }, advancedSearch: async (client) => { - const { searchQuery, searchFilters, searchMailboxId, searchAbortController, isUnifiedView, unifiedRole, crossView } = get(); + const { searchQuery, searchFilters, searchMailboxId, searchAbortController, isUnifiedView, unifiedRole, crossView, selectedKeyword } = get(); const mailboxes = resolveActionMailboxes(); if (searchAbortController) { @@ -2874,6 +2916,15 @@ export const useEmailStore = create((set, get) => ({ ); unifiedErrors = result.errors; + } else if (selectedKeyword) { + // Narrow the tag rather than replace it — see searchEmails. + const built = buildTagViewAccountClients(client); + result = await fetchTagEmails( + built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null), + buildJMAPFilter(searchQuery, searchFilters, undefined), + ); + 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). @@ -3898,9 +3949,16 @@ export const useEmailStore = create((set, get) => ({ // Match fetchEmails: tag views take precedence and query the label // across all folders and across the own + group accounts (#1038). // They cannot establish a folder delta baseline. + // An active search narrows the tag, so carry it or the refresh + // would silently widen the list back to the whole tag. const built = buildTagViewAccountClients(client); result = await fetchTagEmails( built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null), + hasFilters + ? buildJMAPFilter(searchQuery, searchFilters, undefined) + : searchQuery + ? { text: toWildcardQuery(searchQuery) } + : undefined, ); unifiedErrors = result.errors; } else if (hasFilters || searchQuery) {