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
+32 -25
View File
@@ -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;
}