fix: list tagged mail from every account in the tag view #1038

This commit is contained in:
Linus Rath
2026-09-17 22:26:52 +02:00
parent df966693b0
commit fedfc89d44
8 changed files with 551 additions and 56 deletions
+106
View File
@@ -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');
});
});
+7 -1
View File
@@ -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
View File
@@ -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: [
+72
View File
@@ -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
View File
@@ -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;