From fac3bdf31c14eedbb111ddce0a29d46c074b9846 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Sat, 19 Sep 2026 08:59:21 +0200 Subject: [PATCH] fix: scope batch selection to the owning account Email ids are unique only within their account, so in a view that merges several - the unified mailbox, and since #1038 the tag view - two unrelated messages can share one. Selection was a set of bare ids resolved with `emails.filter(e => selected.has(e.id))`, so ticking one row also ticked its namesake in another account, and batch delete, move, archive or mark-read then acted on a message nobody selected. Reported by @alyyousuf7 on #1013, where `eiaaaabc` exists in two accounts at once. Selection is now keyed by `emailKeyFor` - the same account scoping #1013 introduced for threads - and the actions take the email rather than its id, so a caller cannot reintroduce the collision by passing a bare id. Unstamped emails keep the bare id as their key, so single-account views are unchanged. The batch actions derive the JMAP ids from the selected emails themselves instead of looking each id up again, which is where the namesake used to ride along, and every place that pruned the selection after a delete or a move now prunes by key. --- .../email/__tests__/thread-list-item.test.tsx | 14 +- components/email/email-list.tsx | 20 +- components/email/thread-email-item.tsx | 14 +- components/email/thread-list-item.tsx | 36 ++-- components/mail/mail-app.tsx | 42 ++-- hooks/use-email-drag.ts | 16 +- hooks/use-mailbox-drop.ts | 6 +- lib/__tests__/account-state-manager.test.ts | 4 +- lib/account-state-manager.ts | 2 +- lib/thread-utils.ts | 17 ++ stores/__tests__/email-archive.test.ts | 4 +- .../email-selection-cross-account.test.ts | 62 ++++++ stores/__tests__/email-selection.test.ts | 62 +++--- .../email-store-archive-missing.test.ts | 4 +- .../email-store-multi-account.test.ts | 6 +- .../email-store-retained-in-view.test.ts | 4 +- .../email-store-shared-folder-actions.test.ts | 4 +- ...email-store-shared-folder-keywords.test.ts | 2 +- .../__tests__/email-store-tag-counts.test.ts | 4 +- .../email-store-tag-view-accounts.test.ts | 6 +- .../email-store-unified-actions.test.ts | 16 +- stores/email-store.ts | 183 ++++++++++-------- 22 files changed, 325 insertions(+), 203 deletions(-) create mode 100644 stores/__tests__/email-selection-cross-account.test.ts diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index f9b312e39..4b3f6d249 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -53,7 +53,7 @@ describe('ThreadListItem tag badge', () => { mailLayout: 'split', }); useEmailStore.setState({ - selectedEmailIds: new Set(), + selectedEmailKeys: new Set(), selectedMailbox: 'inbox', }); }); @@ -126,7 +126,7 @@ describe('ThreadListItem multi-message thread', () => { mailLayout: 'split', }); useEmailStore.setState({ - selectedEmailIds: new Set(), + selectedEmailKeys: new Set(), selectedMailbox: 'inbox', }); }); @@ -187,7 +187,7 @@ describe('ThreadListItem row content', () => { mailLayout: 'split', }); useEmailStore.setState({ - selectedEmailIds: new Set(), + selectedEmailKeys: new Set(), selectedMailbox: 'inbox', }); }); @@ -226,8 +226,8 @@ describe('ThreadListItem shift-range avatar selection', () => { // Selection mode active, with the anchor on e1. useEmailStore.setState({ emails: [e1, e2, e3], - selectedEmailIds: new Set(['e1']), - lastSelectedEmailId: 'e1', + selectedEmailKeys: new Set(['e1']), + lastSelectedEmailKey: 'e1', selectedMailbox: 'inbox', }); @@ -237,7 +237,7 @@ describe('ThreadListItem shift-range avatar selection', () => { avatar.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); }); - const selected = useEmailStore.getState().selectedEmailIds; + const selected = useEmailStore.getState().selectedEmailKeys; expect(selected.has('e1')).toBe(true); expect(selected.has('e2')).toBe(true); // the row in between got filled in expect(selected.has('e3')).toBe(true); @@ -256,7 +256,7 @@ describe('ThreadListItem row tint', () => { tintListRowsByTag: true, }); useEmailStore.setState({ - selectedEmailIds: new Set(['email-1']), + selectedEmailKeys: new Set(['email-1']), selectedMailbox: 'inbox', }); }); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 71ef9e256..37bb72a15 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -88,7 +88,7 @@ export function EmailList({ const tSpam = useTranslations('email_viewer.spam'); const { client } = useAuthStore(); const { - selectedEmailIds, + selectedEmailKeys, selectAllEmails: _selectAllEmails, clearSelection, batchMarkAsRead, @@ -227,7 +227,7 @@ export function EmailList({ ); - const hasSelection = selectedEmailIds.size > 0; + const hasSelection = selectedEmailKeys.size > 0; const handleBatchMarkAsRead = async (read: boolean) => { if (!client || isProcessing) return; @@ -243,7 +243,7 @@ export function EmailList({ if (!client || isProcessing) return; setIsProcessing(true); try { - const emailIds = Array.from(selectedEmailIds); + const emailIds = Array.from(selectedEmailKeys); await batchUndoSpam(client, emailIds); const { toast } = await import('sonner'); toast.success(tSpam('toast_not_spam_batch', { count: emailIds.length })); @@ -266,8 +266,8 @@ export function EmailList({ ? t('permanent_delete_confirm_title') : t('batch_actions.delete_confirm_title'), message: isInTrash - ? t('permanent_delete_confirm_batch_message', { count: selectedEmailIds.size }) - : t('batch_actions.delete_confirm_message', { count: selectedEmailIds.size }), + ? t('permanent_delete_confirm_batch_message', { count: selectedEmailKeys.size }) + : t('batch_actions.delete_confirm_message', { count: selectedEmailKeys.size }), confirmText: isInTrash ? t('permanent_delete') : t('batch_actions.delete'), @@ -391,7 +391,7 @@ export function EmailList({
- {t('batch_actions.selected_messages', { count: selectedEmailIds.size })} + {t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
@@ -629,8 +629,8 @@ export function EmailList({ mailboxes={mailboxes} selectedMailbox={selectedMailbox} currentMailboxRole={effectiveMailboxRole} - isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)} - selectedCount={selectedEmailIds.size} + isMultiSelect={selectedEmailKeys.has(contextMenuEmail.id)} + selectedCount={selectedEmailKeys.size} onReply={() => onReply?.(contextMenuEmail!)} onReplyAll={() => onReplyAll?.(contextMenuEmail!)} onForward={() => onForward?.(contextMenuEmail!)} @@ -660,7 +660,7 @@ export function EmailList({ onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)} onBatchMarkAsSpam={async () => { if (client) { - const emailIds = Array.from(selectedEmailIds); + const emailIds = Array.from(selectedEmailKeys); try { await batchMarkAsSpam(client, emailIds); const { toast } = await import('sonner'); @@ -675,7 +675,7 @@ export function EmailList({ }} onBatchUndoSpam={async () => { if (client) { - const emailIds = Array.from(selectedEmailIds); + const emailIds = Array.from(selectedEmailKeys); try { await batchUndoSpam(client, emailIds); const { toast } = await import('sonner'); diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 24c97d7e7..ca6b99aaf 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -45,9 +45,9 @@ export function ThreadEmailItem({ // header pools them, so without this they disappear on the way in. const tagIds = sortTagIds(getEmailTagIds(email.keywords)); const sender = email.from?.[0]; - const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); + const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const density = useSettingsStore((state) => state.density); - const isChecked = selectedEmailIds.has(email.id); + const isChecked = selectedEmailKeys.has(email.id); const { dragHandlers, isDragging } = useEmailDrag({ email, @@ -73,18 +73,18 @@ export function ThreadEmailItem({ const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); - toggleEmailSelection(email.id); + toggleEmailSelection(email); }; const handleClick = (e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); - toggleEmailSelection(email.id); + toggleEmailSelection(email); } else if (e.shiftKey) { e.preventDefault(); - selectRangeEmails(email.id); + selectRangeEmails(email); } else { - if (selectedEmailIds.size > 0) clearSelection(); + if (selectedEmailKeys.size > 0) clearSelection(); onClick?.(); } }; @@ -118,7 +118,7 @@ export function ThreadEmailItem({ >
{/* Checkbox - only visible when in selection mode */} - {selectedEmailIds.size > 0 && ( + {selectedEmailKeys.size > 0 && (