diff --git a/lib/__tests__/unified-mailbox-tags.test.ts b/lib/__tests__/unified-mailbox-tags.test.ts new file mode 100644 index 000000000..2504c32bf --- /dev/null +++ b/lib/__tests__/unified-mailbox-tags.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { 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 & { 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'); + }); +}); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 02b225cca..2e009d8d9 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -170,7 +170,13 @@ export interface IJMAPClient { getEmailsInMailbox(mailboxId: string): Promise; getEmail(emailId: string, accountId?: string): Promise; getSomeEmails(emailsId: string[], accountId?: string): Promise - getTagCounts(tagIds: string[]): Promise>; + /** + * 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>; /** * Enumerate account keywords for extensions. Servers supporting Keyword/get * can return exact counts and provider-label metadata; other servers use the diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 16892686a..e8b641a0a 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1736,9 +1736,10 @@ export class JMAPClient implements IJMAPClient { return allEmails; } - async getTagCounts(tagIds: string[]): Promise> { + async getTagCounts(tagIds: string[], accountId?: string): Promise> { if (tagIds.length === 0) return {}; const result: Record = {}; + 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: [ diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index d60bb4745..f9279bd62 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -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:` 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 { + const errors = new Map(); + + type AccountResult = { + account: UnifiedAccountClient; + result: { emails: Email[]; total: number; hasMore: boolean }; + } | null; + + const promises = accounts.map(async (account): Promise => { + const jmapAccountId = account.isShared ? account.accountId : undefined; + try { + const result = await account.client.getEmails( + undefined, jmapAccountId, limit, position, keyword, true, undefined, order, + ); + 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. diff --git a/stores/__tests__/email-store-label-refresh.test.ts b/stores/__tests__/email-store-label-refresh.test.ts index 4f1222165..da5be21e3 100644 --- a/stores/__tests__/email-store-label-refresh.test.ts +++ b/stores/__tests__/email-store-label-refresh.test.ts @@ -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, []); }); diff --git a/stores/__tests__/email-store-tag-counts.test.ts b/stores/__tests__/email-store-tag-counts.test.ts index 07fb9eb2c..b99ea9fdf 100644 --- a/stores/__tests__/email-store-tag-counts.test.ts +++ b/stores/__tests__/email-store-tag-counts.test.ts @@ -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({ diff --git a/stores/__tests__/email-store-tag-view-accounts.test.ts b/stores/__tests__/email-store-tag-view-accounts.test.ts new file mode 100644 index 000000000..c1a896315 --- /dev/null +++ b/stores/__tests__/email-store-tag-view-accounts.test.ts @@ -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(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(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; + }; + return client; +} + +describe('tag view across the own and group accounts (#1038)', () => { + let client: ReturnType; + + beforeEach(() => { + client = makeClient(); + useAuthStore.setState({ + activeAccountId: 'login', + getClientForAccount: ((id: string) => (id === 'login' ? client : undefined)) as never, + getAllConnectedClients: (() => new Map([['login', client]])) as never, + } as never); + useMessageListTabsStore.setState(useMessageListTabsStore.getInitialState()); + useSettingsStore.setState({ + emailsPerPage: 50, messageListOrder: [], messageListOrderScope: 'inbox', + emailKeywords: [{ 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']); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index d1601c9c2..ac048655c 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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(); + 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((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 = {}; + 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((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((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((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((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((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((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((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(); for (const emailId of emailIdsArray) { @@ -3109,7 +3218,7 @@ export const useEmailStore = create((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((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((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((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((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((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;