mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: list tagged mail from every account in the tag view #1038
This commit is contained in:
@@ -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 { fetchTagEmails, type UnifiedAccountClient } from '@/lib/unified-mailbox';
|
||||
|
||||
// A tag keyword sits on messages in the user's own account and in the
|
||||
// group/shared accounts they can reach. The tag view fans out over all of
|
||||
// them (#1038).
|
||||
|
||||
const keyword = '$label:work';
|
||||
|
||||
const mb = (id: string, originalId?: string): Mailbox =>
|
||||
({ id, name: id, role: 'inbox', unreadEmails: 0, totalEmails: 0, originalId } as unknown as Mailbox);
|
||||
|
||||
function email(id: string, receivedAt: string, mailbox: string, pinned = false): Email {
|
||||
return {
|
||||
id, threadId: `t-${id}`, mailboxIds: { [mailbox]: true },
|
||||
keywords: pinned ? { [keyword]: true, $pinned: true } : { [keyword]: true },
|
||||
subject: id, receivedAt, from: [], to: [], preview: '', size: 1, hasAttachment: false,
|
||||
} as Email;
|
||||
}
|
||||
|
||||
const makeAccount = (
|
||||
over: Partial<UnifiedAccountClient> & { accountId: string },
|
||||
getEmails: IJMAPClient['getEmails'],
|
||||
): UnifiedAccountClient => ({
|
||||
accountLabel: over.accountId,
|
||||
mailboxes: [],
|
||||
client: { getEmails } as unknown as IJMAPClient,
|
||||
clientAccountId: 'login',
|
||||
jmapAccountId: over.accountId,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('fetchTagEmails', () => {
|
||||
it('asks every account for the keyword without a folder constraint and merges the pages', async () => {
|
||||
const ownGet = vi.fn(async () => ({
|
||||
emails: [email('own-1', '2026-09-10T00:00:00Z', 'inbox')], total: 1, hasMore: false,
|
||||
}));
|
||||
const groupGet = vi.fn(async () => ({
|
||||
emails: [email('grp-1', '2026-09-12T00:00:00Z', 'g-inbox')], total: 4, hasMore: true,
|
||||
}));
|
||||
const own = makeAccount({ accountId: 'login', jmapAccountId: 'me', mailboxes: [mb('inbox')] }, ownGet);
|
||||
const group = makeAccount({
|
||||
accountId: 'group', isShared: true, accountLabel: 'Team', mailboxes: [mb('group:g-inbox', 'g-inbox')],
|
||||
}, groupGet);
|
||||
|
||||
const result = await fetchTagEmails([own, group], keyword, 50, 0);
|
||||
|
||||
expect(ownGet).toHaveBeenCalledWith(undefined, undefined, 50, 0, keyword, true, undefined, []);
|
||||
expect(groupGet).toHaveBeenCalledWith(undefined, 'group', 50, 0, keyword, true, undefined, []);
|
||||
expect(result.emails.map((e) => e.id)).toEqual(['grp-1', 'own-1']);
|
||||
expect(result.total).toBe(5);
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(result.errors.size).toBe(0);
|
||||
});
|
||||
|
||||
it('stamps each email with its source account so actions route back to the owner', async () => {
|
||||
const own = makeAccount({ accountId: 'login', jmapAccountId: 'me', mailboxes: [mb('inbox')] },
|
||||
vi.fn(async () => ({ emails: [email('own-1', '2026-09-10T00:00:00Z', 'inbox')], total: 1, hasMore: false })));
|
||||
const group = makeAccount({
|
||||
accountId: 'group', isShared: true, accountLabel: 'Team', mailboxes: [mb('group:g-inbox', 'g-inbox')],
|
||||
}, vi.fn(async () => ({ emails: [email('grp-1', '2026-09-12T00:00:00Z', 'group:g-inbox')], total: 1, hasMore: false })));
|
||||
|
||||
const { emails } = await fetchTagEmails([own, group], keyword, 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: 'group:g-inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps pinned messages first across accounts, then newest first', async () => {
|
||||
const own = makeAccount({ accountId: 'login' },
|
||||
vi.fn(async () => ({ emails: [email('own-new', '2026-09-15T00:00:00Z', 'inbox')], total: 1, hasMore: false })));
|
||||
const group = makeAccount({ accountId: 'group', isShared: true },
|
||||
vi.fn(async () => ({ emails: [email('grp-pinned', '2026-09-01T00:00:00Z', 'g', true)], total: 1, hasMore: false })));
|
||||
|
||||
const { emails } = await fetchTagEmails([own, group], keyword, 50, 0);
|
||||
expect(emails.map((e) => e.id)).toEqual(['grp-pinned', 'own-new']);
|
||||
});
|
||||
|
||||
it('passes the requested page and list order through to every account', async () => {
|
||||
const order = [{ criterion: 'from', direction: 'asc' } as const];
|
||||
const get = vi.fn(async () => ({ emails: [], total: 0, hasMore: false }));
|
||||
await fetchTagEmails([makeAccount({ accountId: 'login' }, get), makeAccount({ accountId: 'g', isShared: true }, get)],
|
||||
keyword, 25, 50, order);
|
||||
expect(get).toHaveBeenNthCalledWith(1, undefined, undefined, 25, 50, keyword, true, undefined, order);
|
||||
expect(get).toHaveBeenNthCalledWith(2, undefined, 'g', 25, 50, keyword, true, undefined, order);
|
||||
});
|
||||
|
||||
it('keeps the other accounts when one fails and reports the failure', async () => {
|
||||
const own = makeAccount({ accountId: 'login' },
|
||||
vi.fn(async () => ({ emails: [email('own-1', '2026-09-10T00:00:00Z', 'inbox')], total: 1, hasMore: false })));
|
||||
const group = makeAccount({ accountId: 'group', isShared: true },
|
||||
vi.fn(async () => { throw new Error('forbidden'); }));
|
||||
|
||||
const result = await fetchTagEmails([own, group], keyword, 50, 0);
|
||||
expect(result.emails.map((e) => e.id)).toEqual(['own-1']);
|
||||
expect(result.errors.get('group')).toBe('forbidden');
|
||||
});
|
||||
});
|
||||
@@ -170,7 +170,13 @@ export interface IJMAPClient {
|
||||
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
|
||||
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
|
||||
getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]>
|
||||
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
|
||||
/**
|
||||
* Total / unread message counts per tag id. `accountId` scopes the count to
|
||||
* a group/shared account reached through this client (defaults to the
|
||||
* client's own account); the store sums it over every account a tag view
|
||||
* spans (#1038).
|
||||
*/
|
||||
getTagCounts(tagIds: string[], accountId?: string): Promise<Record<string, { total: number; unread: number }>>;
|
||||
/**
|
||||
* Enumerate account keywords for extensions. Servers supporting Keyword/get
|
||||
* can return exact counts and provider-label metadata; other servers use the
|
||||
|
||||
+4
-3
@@ -1736,9 +1736,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
return allEmails;
|
||||
}
|
||||
|
||||
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
|
||||
async getTagCounts(tagIds: string[], accountId?: string): Promise<Record<string, { total: number; unread: number }>> {
|
||||
if (tagIds.length === 0) return {};
|
||||
const result: Record<string, { total: number; unread: number }> = {};
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
const CALLS_PER_TAG = 2;
|
||||
const perRequest = itemsPerRequest(this.getMaxCallsInRequest(), CALLS_PER_TAG);
|
||||
@@ -1750,14 +1751,14 @@ export class JMAPClient implements IJMAPClient {
|
||||
const keyword = `$label:${batch[i]}`;
|
||||
// Total count for this tag
|
||||
methodCalls.push(["Email/query", {
|
||||
accountId: this.accountId,
|
||||
accountId: targetAccountId,
|
||||
filter: { hasKeyword: keyword },
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, `total_${i}`]);
|
||||
// Unread count for this tag
|
||||
methodCalls.push(["Email/query", {
|
||||
accountId: this.accountId,
|
||||
accountId: targetAccountId,
|
||||
filter: {
|
||||
operator: "AND",
|
||||
conditions: [
|
||||
|
||||
@@ -491,6 +491,78 @@ export async function advancedSearchCrossViewEmails(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches every message carrying a tag keyword across all the given accounts.
|
||||
*
|
||||
* A tag is a user-level concept: the same `$label:<id>` keyword is set on
|
||||
* messages in the user's own account and in the group/shared accounts they
|
||||
* can reach, and the sidebar tag entry should list all of them. Querying only
|
||||
* the account of the folder that happened to be selected made the tag view
|
||||
* flip between the personal and the group messages depending on which folder
|
||||
* the user came from (#1038).
|
||||
*
|
||||
* No `inMailbox` constraint: a tag spans folders. Each account is asked for
|
||||
* the same page (`limit`/`position`) with pinned-first ordering plus the
|
||||
* configured list order, mirroring `fetchEmails`, and the pages are merged
|
||||
* under that same order. Per-account failures land in `errors`.
|
||||
*/
|
||||
export async function fetchTagEmails(
|
||||
accounts: UnifiedAccountClient[],
|
||||
keyword: string,
|
||||
limit: number,
|
||||
position: number,
|
||||
order: SortLevel[] = [],
|
||||
): Promise<UnifiedFetchResult> {
|
||||
const errors = new Map<string, string>();
|
||||
|
||||
type AccountResult = {
|
||||
account: UnifiedAccountClient;
|
||||
result: { emails: Email[]; total: number; hasMore: boolean };
|
||||
} | null;
|
||||
|
||||
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,
|
||||
);
|
||||
return { account, result };
|
||||
} catch (err) {
|
||||
errors.set(account.accountId, err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
let mergedEmails: Email[] = [];
|
||||
let totalSum = 0;
|
||||
let anyHasMore = false;
|
||||
|
||||
for (const outcome of results) {
|
||||
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
|
||||
const { account, result } = outcome.value;
|
||||
// Decorate shallow copies, not the shared client-returned objects. The
|
||||
// source stamps are what routes every later action (read, move, delete,
|
||||
// thread expansion) back to the owning account.
|
||||
const decorated = result.emails.map((email) => ({
|
||||
...email,
|
||||
accountId: account.accountId,
|
||||
accountLabel: account.accountLabel,
|
||||
sourceClientAccountId: account.clientAccountId,
|
||||
sourceAccountId: account.jmapAccountId,
|
||||
sourceFolder: resolveSourceFolderName(email, account.mailboxes),
|
||||
}));
|
||||
mergedEmails = mergedEmails.concat(decorated);
|
||||
totalSum += result.total;
|
||||
if (result.hasMore) anyHasMore = true;
|
||||
}
|
||||
|
||||
mergedEmails.sort(compareEmails(order, { pinnedFirst: true }));
|
||||
|
||||
return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of unified roles that exist in at least one account's
|
||||
* mailboxes.
|
||||
|
||||
@@ -64,13 +64,13 @@ describe('label views during refresh and navigation', () => {
|
||||
const client = clientFor([email('aws', false), voice, archived, email('apple', false)]);
|
||||
|
||||
await useEmailStore.getState().fetchEmails(client);
|
||||
expect(useEmailStore.getState().emails).toEqual([voice, archived]);
|
||||
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual([voice.id, archived.id]);
|
||||
|
||||
await useEmailStore.getState().handleStateChange({
|
||||
'@type': 'StateChange', changed: { 'gmail-account': { Email: '2' } },
|
||||
}, client);
|
||||
|
||||
expect(useEmailStore.getState().emails).toEqual([voice, archived]);
|
||||
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual([voice.id, archived.id]);
|
||||
expect(useEmailStore.getState().totalEmails).toBe(2);
|
||||
expect(useEmailStore.getState().hasMoreEmails).toBe(false);
|
||||
expect(useEmailStore.getState().newEmailNotification).toBeNull();
|
||||
@@ -85,7 +85,7 @@ describe('label views during refresh and navigation', () => {
|
||||
await useEmailStore.getState().loadMoreEmails(client);
|
||||
await useEmailStore.getState().refreshCurrentMailbox(client);
|
||||
|
||||
expect(useEmailStore.getState().emails).toEqual(rows);
|
||||
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(rows.map(r => r.id));
|
||||
expect(useEmailStore.getState().totalEmails).toBe(3);
|
||||
expect(useEmailStore.getState().hasMoreEmails).toBe(false);
|
||||
});
|
||||
@@ -102,7 +102,7 @@ describe('label views during refresh and navigation', () => {
|
||||
const voice = email('voice', true, 'archive');
|
||||
useEmailStore.setState({ selectedMailbox: '' });
|
||||
await useEmailStore.getState().refreshCurrentMailbox(clientFor([voice]));
|
||||
expect(useEmailStore.getState().emails).toEqual([voice]);
|
||||
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual([voice.id]);
|
||||
});
|
||||
|
||||
it('returns to normal folder filtering when the label is cleared', async () => {
|
||||
@@ -113,11 +113,12 @@ describe('label views during refresh and navigation', () => {
|
||||
expect(useEmailStore.getState().emailListSync?.mailboxId).toBe('inbox');
|
||||
});
|
||||
|
||||
it('retains the shared account scope while querying a label across folders', async () => {
|
||||
it('queries the label in the own AND the shared account, whichever folder is selected (#1038)', async () => {
|
||||
const sharedInbox = { ...inbox, id: 'owner:inbox', originalId: 'inbox', isShared: true, accountId: 'owner' };
|
||||
useEmailStore.setState({ selectedMailbox: sharedInbox.id, mailboxes: [sharedInbox] });
|
||||
useEmailStore.setState({ selectedMailbox: sharedInbox.id, mailboxes: [inbox, sharedInbox] });
|
||||
const client = clientFor([email('voice')]);
|
||||
await useEmailStore.getState().refreshCurrentMailbox(client);
|
||||
expect(client.getEmails).toHaveBeenCalledWith(undefined, undefined, 2, 0, keyword, true, undefined, []);
|
||||
expect(client.getEmails).toHaveBeenCalledWith(undefined, 'owner', 2, 0, keyword, true, undefined, []);
|
||||
});
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('email-store tag counts stay in step with read state', () => {
|
||||
|
||||
// The server bulk-marks emails that are not in `state.emails`, so a local
|
||||
// delta would under-count: it has to refetch.
|
||||
expect(client.getTagCounts).toHaveBeenCalledWith(['ingsel', 'work']);
|
||||
expect(client.getTagCounts).toHaveBeenCalledWith(['ingsel', 'work'], undefined);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(useEmailStore.getState().tagCounts).toEqual({
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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 type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// Issue #1038: the user's login also reaches group accounts (each with its own
|
||||
// address and folders). Tagging is one user-level concept across all of them,
|
||||
// so the sidebar tag entry must list the tagged mail of the own account AND of
|
||||
// every group account - not only the account of the folder that happened to be
|
||||
// selected when the tag was clicked.
|
||||
|
||||
const label = 'work';
|
||||
const keyword = `$label:${label}`;
|
||||
|
||||
const ownInbox = { id: 'inbox', name: 'Inbox', role: 'inbox', isShared: false } as Mailbox;
|
||||
const ownArchive = { id: 'archive', name: 'Archive', role: 'archive', isShared: false } as Mailbox;
|
||||
const groupInbox = {
|
||||
id: 'group:inbox', originalId: 'inbox', name: 'Inbox', role: 'inbox',
|
||||
isShared: true, accountId: 'group', accountName: 'group@server.tld',
|
||||
} as Mailbox;
|
||||
|
||||
function email(id: string, mailbox: string, receivedAt: string, tagged = true, seen = true): Email {
|
||||
return {
|
||||
id, threadId: `thread-${id}`, mailboxIds: { [mailbox]: true },
|
||||
keywords: { ...(tagged ? { [keyword]: true } : {}), ...(seen ? { $seen: true } : {}) },
|
||||
subject: id, receivedAt,
|
||||
from: [{ email: 'sender@example.com' }], to: [],
|
||||
preview: '', size: 1, hasAttachment: false,
|
||||
} as Email;
|
||||
}
|
||||
|
||||
// Three tagged messages in the own account, one in the group account.
|
||||
const own = [
|
||||
email('own-1', 'inbox', '2026-09-14T00:00:00Z'),
|
||||
email('own-2', 'archive', '2026-09-12T00:00:00Z'),
|
||||
email('own-3', 'inbox', '2026-09-10T00:00:00Z'),
|
||||
];
|
||||
const untagged = email('own-plain', 'inbox', '2026-09-15T00:00:00Z', false);
|
||||
const group = [email('grp-1', 'inbox', '2026-09-13T00:00:00Z', true, false)];
|
||||
|
||||
function makeClient() {
|
||||
const rowsFor = (accountId?: string) => (accountId === 'group' ? group : [...own, untagged]);
|
||||
const getEmails = vi.fn<IJMAPClient['getEmails']>(async (
|
||||
mailboxId?: string, accountId?: string, limit = 50, position = 0, hasKeyword?: string,
|
||||
) => {
|
||||
const matches = rowsFor(accountId).filter(row =>
|
||||
(!mailboxId || row.mailboxIds[mailboxId]) && (!hasKeyword || row.keywords[hasKeyword]),
|
||||
);
|
||||
return {
|
||||
emails: matches.slice(position, position + limit),
|
||||
total: matches.length,
|
||||
hasMore: position + limit < matches.length,
|
||||
state: `state-${accountId ?? 'me'}`,
|
||||
};
|
||||
});
|
||||
const getTagCounts = vi.fn<IJMAPClient['getTagCounts']>(async (_ids, accountId?: string) =>
|
||||
accountId === 'group'
|
||||
? { [label]: { total: 1, unread: 1 } }
|
||||
: { [label]: { total: 3, unread: 0 } });
|
||||
const client = {
|
||||
getEmails,
|
||||
getTagCounts,
|
||||
getThreads: vi.fn(async () => []),
|
||||
batchMarkAsRead: vi.fn(async () => undefined),
|
||||
getAccountId: () => 'me',
|
||||
} as unknown as IJMAPClient & {
|
||||
getEmails: typeof getEmails;
|
||||
getTagCounts: typeof getTagCounts;
|
||||
batchMarkAsRead: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
describe('tag view across the own and group accounts (#1038)', () => {
|
||||
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: [{ id: label, label: 'Work', color: 'blue' }],
|
||||
} as never);
|
||||
useEmailStore.setState({
|
||||
...useEmailStore.getInitialState(),
|
||||
mailboxes: [ownInbox, ownArchive, groupInbox],
|
||||
});
|
||||
});
|
||||
|
||||
it('lists the tagged mail of every account while a group folder is selected', async () => {
|
||||
useEmailStore.setState({ selectedMailbox: groupInbox.id });
|
||||
useEmailStore.getState().selectKeyword(label);
|
||||
|
||||
await useEmailStore.getState().fetchEmails(client);
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
expect(state.emails.map(e => e.id)).toEqual(['own-1', 'grp-1', 'own-2', 'own-3']);
|
||||
expect(state.totalEmails).toBe(4);
|
||||
expect(state.hasMoreEmails).toBe(false);
|
||||
expect(state.emailListSync).toBeNull();
|
||||
// One keyword query per account, none constrained to a folder.
|
||||
expect(client.getEmails).toHaveBeenCalledWith(undefined, undefined, 50, 0, keyword, true, undefined, []);
|
||||
expect(client.getEmails).toHaveBeenCalledWith(undefined, 'group', 50, 0, keyword, true, undefined, []);
|
||||
});
|
||||
|
||||
it('lists the same set while an own folder is selected', async () => {
|
||||
useEmailStore.setState({ selectedMailbox: ownInbox.id });
|
||||
useEmailStore.getState().selectKeyword(label);
|
||||
|
||||
await useEmailStore.getState().fetchEmails(client);
|
||||
|
||||
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['own-1', 'grp-1', 'own-2', 'own-3']);
|
||||
});
|
||||
|
||||
it('stamps every row with its owning account so actions route back to it', async () => {
|
||||
useEmailStore.setState({ selectedMailbox: groupInbox.id });
|
||||
useEmailStore.getState().selectKeyword(label);
|
||||
await useEmailStore.getState().fetchEmails(client);
|
||||
|
||||
const byId = Object.fromEntries(useEmailStore.getState().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: 'Archive' });
|
||||
expect(byId['grp-1']).toMatchObject({
|
||||
sourceClientAccountId: 'login', sourceAccountId: 'group', accountLabel: 'group@server.tld',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a mixed selection read in each owning account', async () => {
|
||||
useEmailStore.setState({ selectedMailbox: groupInbox.id });
|
||||
useEmailStore.getState().selectKeyword(label);
|
||||
await useEmailStore.getState().fetchEmails(client);
|
||||
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('paginates the fan-out from the loaded position in every account', async () => {
|
||||
useSettingsStore.setState({ emailsPerPage: 2 } as never);
|
||||
useEmailStore.setState({ selectedMailbox: groupInbox.id });
|
||||
useEmailStore.getState().selectKeyword(label);
|
||||
await useEmailStore.getState().fetchEmails(client);
|
||||
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['own-1', 'grp-1', 'own-2']);
|
||||
expect(useEmailStore.getState().hasMoreEmails).toBe(true);
|
||||
|
||||
await useEmailStore.getState().loadMoreEmails(client);
|
||||
|
||||
expect(client.getEmails).toHaveBeenCalledWith(undefined, undefined, 2, 3, keyword, true, undefined, []);
|
||||
expect(client.getEmails).toHaveBeenCalledWith(undefined, 'group', 2, 3, keyword, true, undefined, []);
|
||||
expect(useEmailStore.getState().hasMoreEmails).toBe(false);
|
||||
});
|
||||
|
||||
it('refreshes the fan-out when a push reports a change in the group account only', async () => {
|
||||
useEmailStore.setState({ selectedMailbox: ownInbox.id });
|
||||
useEmailStore.getState().selectKeyword(label);
|
||||
await useEmailStore.getState().fetchEmails(client);
|
||||
client.getEmails.mockClear();
|
||||
|
||||
await useEmailStore.getState().handleStateChange({
|
||||
'@type': 'StateChange', changed: { group: { Email: '2' } },
|
||||
}, client);
|
||||
|
||||
expect(client.getEmails).toHaveBeenCalledWith(undefined, undefined, 50, 0, keyword, true, undefined, []);
|
||||
expect(client.getEmails).toHaveBeenCalledWith(undefined, 'group', 50, 0, keyword, true, undefined, []);
|
||||
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['own-1', 'grp-1', 'own-2', 'own-3']);
|
||||
});
|
||||
|
||||
it('sums the sidebar tag badge over every account', async () => {
|
||||
await useEmailStore.getState().fetchTagCounts(client);
|
||||
|
||||
expect(client.getTagCounts).toHaveBeenCalledWith([label], undefined);
|
||||
expect(client.getTagCounts).toHaveBeenCalledWith([label], 'group');
|
||||
expect(useEmailStore.getState().tagCounts[label]).toEqual({ total: 4, unread: 1 });
|
||||
});
|
||||
|
||||
it('still lists only the selected folder when no tag is selected', async () => {
|
||||
useEmailStore.setState({ selectedMailbox: groupInbox.id });
|
||||
await useEmailStore.getState().fetchEmails(client);
|
||||
|
||||
expect(client.getEmails).toHaveBeenCalledTimes(1);
|
||||
expect(client.getEmails).toHaveBeenCalledWith('inbox', 'group', 50, 0, undefined, true, undefined, []);
|
||||
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['grp-1']);
|
||||
});
|
||||
});
|
||||
+160
-45
@@ -9,7 +9,7 @@ import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty }
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import { resolveThreadRoute } from "@/lib/thread-routing";
|
||||
import type { ExternalSearchResult } from "@/lib/plugin-types";
|
||||
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
|
||||
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, 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";
|
||||
@@ -949,6 +949,74 @@ export async function buildUnifiedAccountClients(
|
||||
return built;
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts a tag view spans: the active/viewing login's own account plus
|
||||
* every group/shared owner whose folders that login can see (#1038).
|
||||
*
|
||||
* A tag keyword is set on messages in all of them, so the sidebar tag entry
|
||||
* fans out over the whole set instead of only the account of the folder that
|
||||
* happened to be selected. Built synchronously from the mailbox list the
|
||||
* sidebar already holds (own + delegated folders), so no extra round trip.
|
||||
* Every entry reaches the server through the same login client; shared
|
||||
* entries are flagged so JMAP requests target the owner's accountId.
|
||||
*/
|
||||
export function buildTagViewAccountClients(passedClient: IJMAPClient): UnifiedAccountClient[] {
|
||||
const client = resolveActionClient(passedClient);
|
||||
const mailboxes = resolveActionMailboxes();
|
||||
const state = useEmailStore.getState();
|
||||
const auth = useAuthStore.getState();
|
||||
// AccountEntry.id of the login this client belongs to (`getClientForAccount`
|
||||
// key), stamped as `sourceClientAccountId` so actions resolve the client.
|
||||
let clientAccountId: string | undefined;
|
||||
for (const [id, c] of auth.getAllConnectedClients()) {
|
||||
if (c === client) { clientAccountId = id; break; }
|
||||
}
|
||||
clientAccountId ??= state.viewingAccountId ?? auth.activeAccountId ?? client.getAccountId();
|
||||
const primaryJmapId = client.getAccountId();
|
||||
const account = useAccountStore.getState().getAccountById(clientAccountId);
|
||||
|
||||
const own = mailboxes.filter((m) => !m.isShared);
|
||||
const built: UnifiedAccountClient[] = [{
|
||||
accountId: clientAccountId,
|
||||
accountLabel: account?.label || account?.email || primaryJmapId,
|
||||
client,
|
||||
mailboxes: own,
|
||||
clientAccountId,
|
||||
jmapAccountId: primaryJmapId,
|
||||
isShared: false,
|
||||
}];
|
||||
|
||||
const sharedByOwner = new Map<string, Mailbox[]>();
|
||||
for (const m of mailboxes) {
|
||||
if (!m.isShared || !m.accountId || m.accountId === primaryJmapId) continue;
|
||||
const list = sharedByOwner.get(m.accountId) ?? [];
|
||||
list.push(m);
|
||||
sharedByOwner.set(m.accountId, list);
|
||||
}
|
||||
for (const [ownerId, ownerMailboxes] of sharedByOwner) {
|
||||
built.push({
|
||||
accountId: ownerId,
|
||||
accountLabel: ownerMailboxes.find((m) => m.accountName)?.accountName || ownerId,
|
||||
client,
|
||||
mailboxes: ownerMailboxes,
|
||||
clientAccountId,
|
||||
jmapAccountId: ownerId,
|
||||
isShared: true,
|
||||
});
|
||||
}
|
||||
return built;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
function isAggregateListView(): boolean {
|
||||
const s = useEmailStore.getState();
|
||||
return s.isUnifiedView || !!s.selectedKeyword;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a mailbox-list mutation (create/rename/delete/etc.), refresh the
|
||||
* cache for whichever account we're operating on. Writes the result to the
|
||||
@@ -1408,7 +1476,21 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
const tagIds = keywords.map(k => k.id);
|
||||
const counts = await resolveActionClient(client).getTagCounts(tagIds);
|
||||
// The badge counts what the tag view lists: messages in the own account
|
||||
// AND in the group/shared accounts this login reaches (#1038). Sum the
|
||||
// per-account counts; an account that fails just contributes nothing.
|
||||
const built = buildTagViewAccountClients(client);
|
||||
const perAccount = await Promise.allSettled(
|
||||
built.map((a) => a.client.getTagCounts(tagIds, a.isShared ? a.accountId : undefined)),
|
||||
);
|
||||
const counts: Record<string, { total: number; unread: number }> = {};
|
||||
for (const outcome of perAccount) {
|
||||
if (outcome.status !== 'fulfilled') continue;
|
||||
for (const [id, c] of Object.entries(outcome.value)) {
|
||||
const cur = counts[id] ?? { total: 0, unread: 0 };
|
||||
counts[id] = { total: cur.total + c.total, unread: cur.unread + c.unread };
|
||||
}
|
||||
}
|
||||
set({ tagCounts: counts });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tag counts:', error);
|
||||
@@ -1621,6 +1703,39 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Get emails per page from settings
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
// A tag view takes precedence over the selected folder. Tags span
|
||||
// folders AND accounts: the same keyword sits on messages in the user's
|
||||
// own account and in the group/shared accounts they can reach, so fan
|
||||
// out over all of them instead of querying only the account of the
|
||||
// folder that happened to be selected (#1038). A tag view has no role,
|
||||
// so it only follows the list order under the "all folders" scope (#718).
|
||||
const { selectedKeyword } = get();
|
||||
if (selectedKeyword) {
|
||||
const order = getMessageListOrderFor(null);
|
||||
const built = buildTagViewAccountClients(client);
|
||||
const result = await fetchTagEmails(built, `$label:${selectedKeyword}`, emailsPerPage, 0, order);
|
||||
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
|
||||
if (!isCurrentView()) return;
|
||||
set({
|
||||
emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
listOrder: order,
|
||||
// A server-side keyword filter cannot be delta-synced.
|
||||
emailListSync: null,
|
||||
threadEmailsCache: new Map(),
|
||||
expandedThreadIds: new Set(),
|
||||
isLoadingThread: null,
|
||||
isLoading: false,
|
||||
unifiedErrors: result.errors,
|
||||
});
|
||||
void get().fetchThreadEmailCounts(client);
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveClient = resolveActionClient(client);
|
||||
|
||||
// Find the mailbox to get its accountId (for shared folder support)
|
||||
@@ -1631,42 +1746,30 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
||||
const jmapMailboxId = mailbox?.originalId || targetMailboxId;
|
||||
|
||||
// Get emails per page from settings
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
// Build keyword filter if a tag is selected
|
||||
const { selectedKeyword } = get();
|
||||
const keywordFilter = selectedKeyword ? `$label:${selectedKeyword}` : undefined;
|
||||
|
||||
// Plugin-registered category tabs (Gmail-style) AND their resolved JMAP
|
||||
// filter fragment into the mailbox view. Tag views take precedence.
|
||||
const categoryFilter = selectedKeyword
|
||||
? null
|
||||
: useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
|
||||
// filter fragment into the mailbox view.
|
||||
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
|
||||
|
||||
// The configured list order (#718). A tag view spans folders and has no
|
||||
// role, so it only follows the order under the "all folders" scope.
|
||||
const order = getMessageListOrderFor(selectedKeyword ? null : mailbox?.role);
|
||||
// The configured list order (#718).
|
||||
const order = getMessageListOrderFor(mailbox?.role);
|
||||
|
||||
// When filtering by tag, omit the mailbox constraint so emails across
|
||||
// all folders that carry the tag are returned.
|
||||
const result = await effectiveClient.getEmails(
|
||||
selectedKeyword ? undefined : jmapMailboxId,
|
||||
jmapMailboxId,
|
||||
accountId,
|
||||
emailsPerPage,
|
||||
0,
|
||||
keywordFilter,
|
||||
undefined,
|
||||
true,
|
||||
categoryFilter ?? undefined,
|
||||
order,
|
||||
);
|
||||
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
|
||||
if (!isCurrentView()) return;
|
||||
// Only a plain folder list can be delta-synced; tag and category views
|
||||
// filter server-side, and a delta cannot tell which changed rows would
|
||||
// match that filter.
|
||||
// Only a plain folder list can be delta-synced; category views filter
|
||||
// server-side, and a delta cannot tell which changed rows would match
|
||||
// that filter.
|
||||
const emailListSync: EmailListSync | null =
|
||||
result.state && !selectedKeyword && !categoryFilter
|
||||
result.state && !categoryFilter
|
||||
? {
|
||||
state: result.state,
|
||||
accountId: accountId ?? effectiveClient.getAccountId(),
|
||||
@@ -1832,6 +1935,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
} 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
|
||||
// initial fetch ran (same keyword, no folder constraint, same order),
|
||||
// so pagination spans the own and group accounts alike (#1038).
|
||||
const built = buildTagViewAccountClients(client);
|
||||
result = await fetchTagEmails(
|
||||
built, `$label:${selectedKeyword}`, emailsPerPage, position, getMessageListOrderFor(null),
|
||||
);
|
||||
set({ unifiedErrors: result.errors });
|
||||
} else {
|
||||
// Load more from mailbox
|
||||
// Find the mailbox to get its accountId (for shared folder support)
|
||||
@@ -1842,21 +1954,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||
|
||||
// When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails).
|
||||
// Category tabs (plugin-registered) must filter pagination the same
|
||||
// way as the initial fetch or pages would mix categories.
|
||||
const categoryFilter = selectedKeyword
|
||||
? null
|
||||
: useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
|
||||
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
|
||||
result = await effectiveClient.getEmails(
|
||||
selectedKeyword ? undefined : jmapMailboxId,
|
||||
jmapMailboxId,
|
||||
accountId,
|
||||
emailsPerPage,
|
||||
position,
|
||||
selectedKeyword ? `$label:${selectedKeyword}` : undefined,
|
||||
undefined,
|
||||
true,
|
||||
categoryFilter ?? undefined,
|
||||
getMessageListOrderFor(selectedKeyword ? null : mailbox?.role),
|
||||
getMessageListOrderFor(mailbox?.role),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2006,7 +2115,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// The email's current mailbox drives the junk auto-permanent-delete rule.
|
||||
// In unified view it comes from the email's own folders (matching the
|
||||
// unified role), not the active account's selected mailbox.
|
||||
const currentMailbox = get().isUnifiedView
|
||||
const currentMailbox = isAggregateListView()
|
||||
? (mailboxes.find(mb => emailInMailbox(email, mb) && mb.role === get().unifiedRole)
|
||||
?? mailboxes.find(mb => emailInMailbox(email, mb)))
|
||||
: mailboxes.find(mb => mb.id === get().selectedMailbox);
|
||||
@@ -2332,14 +2441,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
|
||||
try {
|
||||
const { emails, selectedMailbox, isUnifiedView } = get();
|
||||
const { emails, selectedMailbox } = get();
|
||||
const mailboxes = resolveActionMailboxes();
|
||||
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
|
||||
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
|
||||
const idSet = new Set(emailIds);
|
||||
const affected = emails.filter(e => idSet.has(e.id));
|
||||
|
||||
if (isUnifiedView) {
|
||||
if (isAggregateListView()) {
|
||||
// In unified view, emails may span accounts – group by owning JMAP account
|
||||
// and dispatch through the login client that can reach each one. The login
|
||||
// client is keyed by `sourceClientAccountId` (a real AccountEntry.id); the
|
||||
@@ -2896,7 +3005,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
try {
|
||||
const emailIdsArray = Array.from(selectedEmailIds);
|
||||
|
||||
if (get().isUnifiedView) {
|
||||
if (isAggregateListView()) {
|
||||
// Group by owning JMAP account; dispatch through the reaching login client.
|
||||
const bySource = new Map<string, { clientAccountId?: string; ids: string[] }>();
|
||||
for (const emailId of emailIdsArray) {
|
||||
@@ -3109,7 +3218,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
try {
|
||||
const emailIdsArray = Array.from(selectedEmailIds);
|
||||
|
||||
if (get().isUnifiedView) {
|
||||
if (isAggregateListView()) {
|
||||
// Group by owning JMAP account; dispatch through the reaching login client.
|
||||
const destMailbox = resolveActionMailboxes().find(mb => mb.id === toMailboxId);
|
||||
const jmapDestId = destMailbox?.originalId || toMailboxId;
|
||||
@@ -3228,7 +3337,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// The email's current mailbox is what undo restores it to. In unified view
|
||||
// derive it from the email's own folders (preferring the unified role),
|
||||
// otherwise the active account's selected mailbox.
|
||||
const currentMailbox = get().isUnifiedView
|
||||
const currentMailbox = isAggregateListView()
|
||||
? (mailboxes.find(mb => emailInMailbox(email, mb) && mb.role === get().unifiedRole)
|
||||
?? mailboxes.find(mb => emailInMailbox(email, mb)))
|
||||
: mailboxes.find(m => m.id === get().selectedMailbox);
|
||||
@@ -3592,14 +3701,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// changed - and only re-queried when the delta cannot be applied.
|
||||
const sync = get().emailListSync;
|
||||
const syncedAccountEmailState = sync ? change.changed[sync.accountId]?.Email : undefined;
|
||||
if (accountChanges?.Email || syncedAccountEmailState) {
|
||||
// A tag view and the sidebar tag badges span the own AND the group/shared
|
||||
// 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) {
|
||||
const applied = syncedAccountEmailState
|
||||
? await get().applyEmailDelta(client, syncedAccountEmailState)
|
||||
: false;
|
||||
if (!applied) {
|
||||
await get().refreshCurrentMailbox(client);
|
||||
}
|
||||
if (accountChanges?.Email) {
|
||||
if (anyEmailChanged) {
|
||||
get().fetchTagCounts(client);
|
||||
}
|
||||
}
|
||||
@@ -3734,11 +3847,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||
if (selectedKeyword) {
|
||||
// Match fetchEmails: tag views take precedence and query the label
|
||||
// across all folders. They cannot establish a folder delta baseline.
|
||||
result = await effectiveClient.getEmails(
|
||||
undefined, accountId, emailsPerPage, 0, `$label:${selectedKeyword}`,
|
||||
true, undefined, getMessageListOrderFor(null),
|
||||
// across all folders and across the own + group accounts (#1038).
|
||||
// They cannot establish a folder delta baseline.
|
||||
const built = buildTagViewAccountClients(client);
|
||||
result = await fetchTagEmails(
|
||||
built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null),
|
||||
);
|
||||
unifiedErrors = result.errors;
|
||||
} else if (hasFilters || searchQuery) {
|
||||
// A refresh while a search is active must re-run it under the
|
||||
// search's own folder scope, which is independent of selectedMailbox.
|
||||
@@ -3968,7 +4083,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// selected-mailbox shared-folder logic. (#281)
|
||||
const threadEmail = get().emails.find(e => e.threadId === threadId);
|
||||
const route = resolveThreadRoute({
|
||||
isUnifiedView: get().isUnifiedView,
|
||||
isUnifiedView: isAggregateListView(),
|
||||
ref: threadEmail,
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
@@ -3983,7 +4098,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
// Re-stamp the source reference so actions on thread emails resolve to the
|
||||
// right account (the fetched objects don't carry it).
|
||||
if (get().isUnifiedView && threadEmail) {
|
||||
if (isAggregateListView() && threadEmail) {
|
||||
for (const e of emails) {
|
||||
e.accountId = threadEmail.accountId;
|
||||
e.accountLabel = threadEmail.accountLabel;
|
||||
|
||||
Reference in New Issue
Block a user