diff --git a/lib/__tests__/unified-mailbox.test.ts b/lib/__tests__/unified-mailbox.test.ts index a03c90b3..2fb1780a 100644 --- a/lib/__tests__/unified-mailbox.test.ts +++ b/lib/__tests__/unified-mailbox.test.ts @@ -120,16 +120,17 @@ describe('fetchUnifiedEmails', () => { expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() }); }); - it('CHARACTERISATION: mutates the source email objects in place (shared reference)', async () => { + it('does NOT mutate the source email objects (decorates copies)', async () => { const original = makeEmail('m1', '2026-01-01T00:00:00Z'); const acc = makeAccount( { accountId: 'A', accountLabel: 'Label A', mailboxes: [makeMailbox({ role: 'inbox' })] }, { getEmails: vi.fn(async (): Promise => ({ emails: [original], total: 1, hasMore: false })) }, ); - await fetchUnifiedEmails([acc], 'inbox', 20, 0); - // The very object passed back by the client was mutated, not a copy. - expect(original.accountId).toBe('A'); - expect(original.accountLabel).toBe('Label A'); + const res = await fetchUnifiedEmails([acc], 'inbox', 20, 0); + // The returned email carries the account info, but the client's object is untouched. + expect(res.emails[0]).toMatchObject({ id: 'm1', accountId: 'A', accountLabel: 'Label A' }); + expect('accountId' in original).toBe(false); + expect('accountLabel' in original).toBe(false); }); }); diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index 9bd0c11d..5789068a 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -119,16 +119,19 @@ export async function fetchUnifiedEmails( const { account, result } = outcome.value; - // Decorate each email with the source account info. - for (const email of result.emails) { - email.accountId = account.accountId; - email.accountLabel = account.accountLabel; - email.sourceClientAccountId = account.clientAccountId; - email.sourceAccountId = account.jmapAccountId; - email.sourceFolder = resolveSourceFolderName(email, account.mailboxes); - } + // Decorate each email with the source account info. The per-account client + // returns shared object references; decorate shallow copies instead of + // mutating them in place so retained callers/snapshots aren't corrupted. + 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(result.emails); + mergedEmails = mergedEmails.concat(decorated); totalSum += result.total; if (result.hasMore) { anyHasMore = true; @@ -247,14 +250,16 @@ async function fanOutUnifiedQuery( for (const outcome of results) { if (outcome.status !== 'fulfilled' || outcome.value === null) continue; const { account, result } = outcome.value; - for (const email of result.emails) { - email.accountId = account.accountId; - email.accountLabel = account.accountLabel; - email.sourceClientAccountId = account.clientAccountId; - email.sourceAccountId = account.jmapAccountId; - email.sourceFolder = resolveSourceFolderName(email, account.mailboxes); - } - mergedEmails = mergedEmails.concat(result.emails); + // Decorate shallow copies, not the shared client-returned objects. + 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; } @@ -383,14 +388,16 @@ async function fanOutCrossQuery( for (const outcome of results) { if (outcome.status !== 'fulfilled' || outcome.value === null) continue; const { account, result } = outcome.value; - for (const email of result.emails) { - email.accountId = account.accountId; - email.accountLabel = account.accountLabel; - email.sourceClientAccountId = account.clientAccountId; - email.sourceAccountId = account.jmapAccountId; - email.sourceFolder = resolveSourceFolderName(email, account.mailboxes); - } - mergedEmails = mergedEmails.concat(result.emails); + // Decorate shallow copies, not the shared client-returned objects. + 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; }