mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: include shared accounts in "All folders" mail search #1082
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user