fix: route all counter updates to the email's own account in aggregate views

Extend the counter-routing fix beyond markAsRead to every optimistic mailbox
counter update, so a different account's email never adjusts the active
account's folder counters (JMAP ids can collide across accounts).

- Add applyBatchMailboxCounterUpdate() + applyDeleteCounters() and apply the
  per-account routing to: deleteEmail (trash + permanent), moveToMailbox,
  moveEmailsToMailbox, batchMarkAsRead, batchDelete, and markThreadAsRead.
- markAsSpam/batchMarkAsSpam/batchMoveToMailbox don't touch counters (rely on
  refresh) and folder-level ops (rename/empty/markMailboxAsRead) are already
  account-scoped — left as-is.
- Test: batchMarkAsRead adjusts each account's counter in its own list.
This commit is contained in:
Stefan Hildebrandt
2026-06-23 19:16:23 +02:00
parent befee332d2
commit fa3c57467b
2 changed files with 175 additions and 154 deletions
@@ -53,6 +53,7 @@ function makeClient() {
markAsRead: vi.fn().mockResolvedValue(undefined), markAsRead: vi.fn().mockResolvedValue(undefined),
toggleStar: vi.fn().mockResolvedValue(undefined), toggleStar: vi.fn().mockResolvedValue(undefined),
moveEmail: vi.fn().mockResolvedValue(undefined), moveEmail: vi.fn().mockResolvedValue(undefined),
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
} as unknown as IJMAPClient; } as unknown as IJMAPClient;
} }
@@ -146,6 +147,27 @@ describe('unified-view single-email action routing (#281)', () => {
expect(s.accountMailboxes['account-a'][0].unreadEmails).toBe(5); // active list untouched expect(s.accountMailboxes['account-a'][0].unreadEmails).toBe(5); // active list untouched
}); });
it('batchMarkAsRead adjusts each emails own account counter (cross-account)', async () => {
useEmailStore.setState({
mailboxes: [makeMailbox({ id: 'inbox', role: 'inbox', unreadEmails: 5 })],
accountMailboxes: {
'account-a': [makeMailbox({ id: 'inbox', role: 'inbox', unreadEmails: 5 })],
'account-b': [makeMailbox({ id: 'inbox', role: 'inbox', unreadEmails: 3 })],
},
emails: [
makeEmail({ id: 'a1', sourceClientAccountId: 'account-a', sourceAccountId: 'account-a', keywords: {}, mailboxIds: { inbox: true } }),
makeEmail({ id: 'b1', sourceClientAccountId: 'account-b', sourceAccountId: 'account-b', keywords: {}, mailboxIds: { inbox: true } }),
],
selectedEmailIds: new Set(['a1', 'b1']),
});
await useEmailStore.getState().batchMarkAsRead(activeClient, true);
const s = useEmailStore.getState();
expect(s.mailboxes[0].unreadEmails).toBe(4); // active account: -1
expect(s.accountMailboxes['account-b'][0].unreadEmails).toBe(2); // account-b: -1
});
it('routes a shared/group email through the delegating login client + owner accountId', async () => { it('routes a shared/group email through the delegating login client + owner accountId', async () => {
await useEmailStore.getState().markAsRead(activeClient, 'email-shared', true); await useEmailStore.getState().markAsRead(activeClient, 'email-shared', true);
// Reached via account-a's client (the active one), targeting the owner account. // Reached via account-a's client (the active one), targeting the owner account.
+153 -154
View File
@@ -550,6 +550,60 @@ function applyMailboxCounterUpdate(
return { mailboxes: state.mailboxes.map(adjust) }; return { mailboxes: state.mailboxes.map(adjust) };
} }
// Batch variant of applyMailboxCounterUpdate: a set of emails may span accounts,
// so group them by the list that holds them and run `mapMailbox` per list using
// only that group's emails. Each account's counters are adjusted in its own list
// (active account → `mailboxes`, others → `accountMailboxes[sourceClientAccountId]`),
// avoiding cross-account id collisions. (#281)
function applyBatchMailboxCounterUpdate(
state: { mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
emails: Array<{ sourceClientAccountId?: string }>,
mapMailbox: (mb: Mailbox, emailsForList: Array<{ sourceClientAccountId?: string }>) => Mailbox,
): { mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> } {
const activeId = useAuthStore.getState().activeAccountId;
const ACTIVE = '__active__';
const groups = new Map<string, Array<{ sourceClientAccountId?: string }>>();
for (const e of emails) {
const k = e.sourceClientAccountId && e.sourceClientAccountId !== activeId ? e.sourceClientAccountId : ACTIVE;
const g = groups.get(k);
if (g) g.push(e); else groups.set(k, [e]);
}
let mailboxes = state.mailboxes;
const accountMailboxes = { ...state.accountMailboxes };
for (const [k, es] of groups) {
if (k === ACTIVE) {
mailboxes = mailboxes.map((mb) => mapMailbox(mb, es));
} else if (accountMailboxes[k]) {
accountMailboxes[k] = accountMailboxes[k].map((mb) => mapMailbox(mb, es));
}
}
return { mailboxes, accountMailboxes };
}
// Per-mailbox counter map (for applyBatchMailboxCounterUpdate) for removing a
// group of emails from a folder: decrement total (and unread for unseen) for
// each group email that lives in the mailbox.
function applyDeleteCounters(
mailbox: Mailbox,
group: Array<{ sourceClientAccountId?: string }>,
): Mailbox {
let dTotal = 0;
let dUnread = 0;
for (const email of group as Email[]) {
if (emailInMailbox(email, mailbox)) {
dTotal--;
if (!email.keywords?.$seen) dUnread--;
}
}
return dTotal === 0 && dUnread === 0 ? mailbox : {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails + dTotal),
unreadEmails: Math.max(0, mailbox.unreadEmails + dUnread),
totalThreads: Math.max(0, mailbox.totalThreads + dTotal),
unreadThreads: Math.max(0, mailbox.unreadThreads + dUnread),
};
}
// Find the trash mailbox for a given account scope. Prefers JMAP role, but // Find the trash mailbox for a given account scope. Prefers JMAP role, but
// falls back to name matching ("trash" / "deleted") so users with custom or // falls back to name matching ("trash" / "deleted") so users with custom or
// pre-existing folders (e.g. "Deleted Items") aren't silently destroyed. // pre-existing folders (e.g. "Deleted Items") aren't silently destroyed.
@@ -1217,40 +1271,38 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// After marking read in the same request, the email arrives in trash as read. // After marking read in the same request, the email arrives in trash as read.
const arrivesUnread = isUnread && !alsoMarkRead; const arrivesUnread = isUnread && !alsoMarkRead;
// Remove from local state (email moved to trash, not in current view) // Remove from local state (email moved to trash, not in current view).
// Counter changes go to the email's own account list (#281). Source and
// trash live in the same account.
set((state) => { set((state) => {
let updatedMailboxes = state.mailboxes; const mailboxPatch = email.mailboxIds
? applyMailboxCounterUpdate(state, email, (mailbox) => {
// Update counters for source mailbox (email leaving) if (emailInMailbox(email, mailbox)) {
if (email.mailboxIds) { return {
updatedMailboxes = state.mailboxes.map(mailbox => { ...mailbox,
if (emailInMailbox(email, mailbox)) { totalEmails: Math.max(0, mailbox.totalEmails - 1),
return { unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
...mailbox, totalThreads: Math.max(0, mailbox.totalThreads - 1),
totalEmails: Math.max(0, mailbox.totalEmails - 1), unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads,
unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails, };
totalThreads: Math.max(0, mailbox.totalThreads - 1), }
unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads if (mailbox.id === trashMailbox.id) {
}; return {
} ...mailbox,
// Update trash mailbox counters (email arriving) totalEmails: mailbox.totalEmails + 1,
if (mailbox.id === trashMailbox.id) { unreadEmails: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
return { totalThreads: mailbox.totalThreads + 1,
...mailbox, unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads,
totalEmails: mailbox.totalEmails + 1, };
unreadEmails: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails, }
totalThreads: mailbox.totalThreads + 1, return mailbox;
unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads })
}; : {};
}
return mailbox;
});
}
return { return {
emails: state.emails.filter(e => e.id !== emailId), emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: getNextSelectedEmail(state, emailId), selectedEmail: getNextSelectedEmail(state, emailId),
mailboxes: updatedMailboxes ...mailboxPatch,
}; };
}); });
return; return;
@@ -1264,42 +1316,26 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Permanent delete // Permanent delete
await effectiveClient.deleteEmail(emailId); await effectiveClient.deleteEmail(emailId);
// Remove from local state and update mailbox counters if needed // Remove from local state and update mailbox counters (in the email's own
// account list). Unread emails also decrement the unread counters. (#281)
set((state) => { set((state) => {
let updatedMailboxes = state.mailboxes; const mailboxPatch = email.mailboxIds
? applyMailboxCounterUpdate(state, email, (mailbox) =>
// If the email was unread, decrement the unread counters emailInMailbox(email, mailbox)
if (isUnread && email.mailboxIds) { ? {
updatedMailboxes = state.mailboxes.map(mailbox => { ...mailbox,
if (emailInMailbox(email, mailbox)) { totalEmails: Math.max(0, mailbox.totalEmails - 1),
return { unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
...mailbox, totalThreads: Math.max(0, mailbox.totalThreads - 1),
totalEmails: Math.max(0, mailbox.totalEmails - 1), unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads,
unreadEmails: Math.max(0, mailbox.unreadEmails - 1), }
totalThreads: Math.max(0, mailbox.totalThreads - 1), : mailbox)
unreadThreads: Math.max(0, mailbox.unreadThreads - 1) : {};
};
}
return mailbox;
});
} else if (email.mailboxIds) {
// If email was read, only decrement total counters
updatedMailboxes = state.mailboxes.map(mailbox => {
if (emailInMailbox(email, mailbox)) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
totalThreads: Math.max(0, mailbox.totalThreads - 1)
};
}
return mailbox;
});
}
return { return {
emails: state.emails.filter(e => e.id !== emailId), emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: getNextSelectedEmail(state, emailId), selectedEmail: getNextSelectedEmail(state, emailId),
mailboxes: updatedMailboxes ...mailboxPatch,
}; };
}); });
} catch (error) { } catch (error) {
@@ -1415,7 +1451,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (!email) return; if (!email) return;
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : [];
// In unified view route to the email's own account (client + that // In unified view route to the email's own account (client + that
// account's mailbox list, where the destination id lives); otherwise the // account's mailbox list, where the destination id lives); otherwise the
@@ -1428,14 +1463,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
await actionClient.moveEmail(emailId, jmapDestId, accountId); await actionClient.moveEmail(emailId, jmapDestId, accountId);
set((state) => { set((state) => {
const updatedMailboxes = state.mailboxes.map(mailbox => { // Counter changes go to the email's own account list; source and
if (currentMailboxIds.includes(mailbox.id) || (!mailbox.isShared && mailbox.originalId ? currentMailboxIds.includes(mailbox.originalId) : false)) { // destination live in the same account. (#281)
const mailboxPatch = applyMailboxCounterUpdate(state, email, (mailbox) => {
if (emailInMailbox(email, mailbox)) {
return { return {
...mailbox, ...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1), totalEmails: Math.max(0, mailbox.totalEmails - 1),
unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails, unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
totalThreads: Math.max(0, mailbox.totalThreads - 1), totalThreads: Math.max(0, mailbox.totalThreads - 1),
unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads,
}; };
} }
if (mailbox.id === destinationMailboxId) { if (mailbox.id === destinationMailboxId) {
@@ -1444,7 +1481,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
totalEmails: mailbox.totalEmails + 1, totalEmails: mailbox.totalEmails + 1,
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails, unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
totalThreads: mailbox.totalThreads + 1, totalThreads: mailbox.totalThreads + 1,
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads,
}; };
} }
return mailbox; return mailbox;
@@ -1453,7 +1490,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return { return {
emails: state.emails.filter(e => e.id !== emailId), emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: getNextSelectedEmail(state, emailId), selectedEmail: getNextSelectedEmail(state, emailId),
mailboxes: updatedMailboxes ...mailboxPatch,
}; };
}); });
} catch (error) { } catch (error) {
@@ -1506,39 +1543,43 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Adjust counters and drop moved emails from the current view. // Adjust counters and drop moved emails from the current view.
let unreadDelta = 0; let unreadDelta = 0;
const sourceMailboxIds = new Set<string>();
for (const e of affected) { for (const e of affected) {
if (!e.keywords?.$seen) unreadDelta += 1; if (!e.keywords?.$seen) unreadDelta += 1;
if (e.mailboxIds) for (const mid of Object.keys(e.mailboxIds)) sourceMailboxIds.add(mid);
} }
const movedCount = affected.length; const movedCount = affected.length;
set((state) => ({ set((state) => {
emails: state.emails.filter(e => !idSet.has(e.id)), // Decrement source folders per the email's own account list (#281).
selectedEmail: state.selectedEmail && idSet.has(state.selectedEmail.id) ? null : state.selectedEmail, const patch = applyBatchMailboxCounterUpdate(state, affected, (mb, group) => {
selectedEmailIds: (() => { let dTotal = 0;
const next = new Set(state.selectedEmailIds); let dUnread = 0;
for (const id of idSet) next.delete(id); for (const e of group as Email[]) {
return next; if (emailInMailbox(e, mb)) {
})(), dTotal--;
mailboxes: state.mailboxes.map(mb => { if (!e.keywords?.$seen) dUnread--;
if (sourceMailboxIds.has(mb.id) || (!mb.isShared && mb.originalId ? sourceMailboxIds.has(mb.originalId) : false)) { }
return {
...mb,
totalEmails: Math.max(0, mb.totalEmails - movedCount),
unreadEmails: Math.max(0, mb.unreadEmails - unreadDelta),
};
} }
if (mb.id === destinationMailboxId) { return dTotal === 0 && dUnread === 0 ? mb : {
return { ...mb,
...mb, totalEmails: Math.max(0, mb.totalEmails + dTotal),
totalEmails: mb.totalEmails + movedCount, unreadEmails: Math.max(0, mb.unreadEmails + dUnread),
unreadEmails: mb.unreadEmails + unreadDelta, };
}; });
} // The destination folder (picked from the active/viewing list) gains them.
return mb; patch.mailboxes = patch.mailboxes.map(mb => mb.id === destinationMailboxId
}), ? { ...mb, totalEmails: mb.totalEmails + movedCount, unreadEmails: mb.unreadEmails + unreadDelta }
})); : mb);
return {
emails: state.emails.filter(e => !idSet.has(e.id)),
selectedEmail: state.selectedEmail && idSet.has(state.selectedEmail.id) ? null : state.selectedEmail,
selectedEmailIds: (() => {
const next = new Set(state.selectedEmailIds);
for (const id of idSet) next.delete(id);
return next;
})(),
...patch,
};
});
} catch (error) { } catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to move emails' }); set({ error: error instanceof Error ? error.message : 'Failed to move emails' });
throw error; throw error;
@@ -1928,7 +1969,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Batch operations // Batch operations
batchMarkAsRead: async (client, read) => { batchMarkAsRead: async (client, read) => {
const { selectedEmailIds, emails } = get(); const { selectedEmailIds, emails } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailIds.size === 0) return; if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
@@ -1965,29 +2005,25 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
: email : email
); );
// Update mailbox counters // Update mailbox counters per the email's own account list (#281).
const affectedEmails = emails.filter(e => selectedEmailIds.has(e.id)); const affectedEmails = emails.filter(e => selectedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => { const mailboxPatch = applyBatchMailboxCounterUpdate(get(), affectedEmails, (mailbox, group) => {
let deltaUnread = 0; let deltaUnread = 0;
affectedEmails.forEach(email => { for (const email of group as Email[]) {
if (emailInMailbox(email, mailbox)) { if (emailInMailbox(email, mailbox) && (email.keywords?.$seen ?? false) !== read) {
const wasRead = email.keywords?.$seen; deltaUnread += read ? -1 : 1;
if (wasRead !== read) {
deltaUnread += read ? -1 : 1;
}
} }
}); }
return deltaUnread === 0 ? mailbox : {
return {
...mailbox, ...mailbox,
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnread), unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnread),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnread) unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnread),
}; };
}); });
set({ set({
emails: updatedEmails, emails: updatedEmails,
mailboxes: updatedMailboxes, ...mailboxPatch,
selectedEmailIds: new Set(), selectedEmailIds: new Set(),
isLoading: false isLoading: false
}); });
@@ -2079,26 +2115,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (movedEmailIds.size < emailIdsArray.length) { if (movedEmailIds.size < emailIdsArray.length) {
const deletedEmails = emails.filter(e => movedEmailIds.has(e.id)); const deletedEmails = emails.filter(e => movedEmailIds.has(e.id));
const remainingEmails = emails.filter(e => !movedEmailIds.has(e.id)); const remainingEmails = emails.filter(e => !movedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => { const mailboxPatch = applyBatchMailboxCounterUpdate(get(), deletedEmails, applyDeleteCounters);
let deltaTotalEmails = 0;
let deltaUnreadEmails = 0;
deletedEmails.forEach(email => {
if (emailInMailbox(email, mailbox)) {
deltaTotalEmails--;
if (!email.keywords?.$seen) deltaUnreadEmails--;
}
});
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails),
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails),
totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails),
};
});
set({ set({
emails: remainingEmails, emails: remainingEmails,
mailboxes: updatedMailboxes, ...mailboxPatch,
selectedEmailIds: new Set(), selectedEmailIds: new Set(),
selectedEmail: null, selectedEmail: null,
isLoading: false, isLoading: false,
@@ -2111,33 +2131,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Remove deleted emails from local state // Remove deleted emails from local state
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id)); const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
// Update mailbox counters // Update mailbox counters per the email's own account list (#281).
const deletedEmails = emails.filter(e => selectedEmailIds.has(e.id)); const deletedEmails = emails.filter(e => selectedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => { const mailboxPatch = applyBatchMailboxCounterUpdate(get(), deletedEmails, applyDeleteCounters);
let deltaTotalEmails = 0;
let deltaUnreadEmails = 0;
deletedEmails.forEach(email => {
if (emailInMailbox(email, mailbox)) {
deltaTotalEmails--;
if (!email.keywords?.$seen) {
deltaUnreadEmails--;
}
}
});
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails),
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails),
totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails)
};
});
set({ set({
emails: remainingEmails, emails: remainingEmails,
mailboxes: updatedMailboxes, ...mailboxPatch,
selectedEmailIds: new Set(), selectedEmailIds: new Set(),
selectedEmail: null, selectedEmail: null,
isLoading: false isLoading: false
@@ -2795,15 +2795,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
)); ));
} }
// Update mailbox unread counters // Update mailbox unread counters in the thread's own account list (#281).
const affectedEmails = state.emails.filter(e => unreadSet.has(e.id)); const affectedEmails = state.emails.filter(e => unreadSet.has(e.id));
const updatedMailboxes = state.mailboxes.map(mailbox => { const mailboxPatch = applyBatchMailboxCounterUpdate(state, affectedEmails, (mailbox, group) => {
let delta = 0; let delta = 0;
for (const email of affectedEmails) { for (const email of group as Email[]) {
if (emailInMailbox(email, mailbox)) delta -= 1; if (emailInMailbox(email, mailbox)) delta -= 1;
} }
if (delta === 0) return mailbox; return delta === 0 ? mailbox : {
return {
...mailbox, ...mailbox,
unreadEmails: Math.max(0, mailbox.unreadEmails + delta), unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
unreadThreads: Math.max(0, mailbox.unreadThreads + delta), unreadThreads: Math.max(0, mailbox.unreadThreads + delta),
@@ -2813,7 +2812,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return { return {
emails: updatedEmails, emails: updatedEmails,
threadEmailsCache: newCache, threadEmailsCache: newCache,
mailboxes: updatedMailboxes, ...mailboxPatch,
selectedEmail: state.selectedEmail && unreadSet.has(state.selectedEmail.id) selectedEmail: state.selectedEmail && unreadSet.has(state.selectedEmail.id)
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } } ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } }
: state.selectedEmail, : state.selectedEmail,