fix: include shared accounts in "All folders" mail search #1082

This commit is contained in:
Linus Rath
2026-09-22 17:25:43 +02:00
parent 9893b0a8ad
commit 9ef7068bd8
9 changed files with 572 additions and 34 deletions
+21 -3
View File
@@ -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 && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.folder")}</label>
@@ -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"
>
<option value="">{t("advanced_search.all_folders")}</option>
{mailboxes.map((mb) => (
{searchScopeFolders.own.map((mb) => (
<option key={mb.id} value={mb.id}>
{mb.name}
</option>
))}
{searchScopeFolders.shared.map((group) => (
<optgroup key={group.ownerId} label={group.label}>
{group.mailboxes.map((mb) => (
<option key={mb.id} value={mb.id}>
{mb.name}
</option>
))}
</optgroup>
))}
</select>
</div>
)}
@@ -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<Mailbox> & { 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([]);
});
});
@@ -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<UnifiedAccountClient> & { accountId: string },
client: Partial<IJMAPClient>,
): 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' });
});
});
+39
View File
@@ -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<string, SearchScopeFolderGroup>();
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()) };
}
+69 -4
View File
@@ -512,6 +512,73 @@ export async function fetchTagEmails(
limit: number,
position: number,
order: SortLevel[] = [],
): Promise<UnifiedFetchResult> {
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<UnifiedFetchResult> {
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<string, unknown>,
limit: number,
position: number,
): Promise<UnifiedFetchResult> {
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<UnifiedFetchResult> {
const errors = new Map<string, string>();
@@ -523,9 +590,7 @@ export async function fetchTagEmails(
const promises = accounts.map(async (account): Promise<AccountResult> => {
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 };
}
@@ -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<IJMAPClient['searchEmails']>(async (
query: string, _mailboxId?: string, accountId?: string, limit = 50, position = 0,
) => page(matching(accountId, query), limit, position));
const advancedSearchEmails = vi.fn<IJMAPClient['advancedSearchEmails']>(async (
filter: Record<string, unknown>, 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<typeof vi.fn>;
};
return client;
}
describe('"All folders" search across the own and group accounts (#1082)', () => {
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,
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']);
});
});
@@ -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({
@@ -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<typeof vi.fn>).mock.calls[0];
expect(filter).toEqual({ from: 'bob@example.com' });
expect(JSON.stringify(filter)).not.toContain('inMailbox');
const calls = (client.advancedSearchEmails as ReturnType<typeof vi.fn>).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 () => {
+76 -21
View File
@@ -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<EmailStore, 'isUnifiedView' | 'searchMailboxId' | 'searchQuery' | 'searchFilters'>,
): 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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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