This commit is contained in:
Linus Rath
2026-09-22 21:40:54 +02:00
4 changed files with 214 additions and 6 deletions
+2 -1
View File
@@ -512,11 +512,12 @@ export async function fetchTagEmails(
limit: number,
position: number,
order: SortLevel[] = [],
extraFilter?: Record<string, unknown>,
): Promise<UnifiedFetchResult> {
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 }),
);
@@ -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');
@@ -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<string, Email[]> = {
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<IJMAPClient['getEmails']>(async (
_mailboxId?: string, accountId?: string, limit = 50, position = 0,
hasKeyword?: string, _pinnedFirst?: boolean, extraFilter?: Record<string, unknown>,
) => {
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<typeof makeClient>) =>
c.getEmails.mock.calls.filter(args => !!args[4]).map(args => args[6]);
describe('searching inside a tag view narrows the tag', () => {
let client: ReturnType<typeof makeClient>;
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();
});
});
+63 -5
View File
@@ -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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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) {