fix: stop unified-mailbox from mutating client-returned email objects

fetchUnifiedEmails, fanOutUnifiedQuery and the cross-account fanOutCrossQuery
stamped accountId/accountLabel/source* directly onto each email object
returned by the per-account client. Those objects are shared references;
mutating them in place could surprise any caller that retained them (and
corrupt an account-state snapshot). Decorate shallow copies instead, at all
three fan-out sites.

The original fix/unified-mailbox-no-mutation branch predated the cross-account
"All accounts" feature and only covered two sites; this re-applies the fix to
main's current code, including the third (shared/group) fan-out site, and
preserves all five stamped fields. Flips the characterisation test to assert
the client's object is left untouched.
This commit is contained in:
Stefan Hildebrandt
2026-06-25 00:25:00 +02:00
committed by Linus Rath
parent de56229ef2
commit 70aaf0aac1
2 changed files with 38 additions and 30 deletions
+6 -5
View File
@@ -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<FetchResult> => ({ 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);
});
});