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 && (
{t("advanced_search.folder")}
@@ -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"
>
{t("advanced_search.all_folders")}
- {mailboxes.map((mb) => (
+ {searchScopeFolders.own.map((mb) => (
{mb.name}
))}
+ {searchScopeFolders.shared.map((group) => (
+
+ {group.mailboxes.map((mb) => (
+
+ {mb.name}
+
+ ))}
+
+ ))}
)}
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