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.
This commit is contained in:
lucamzanon
2026-09-19 08:59:21 +02:00
parent 5b63993bfe
commit fac3bdf31c
22 changed files with 325 additions and 203 deletions
@@ -53,7 +53,7 @@ describe('ThreadListItem tag badge', () => {
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedEmailKeys: new Set<string>(),
selectedMailbox: 'inbox',
});
});
@@ -126,7 +126,7 @@ describe('ThreadListItem multi-message thread', () => {
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedEmailKeys: new Set<string>(),
selectedMailbox: 'inbox',
});
});
@@ -187,7 +187,7 @@ describe('ThreadListItem row content', () => {
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedEmailKeys: new Set<string>(),
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',
});
});
+10 -10
View File
@@ -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({
</div>
);
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({
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground">
{t('batch_actions.selected_messages', { count: selectedEmailIds.size })}
{t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
</span>
</div>
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
@@ -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');
+7 -7
View File
@@ -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({
>
<div className="flex items-start gap-3">
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
{selectedEmailKeys.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
+18 -18
View File
@@ -165,7 +165,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isPinned = email.keywords?.['$pinned'] === true;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
// In Sent/Drafts folders, show recipient instead of sender (which is always
// "me"). In aggregate role-views the selected mailbox is virtual → fall back
// to the unified role so junk-contextual UI and avatar hiding work.
@@ -187,7 +187,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const account = email.accountId ? getAccountById(email.accountId) : undefined;
const accountColor = account?.avatarColor;
const accountDescription = describeAccount(email.accountLabel, account);
const isChecked = selectedEmailIds.has(email.id);
const isChecked = selectedEmailKeys.has(email.id);
const isMobile = useUIStore((state) => state.isMobile);
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
@@ -267,9 +267,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (e.shiftKey) {
selectRangeEmails(email.id);
selectRangeEmails(email);
} else {
toggleEmailSelection(email.id);
toggleEmailSelection(email);
}
};
@@ -281,12 +281,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
if (swipeEnabled && consumeTap()) { return; }
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();
}
};
@@ -348,7 +348,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)', transform: swipeEnabled && offsetX ? `translateX(${offsetX}px)` : undefined, transition: swipeEnabled && offsetX === 0 ? 'transform 200ms ease-out' : undefined }}
>
{/* Checkbox - only for extra-compact density (no avatar) while in selection mode */}
{density === 'extra-compact' && selectedEmailIds.size > 0 && (
{density === 'extra-compact' && selectedEmailKeys.size > 0 && (
<button
onClick={handleCheckboxClick}
role="checkbox"
@@ -641,7 +641,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? formatDateTime(latestEmail.scheduledSendAt, timeFormat)
: null;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
const threadAccount = latestEmail.accountId ? getAccountById(latestEmail.accountId) : undefined;
@@ -688,7 +688,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId);
const isChecked = thread.emails.some(e => selectedEmailIds.has(e.id));
const isChecked = thread.emails.some(e => selectedEmailKeys.has(e.id));
if (emailCount === 1) {
return (
@@ -717,8 +717,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
// Toggle selection for all emails in this thread.
const toggleThreadSelection = () => {
const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id));
const newSelection = new Set(selectedEmailIds);
const allSelected = thread.emails.every(em => selectedEmailKeys.has(em.id));
const newSelection = new Set(selectedEmailKeys);
thread.emails.forEach(em => {
if (allSelected) {
newSelection.delete(em.id);
@@ -726,13 +726,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
newSelection.add(em.id);
}
});
useEmailStore.setState({ selectedEmailIds: newSelection, lastSelectedEmailId: latestEmail.id });
useEmailStore.setState({ selectedEmailKeys: newSelection, lastSelectedEmailKey: latestEmail.id });
};
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (e.shiftKey) {
selectRangeEmails(latestEmail.id);
selectRangeEmails(latestEmail);
return;
}
toggleThreadSelection();
@@ -742,12 +742,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
// Ctrl+Click: toggle selection for all thread emails
thread.emails.forEach(em => toggleEmailSelection(em.id));
thread.emails.forEach(em => toggleEmailSelection(em));
return;
}
if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(latestEmail.id);
selectRangeEmails(latestEmail);
return;
}
@@ -760,7 +760,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
if (target.closest('[data-expand-toggle]')) {
onToggleExpand();
} else {
if (selectedEmailIds.size > 0) clearSelection();
if (selectedEmailKeys.size > 0) clearSelection();
if (!isExpanded) {
onCollapseAllThreads?.();
onToggleExpand();
@@ -812,7 +812,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox for thread selection - only for extra-compact density (no avatar) while in selection mode */}
{density === 'extra-compact' && selectedEmailIds.size > 0 && (
{density === 'extra-compact' && selectedEmailKeys.size > 0 && (
<button
onClick={handleThreadCheckboxClick}
role="checkbox"
+22 -20
View File
@@ -338,7 +338,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
newEmailNotification,
selectEmail,
selectMailbox,
selectedEmailIds,
selectedEmailKeys,
selectAllEmails,
clearSelection,
toggleEmailSelection,
@@ -703,7 +703,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
onArchive: async () => {
if (isScheduledView) return;
if (selectedEmailIds.size > 0 && client) {
if (selectedEmailKeys.size > 0 && client) {
try {
await batchArchive(client);
} catch (error) {
@@ -719,7 +719,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
onDelete: async () => {
if (isScheduledView) return;
if (selectedEmailIds.size > 0 && client) {
if (selectedEmailKeys.size > 0 && client) {
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
const isInJunk = currentMailbox?.role === 'junk';
@@ -730,8 +730,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
? t('email_list.permanent_delete_confirm_title')
: t('email_list.batch_actions.delete_confirm_title'),
message: permanent
? t('email_list.permanent_delete_confirm_batch_message', { count: selectedEmailIds.size })
: t('email_list.batch_actions.delete_confirm_message', { count: selectedEmailIds.size }),
? t('email_list.permanent_delete_confirm_batch_message', { count: selectedEmailKeys.size })
: t('email_list.batch_actions.delete_confirm_message', { count: selectedEmailKeys.size }),
confirmText: permanent
? t('email_list.permanent_delete')
: t('email_list.batch_actions.delete'),
@@ -750,7 +750,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onMarkAsUnread: async () => {
if (isScheduledView) return;
if (!client) return;
if (selectedEmailIds.size > 0) {
if (selectedEmailKeys.size > 0) {
await batchMarkAsRead(client, false);
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, false);
@@ -759,7 +759,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onMarkAsRead: async () => {
if (isScheduledView) return;
if (!client) return;
if (selectedEmailIds.size > 0) {
if (selectedEmailKeys.size > 0) {
await batchMarkAsRead(client, true);
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, true);
@@ -773,8 +773,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// is a no-op there too.
if (['sent', 'drafts', 'scheduled'].includes(currentMailbox?.role || '')) return;
const isInJunk = currentMailbox?.role === 'junk';
if (selectedEmailIds.size > 0 && client) {
const ids = Array.from(selectedEmailIds);
if (selectedEmailKeys.size > 0 && client) {
const ids = Array.from(selectedEmailKeys);
try {
if (isInJunk) {
await batchUndoSpam(client, ids);
@@ -842,14 +842,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes, isScheduledView]);
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailKeys, mailboxes, isScheduledView]);
// Initialize keyboard shortcuts
useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer,
emails: activeEmails,
selectedEmailId: selectedEmail?.id,
selectionCount: selectedEmailIds.size,
selectionCount: selectedEmailKeys.size,
handlers: keyboardHandlers,
});
@@ -3706,30 +3706,32 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
<button
type="button"
onClick={() => {
if (selectedEmailIds.size > 0) {
if (selectedEmailIds.size === activeEmails.length) {
if (selectedEmailKeys.size > 0) {
if (selectedEmailKeys.size === activeEmails.length) {
clearSelection();
} else {
selectAllEmails();
}
} else if (activeEmails.length > 0) {
const currentId = selectedEmail?.id;
const target = currentId && activeEmails.some((e) => e.id === currentId)
? currentId
: activeEmails[0].id;
// The open message when it is in this list, else the
// first row - as an email, so the selection is keyed by
// its owning account and not by a colliding id.
const target = (selectedEmail && activeEmails.some((e) => e.id === selectedEmail.id)
? activeEmails.find((e) => e.id === selectedEmail.id)
: activeEmails[0])!;
toggleEmailSelection(target);
}
}}
disabled={isScheduledView}
className={cn(
"flex-shrink-0 p-2 rounded-md transition-colors",
selectedEmailIds.size > 0
selectedEmailKeys.size > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : selectedEmailIds.size > 0 ? (selectedEmailIds.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : selectedEmailKeys.size > 0 ? (selectedEmailKeys.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
>
{selectedEmailIds.size > 0 ? (
{selectedEmailKeys.size > 0 ? (
<CheckSquare className="w-4 h-4" />
) : (
<Square className="w-4 h-4" />
+8 -8
View File
@@ -141,7 +141,7 @@ function getReadyBundle(emails: Email[]): { url: string; name: string } | null {
}
export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
const { selectedEmailIds, emails } = useEmailStore();
const { selectedEmailKeys, emails } = useEmailStore();
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
const isMobile = useUIStore((state) => state.isMobile);
const client = useAuthStore((state) => state.client);
@@ -195,10 +195,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const handlePointerEnter = useCallback(() => {
if (!dragOutEnabled || !client) return;
const isSelected = selectedEmailIds.has(email.id);
const isMulti = isSelected && selectedEmailIds.size > 1;
const isSelected = selectedEmailKeys.has(email.id);
const isMulti = isSelected && selectedEmailKeys.size > 1;
if (isMulti) {
const selected = emails.filter((em) => selectedEmailIds.has(em.id));
const selected = emails.filter((em) => selectedEmailKeys.has(em.id));
// Only worth bundling when at least one selected email has a blobId.
if (selected.some((em) => em.blobId)) {
prefetchEmailBundle(client, selected, filenameOptions, bundleOptions);
@@ -206,16 +206,16 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
} else {
prefetchSingle();
}
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions, bundleOptions]);
}, [dragOutEnabled, client, selectedEmailKeys, email.id, emails, prefetchSingle, filenameOptions, bundleOptions]);
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
// Determine which emails to drag:
// - If current email is selected, drag all selected
// - If threadEmails provided (thread header), drag all thread emails
// - Otherwise, drag only this email
const isSelected = selectedEmailIds.has(email.id);
const isSelected = selectedEmailKeys.has(email.id);
const emailsToDrag = isSelected
? emails.filter(em => selectedEmailIds.has(em.id))
? emails.filter(em => selectedEmailKeys.has(em.id))
: threadEmails || [email];
e.dataTransfer.effectAllowed = "copyMove";
@@ -284,7 +284,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
});
startDrag(emailsToDrag, sourceMailboxId);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
}, [email, selectedEmailKeys, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
const handleDragEnd = useCallback(() => {
endDrag();
+3 -3
View File
@@ -65,7 +65,7 @@ interface UseMailboxDropReturn {
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore();
const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailKeys, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
// Determine if this is a valid drop target
@@ -184,7 +184,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
}
// Clear selection if any selected emails were moved
if (emailIds.some(id => selectedEmailIds.has(id))) {
if (emailIds.some(id => selectedEmailKeys.has(id))) {
clearSelection();
}
@@ -222,7 +222,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
} finally {
endDrag();
}
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailKeys, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
const valid = isValidTarget();
+2 -2
View File
@@ -94,7 +94,7 @@ describe('clearAllStores', () => {
it('resets the email store to fresh empty collections', () => {
useEmailStore.setState({
emails: [makeEmail({ id: 'x' })],
selectedEmailIds: new Set(['x']),
selectedEmailKeys: new Set(['x']),
searchQuery: 'q',
tagCounts: { a: { total: 1, unread: 0 } },
threadEmailsCache: new Map([['t', []]]),
@@ -105,7 +105,7 @@ describe('clearAllStores', () => {
const s = useEmailStore.getState();
expect(s.emails).toEqual([]);
expect(s.searchQuery).toBe('');
expect(s.selectedEmailIds.size).toBe(0);
expect(s.selectedEmailKeys.size).toBe(0);
expect(s.threadEmailsCache.size).toBe(0);
expect(s.tagCounts).toEqual({});
expect(s.searchFilters).toEqual(DEFAULT_SEARCH_FILTERS);
+1 -1
View File
@@ -117,7 +117,7 @@ export function clearAllStores(): void {
isPushConnected: false,
lastPushUpdate: null,
newEmailNotification: null,
selectedEmailIds: new Set<string>(),
selectedEmailKeys: new Set<string>(),
hasMoreEmails: false,
totalEmails: 0,
expandedThreadIds: new Set<string>(),
+17
View File
@@ -19,6 +19,23 @@ export function threadKeyFor(
return scope ? `${scope}:${email.threadId}` : email.threadId;
}
/**
* Client-side identity of a single message.
*
* Email ids are per-account too, so in a merged view two unrelated messages can
* share an `id` - `eiaaaabc` exists in every account that got that far. Batch
* selection is what makes that dangerous: keyed by the bare id, ticking one row
* also ticks its namesake in another account, and delete, move or archive then
* act on both. Same scoping as `threadKeyFor`; unstamped emails keep the bare
* id, so single-account views are untouched.
*/
export function emailKeyFor(
email: Pick<Email, "id" | "sourceClientAccountId" | "sourceAccountId">,
): string {
const scope = [email.sourceClientAccountId, email.sourceAccountId].filter(Boolean).join("/");
return scope ? `${scope}:${email.id}` : email.id;
}
/**
* The JMAP thread id behind a `threadKeyFor` key. JMAP ids never contain ":"
* (RFC 8620 §1.2 limits them to the URL-safe base64 alphabet), so the last
+2 -2
View File
@@ -61,7 +61,7 @@ describe('email-store archive thread behavior', () => {
mailboxes: [inbox, archive],
selectedMailbox: 'shared-inbox',
selectedEmail: threadEmailA,
selectedEmailIds: new Set(['email-1', 'email-2']),
selectedEmailKeys: new Set(['email-1', 'email-2']),
expandedThreadIds: new Set(['thread-1']),
threadEmailsCache: new Map([['thread-1', [threadEmailA, threadEmailB]]]),
error: null,
@@ -82,7 +82,7 @@ describe('email-store archive thread behavior', () => {
const state = useEmailStore.getState();
expect(state.emails.map(email => email.id)).toEqual(['email-3']);
expect(state.selectedEmail?.id).toBe('email-3');
expect(Array.from(state.selectedEmailIds)).toEqual([]);
expect(Array.from(state.selectedEmailKeys)).toEqual([]);
expect(state.expandedThreadIds.has('thread-1')).toBe(false);
expect(state.threadEmailsCache.has('thread-1')).toBe(false);
});
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { emailKeyFor } from '@/lib/thread-utils';
import type { Email } from '@/lib/jmap/types';
/**
* Email ids are per-account, so the same id exists in several accounts
* ("eiaaaabc" is an ordinary first id). Reported on #1013 by @alyyousuf7:
* selection keyed by the bare id ticks both, and a batch action then hits a
* message the user never selected.
*/
const twin = (account: string, subject: string): Email => ({
id: 'eiaaaabc', threadId: 'taaaabc', blobId: `blob-${account}`,
mailboxIds: { inbox: true }, keywords: {},
from: [{ email: `someone@${account}` }], to: [], subject,
receivedAt: '2026-09-19T08:00:00Z', size: 42, preview: '', hasAttachment: false,
sourceClientAccountId: account, sourceAccountId: account,
} as unknown as Email);
const mine = twin('account-a', 'mia');
const theirs = twin('account-b', 'di un altro account');
beforeEach(() => {
useEmailStore.setState({ emails: [mine, theirs], selectedEmailKeys: new Set(), lastSelectedEmailKey: null });
});
describe('selection across accounts sharing an email id', () => {
it('ticks only the row that was clicked', () => {
useEmailStore.getState().toggleEmailSelection(mine);
const { emails, selectedEmailKeys } = useEmailStore.getState();
const affected = emails.filter(e => selectedEmailKeys.has(emailKeyFor(e)));
expect(affected).toHaveLength(1);
expect(affected[0]!.sourceAccountId).toBe('account-a');
});
it('unticks its namesake independently', () => {
const store = useEmailStore.getState();
store.toggleEmailSelection(mine);
store.toggleEmailSelection(theirs);
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(2);
store.toggleEmailSelection(theirs);
const { selectedEmailKeys } = useEmailStore.getState();
expect([...selectedEmailKeys]).toEqual([emailKeyFor(mine)]);
});
it('selects every account copy when the whole list is selected', () => {
useEmailStore.getState().selectAllEmails();
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(2);
});
it('deletes only the selected account copy', async () => {
const client = {
batchDeleteEmails: vi.fn(async () => {}),
batchMoveToMailbox: vi.fn(async () => {}),
getMailboxes: vi.fn(async () => []),
} as never;
useEmailStore.getState().toggleEmailSelection(theirs);
await useEmailStore.getState().batchDelete(client, true);
const remaining = useEmailStore.getState().emails;
expect(remaining.map(e => e.sourceAccountId)).toEqual(['account-a']);
});
});
+31 -31
View File
@@ -24,29 +24,29 @@ describe('email-store selection', () => {
beforeEach(() => {
useEmailStore.setState({
emails: [makeEmail('a'), makeEmail('b'), makeEmail('c'), makeEmail('d'), makeEmail('e')],
selectedEmailIds: new Set(),
lastSelectedEmailId: null,
selectedEmailKeys: new Set(),
lastSelectedEmailKey: null,
selectedEmail: null,
});
});
describe('toggleEmailSelection', () => {
it('should add email to selection', () => {
useEmailStore.getState().toggleEmailSelection('b');
expect(useEmailStore.getState().selectedEmailIds.has('b')).toBe(true);
expect(useEmailStore.getState().lastSelectedEmailId).toBe('b');
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
expect(useEmailStore.getState().selectedEmailKeys.has('b')).toBe(true);
expect(useEmailStore.getState().lastSelectedEmailKey).toBe('b');
});
it('should remove email from selection when toggled again', () => {
useEmailStore.getState().toggleEmailSelection('b');
useEmailStore.getState().toggleEmailSelection('b');
expect(useEmailStore.getState().selectedEmailIds.has('b')).toBe(false);
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
expect(useEmailStore.getState().selectedEmailKeys.has('b')).toBe(false);
});
it('should support selecting multiple emails', () => {
useEmailStore.getState().toggleEmailSelection('a');
useEmailStore.getState().toggleEmailSelection('c');
const ids = useEmailStore.getState().selectedEmailIds;
useEmailStore.getState().toggleEmailSelection({ id: 'a' });
useEmailStore.getState().toggleEmailSelection({ id: 'c' });
const ids = useEmailStore.getState().selectedEmailKeys;
expect(ids.has('a')).toBe(true);
expect(ids.has('c')).toBe(true);
expect(ids.size).toBe(2);
@@ -55,9 +55,9 @@ describe('email-store selection', () => {
describe('selectRangeEmails', () => {
it('should select range from last selected to target (forward)', () => {
useEmailStore.getState().toggleEmailSelection('b'); // anchor at index 1
useEmailStore.getState().selectRangeEmails('d'); // target at index 3
const ids = useEmailStore.getState().selectedEmailIds;
useEmailStore.getState().toggleEmailSelection({ id: 'b' }); // anchor at index 1
useEmailStore.getState().selectRangeEmails({ id: 'd' }); // target at index 3
const ids = useEmailStore.getState().selectedEmailKeys;
expect(ids.has('b')).toBe(true);
expect(ids.has('c')).toBe(true);
expect(ids.has('d')).toBe(true);
@@ -65,9 +65,9 @@ describe('email-store selection', () => {
});
it('should select range backward', () => {
useEmailStore.getState().toggleEmailSelection('d'); // anchor at index 3
useEmailStore.getState().selectRangeEmails('b'); // target at index 1
const ids = useEmailStore.getState().selectedEmailIds;
useEmailStore.getState().toggleEmailSelection({ id: 'd' }); // anchor at index 3
useEmailStore.getState().selectRangeEmails({ id: 'b' }); // target at index 1
const ids = useEmailStore.getState().selectedEmailKeys;
expect(ids.has('b')).toBe(true);
expect(ids.has('c')).toBe(true);
expect(ids.has('d')).toBe(true);
@@ -75,8 +75,8 @@ describe('email-store selection', () => {
});
it('should use first email as anchor when no previous selection', () => {
useEmailStore.getState().selectRangeEmails('c'); // no anchor → uses first email 'a'
const ids = useEmailStore.getState().selectedEmailIds;
useEmailStore.getState().selectRangeEmails({ id: 'c' }); // no anchor → uses first email 'a'
const ids = useEmailStore.getState().selectedEmailKeys;
expect(ids.has('a')).toBe(true);
expect(ids.has('b')).toBe(true);
expect(ids.has('c')).toBe(true);
@@ -84,10 +84,10 @@ describe('email-store selection', () => {
});
it('should add to existing selection', () => {
useEmailStore.getState().toggleEmailSelection('a');
useEmailStore.getState().toggleEmailSelection('b'); // anchor now at 'b'
useEmailStore.getState().selectRangeEmails('d');
const ids = useEmailStore.getState().selectedEmailIds;
useEmailStore.getState().toggleEmailSelection({ id: 'a' });
useEmailStore.getState().toggleEmailSelection({ id: 'b' }); // anchor now at 'b'
useEmailStore.getState().selectRangeEmails({ id: 'd' });
const ids = useEmailStore.getState().selectedEmailKeys;
// 'a' still selected, plus b-d range
expect(ids.has('a')).toBe(true);
expect(ids.has('b')).toBe(true);
@@ -97,9 +97,9 @@ describe('email-store selection', () => {
});
it('should handle single-item range', () => {
useEmailStore.getState().toggleEmailSelection('c');
useEmailStore.getState().selectRangeEmails('c');
const ids = useEmailStore.getState().selectedEmailIds;
useEmailStore.getState().toggleEmailSelection({ id: 'c' });
useEmailStore.getState().selectRangeEmails({ id: 'c' });
const ids = useEmailStore.getState().selectedEmailKeys;
expect(ids.has('c')).toBe(true);
expect(ids.size).toBe(1);
});
@@ -108,17 +108,17 @@ describe('email-store selection', () => {
describe('selectAllEmails', () => {
it('should select all emails', () => {
useEmailStore.getState().selectAllEmails();
expect(useEmailStore.getState().selectedEmailIds.size).toBe(5);
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(5);
});
});
describe('clearSelection', () => {
it('should clear all selections and reset anchor', () => {
useEmailStore.getState().toggleEmailSelection('a');
useEmailStore.getState().toggleEmailSelection('b');
useEmailStore.getState().toggleEmailSelection({ id: 'a' });
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
useEmailStore.getState().clearSelection();
expect(useEmailStore.getState().selectedEmailIds.size).toBe(0);
expect(useEmailStore.getState().lastSelectedEmailId).toBeNull();
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
expect(useEmailStore.getState().lastSelectedEmailKey).toBeNull();
});
});
});
@@ -74,7 +74,7 @@ describe('batchArchive without an archive mailbox (#578)', () => {
],
accountMailboxes: {},
emails: [makeEmail({ id: 'e1', mailboxIds: { 'a-inbox': true } })],
selectedEmailIds: new Set(['e1']),
selectedEmailKeys: new Set(['e1']),
});
});
@@ -86,6 +86,6 @@ describe('batchArchive without an archive mailbox (#578)', () => {
expect(useEmailStore.getState().error).toMatch(/archive mailbox not found/i);
expect(client.batchArchiveEmails).not.toHaveBeenCalled();
// Nothing was archived, so the selection must survive.
expect(useEmailStore.getState().selectedEmailIds.has('e1')).toBe(true);
expect(useEmailStore.getState().selectedEmailKeys.has('e1')).toBe(true);
});
});
@@ -36,7 +36,7 @@ describe('useEmailStore multi-account state', () => {
viewingAccountId: null,
selectedMailbox: '',
selectedEmail: null,
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
selectedKeyword: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
@@ -89,7 +89,7 @@ describe('useEmailStore multi-account state', () => {
it('selectAccountMailbox sets viewing and selected together, and clears email selection state', () => {
useEmailStore.setState({
selectedEmail: { id: 'e1' } as unknown as ReturnType<typeof useEmailStore.getState>['selectedEmail'],
selectedEmailIds: new Set(['e1', 'e2']),
selectedEmailKeys: new Set(['e1', 'e2']),
selectedKeyword: 'work',
expandedThreadIds: new Set(['thread-1']),
});
@@ -100,7 +100,7 @@ describe('useEmailStore multi-account state', () => {
expect(state.viewingAccountId).toBe('account-b');
expect(state.selectedMailbox).toBe('b-inbox');
expect(state.selectedEmail).toBeNull();
expect(state.selectedEmailIds.size).toBe(0);
expect(state.selectedEmailKeys.size).toBe(0);
expect(state.selectedKeyword).toBeNull();
expect(state.expandedThreadIds.size).toBe(0);
});
@@ -78,7 +78,7 @@ function seedView(id: typeof CROSS_UNREAD | typeof CROSS_STARRED, view: 'unread'
mailboxes: [makeMailbox()],
accountMailboxes: { 'account-a': [makeMailbox()] },
processingReadStatus: new Set(),
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
retainedInViewIds: new Set(),
emails,
totalEmails: emails.length,
@@ -194,7 +194,7 @@ describe('retained-in-view: scope', () => {
isUnifiedView: true, unifiedRole: null, crossView: 'all', selectedMailbox: '__cross_all__',
selectedKeyword: null, searchQuery: '', mailboxes: [makeMailbox()],
accountMailboxes: { 'account-a': [makeMailbox()] },
processingReadStatus: new Set(), selectedEmailIds: new Set(), retainedInViewIds: new Set(),
processingReadStatus: new Set(), selectedEmailKeys: new Set(), retainedInViewIds: new Set(),
emails: [a], totalEmails: 1,
});
@@ -118,7 +118,7 @@ describe('non-unified shared-folder batch action routing', () => {
makeEmail({ id: 'e1', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } }),
makeEmail({ id: 'e2', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } }),
],
selectedEmailIds: new Set(['e1', 'e2']),
selectedEmailKeys: new Set(['e1', 'e2']),
});
});
@@ -185,7 +185,7 @@ describe('non-unified shared-folder batch action routing', () => {
makeEmail({ id: 'o1', keywords: {}, mailboxIds: { 'a-inbox': true } }),
makeEmail({ id: 'o2', keywords: {}, mailboxIds: { 'a-inbox': true } }),
],
selectedEmailIds: new Set(['o1', 'o2']),
selectedEmailKeys: new Set(['o1', 'o2']),
});
await useEmailStore.getState().batchDelete(activeClient, false);
@@ -101,7 +101,7 @@ describe('non-unified shared-folder keyword routing', () => {
accountMailboxes: {},
selectedEmail: null,
emails: [makeEmail({ id: 'e1', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } })],
selectedEmailIds: new Set<string>(),
selectedEmailKeys: new Set<string>(),
} as never);
});
@@ -86,7 +86,7 @@ describe('email-store tag counts stay in step with read state', () => {
accountMailboxes: {},
emails: [],
selectedEmail: null,
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
processingReadStatus: new Set(),
threadEmailsCache: new Map(),
tagCounts: {
@@ -187,7 +187,7 @@ describe('email-store tag counts stay in step with read state', () => {
// Untagged: must not contribute a delta.
makeEmail({ id: 'e4', keywords: {} }),
],
selectedEmailIds: new Set(['e1', 'e2', 'e3', 'e4']),
selectedEmailKeys: new Set(['e1', 'e2', 'e3', 'e4']),
} as never);
await useEmailStore.getState().batchMarkAsRead(client, true);
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { emailKeyFor } from '@/lib/thread-utils';
import { useSettingsStore } from '../settings-store';
import { useAuthStore } from '../auth-store';
import { useMessageListTabsStore } from '../message-list-tabs-store';
@@ -137,7 +138,10 @@ describe('tag view across the own and group accounts (#1038)', () => {
useEmailStore.setState({ selectedMailbox: groupInbox.id });
useEmailStore.getState().selectKeyword(label);
await useEmailStore.getState().fetchEmails(client);
useEmailStore.setState({ selectedEmailIds: new Set(['own-1', 'grp-1']) });
// Keyed by owning account: in this very view 'own-1' and 'grp-1' could be
// the same bare id in two accounts.
const picked = useEmailStore.getState().emails.filter(e => ['own-1', 'grp-1'].includes(e.id));
useEmailStore.setState({ selectedEmailKeys: new Set(picked.map(emailKeyFor)) });
await useEmailStore.getState().batchMarkAsRead(client, true);
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { emailKeyFor } from '@/lib/thread-utils';
import { useAuthStore } from '../auth-store';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -98,7 +99,7 @@ describe('unified-view single-email action routing (#281)', () => {
},
processingReadStatus: new Set(),
selectedEmail: null,
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
unifiedScope: [],
unifiedCounts: [],
crossUnreadCount: 0,
@@ -164,7 +165,10 @@ describe('unified-view single-email action routing (#281)', () => {
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']),
selectedEmailKeys: new Set([
emailKeyFor({ id: 'a1', sourceClientAccountId: 'account-a', sourceAccountId: 'account-a' }),
emailKeyFor({ id: 'b1', sourceClientAccountId: 'account-b', sourceAccountId: 'account-b' }),
]),
});
await useEmailStore.getState().batchMarkAsRead(activeClient, true);
@@ -209,7 +213,9 @@ describe('unified-view single-email action routing (#281)', () => {
mailboxIds: { 'x-inbox': true }, // BARE owner id (not namespaced)
}),
],
selectedEmailIds: new Set(['email-shared']),
selectedEmailKeys: new Set([
emailKeyFor({ id: 'email-shared', sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x' }),
]),
});
await useEmailStore.getState().batchDelete(activeClient, true);
@@ -255,7 +261,9 @@ describe('unified-view single-email action routing (#281)', () => {
mailboxIds: { 'x-inbox': true }, // BARE owner id (not namespaced)
}),
],
selectedEmailIds: new Set(['email-shared']),
selectedEmailKeys: new Set([
emailKeyFor({ id: 'email-shared', sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x' }),
]),
});
// Seed the badges from the scope (also stores unifiedScope).
+106 -77
View File
@@ -8,7 +8,7 @@ import type { SortLevel } from "@/lib/message-list-order";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import { resolveThreadRoute } from "@/lib/thread-routing";
import { threadKeyFor, threadIdFromKey } from "@/lib/thread-utils";
import { emailKeyFor, threadKeyFor, threadIdFromKey } from "@/lib/thread-utils";
import type { ExternalSearchResult } from "@/lib/plugin-types";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
@@ -41,6 +41,19 @@ type PendingUndoSend = {
submissionAccountId?: string;
};
/** Drop the matching emails from a key-based selection. */
function dropFromSelection(
state: { emails: Email[]; selectedEmailKeys: Set<string> },
matches: (email: Email) => boolean,
): Set<string> {
const next = new Set(state.selectedEmailKeys);
for (const email of state.emails) if (matches(email)) next.delete(emailKeyFor(email));
return next;
}
/** Enough of an email to key a selection by its owning account. */
type SelectableEmail = Pick<Email, 'id' | 'sourceClientAccountId' | 'sourceAccountId'>;
interface EmailStore {
emails: Email[];
mailboxes: Mailbox[];
@@ -68,7 +81,7 @@ interface EmailStore {
searchQuery: string;
quota: { used: number; total: number } | null;
processingReadStatus: Set<string>; // Track emails being marked as read/unread
selectedEmailIds: Set<string>; // Track selected emails for batch operations
selectedEmailKeys: Set<string>; // Track selected emails for batch operations
// Emails the user just read (unread view) or unstarred (starred view) that
// should stay visible in that self-filtering cross view until it is re-opened,
// instead of vanishing on the next push refresh. Cleared on navigation.
@@ -189,9 +202,10 @@ interface EmailStore {
setQuota: (quota: { used: number; total: number } | null) => void;
selectKeyword: (keyword: string | null) => void;
fetchTagCounts: (client: IJMAPClient) => Promise<void>;
toggleEmailSelection: (emailId: string) => void;
selectRangeEmails: (targetEmailId: string) => void;
lastSelectedEmailId: string | null;
/** Take the email, not its id: the id alone collides across accounts. */
toggleEmailSelection: (email: SelectableEmail) => void;
selectRangeEmails: (target: SelectableEmail) => void;
lastSelectedEmailKey: string | null;
selectAllEmails: () => void;
clearSelection: () => void;
@@ -1356,9 +1370,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
searchQuery: "",
quota: null,
processingReadStatus: new Set(),
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
retainedInViewIds: new Set(),
lastSelectedEmailId: null,
lastSelectedEmailKey: null,
hasMoreEmails: false,
totalEmails: 0,
listOrder: [],
@@ -1422,7 +1436,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedMailbox: mailboxId,
isLoadingMore: false,
selectedEmail: null,
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
selectedKeyword: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
@@ -1455,7 +1469,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
selectEmail: (email) => {
const prev = get().selectedEmail;
set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId });
set({ selectedEmail: email, lastSelectedEmailKey: email?.id ?? get().lastSelectedEmailKey });
if (prev && (!email || email.id !== prev.id)) {
emailHooks.onEmailClose.emitSync(prev);
}
@@ -1467,7 +1481,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedKeyword: keyword,
isLoadingMore: false,
selectedEmail: null,
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
threadEmailCounts: new Map(),
@@ -1504,7 +1518,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedMailbox: mailboxId,
isLoadingMore: false,
selectedEmail: null,
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
selectedKeyword: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
@@ -1517,41 +1531,42 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
setSearchQuery: (query) => set({ searchQuery: query }),
setQuota: (quota) => set({ quota }),
toggleEmailSelection: (emailId) => {
const { selectedEmailIds } = get();
const newSelection = new Set(selectedEmailIds);
if (newSelection.has(emailId)) {
newSelection.delete(emailId);
toggleEmailSelection: (email) => {
const key = emailKeyFor(email);
const { selectedEmailKeys } = get();
const newSelection = new Set(selectedEmailKeys);
if (newSelection.has(key)) {
newSelection.delete(key);
} else {
newSelection.add(emailId);
newSelection.add(key);
}
set({ selectedEmailIds: newSelection, lastSelectedEmailId: emailId });
set({ selectedEmailKeys: newSelection, lastSelectedEmailKey: key });
},
selectRangeEmails: (targetEmailId) => {
const { emails, lastSelectedEmailId, selectedEmailIds } = get();
const anchorId = lastSelectedEmailId || emails[0]?.id;
if (!anchorId) return;
const anchorIndex = emails.findIndex(e => e.id === anchorId);
const targetIndex = emails.findIndex(e => e.id === targetEmailId);
selectRangeEmails: (target) => {
const { emails, lastSelectedEmailKey, selectedEmailKeys } = get();
const anchorKey = lastSelectedEmailKey || (emails[0] && emailKeyFor(emails[0]));
if (!anchorKey) return;
const anchorIndex = emails.findIndex(e => emailKeyFor(e) === anchorKey);
const targetKey = emailKeyFor(target);
const targetIndex = emails.findIndex(e => emailKeyFor(e) === targetKey);
if (anchorIndex === -1 || targetIndex === -1) return;
const start = Math.min(anchorIndex, targetIndex);
const end = Math.max(anchorIndex, targetIndex);
const newSelection = new Set(selectedEmailIds);
const newSelection = new Set(selectedEmailKeys);
for (let i = start; i <= end; i++) {
newSelection.add(emails[i].id);
newSelection.add(emailKeyFor(emails[i]));
}
set({ selectedEmailIds: newSelection });
set({ selectedEmailKeys: newSelection });
},
selectAllEmails: () => {
const { emails } = get();
const allIds = new Set(emails.map(e => e.id));
set({ selectedEmailIds: allIds });
set({ selectedEmailKeys: new Set(emails.map(emailKeyFor)) });
},
clearSelection: () => {
set({ selectedEmailIds: new Set(), lastSelectedEmailId: null });
set({ selectedEmailKeys: new Set(), lastSelectedEmailKey: null });
},
// JMAP operations
@@ -2508,9 +2523,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
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);
selectedEmailKeys: (() => {
const next = new Set(state.selectedEmailKeys);
// Keys, not ids: drop exactly the emails leaving the list.
for (const e of state.emails) if (idSet.has(e.id)) next.delete(emailKeyFor(e));
return next;
})(),
...patch,
@@ -2601,9 +2617,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
state.selectedEmail && movedSet.has(state.selectedEmail.id)
? null
: state.selectedEmail,
selectedEmailIds: (() => {
const next = new Set(state.selectedEmailIds);
for (const id of movedIds) next.delete(id);
selectedEmailKeys: (() => {
const next = new Set(state.selectedEmailKeys);
for (const e of state.emails) if (movedSet.has(e.id)) next.delete(emailKeyFor(e));
return next;
})(),
isLoading: false,
@@ -2673,8 +2689,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const removedEmailIds = new Set(threadEmailIds);
set((currentState) => {
const nextSelectedEmail = getNextSelectedEmailAfterRemoval(currentState, removedEmailIds);
const removedKeys = new Set(
currentState.emails.filter(e => removedEmailIds.has(e.id)).map(emailKeyFor)
);
const nextSelectedEmailIds = new Set(
Array.from(currentState.selectedEmailIds).filter(id => !removedEmailIds.has(id))
Array.from(currentState.selectedEmailKeys).filter(key => !removedKeys.has(key))
);
const nextExpandedThreadIds = new Set(currentState.expandedThreadIds);
nextExpandedThreadIds.delete(threadKeyFor(email));
@@ -2684,7 +2703,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return {
emails: currentState.emails.filter(currentEmail => !removedEmailIds.has(currentEmail.id)),
selectedEmail: nextSelectedEmail,
selectedEmailIds: nextSelectedEmailIds,
selectedEmailKeys: nextSelectedEmailIds,
expandedThreadIds: nextExpandedThreadIds,
threadEmailsCache: nextThreadEmailsCache,
};
@@ -3002,21 +3021,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Batch operations
batchMarkAsRead: async (client, read) => {
const { selectedEmailIds, emails } = get();
if (selectedEmailIds.size === 0) return;
const { selectedEmailKeys, emails } = get();
if (selectedEmailKeys.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
// The selection is keyed by owning account; JMAP takes the bare ids of
// exactly those emails, never the keys and never a lookup by id (which
// is what let a namesake in another account ride along).
const selectedEmails = emails.filter(e => selectedEmailKeys.has(emailKeyFor(e)));
const emailIdsArray = selectedEmails.map(e => e.id);
if (isAggregateListView()) {
// Group by owning JMAP account; dispatch through the reaching login client.
const bySource = new Map<string, { clientAccountId?: string; ids: string[] }>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const key = email?.sourceAccountId || '__default__';
if (!bySource.has(key)) bySource.set(key, { clientAccountId: email?.sourceClientAccountId, ids: [] });
bySource.get(key)!.ids.push(emailId);
for (const email of selectedEmails) {
const key = email.sourceAccountId || '__default__';
if (!bySource.has(key)) bySource.set(key, { clientAccountId: email.sourceClientAccountId, ids: [] });
bySource.get(key)!.ids.push(email.id);
}
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
@@ -3036,13 +3058,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Update local state
const updatedEmails = emails.map(email =>
selectedEmailIds.has(email.id)
selectedEmailKeys.has(emailKeyFor(email))
? { ...email, keywords: { ...email.keywords, $seen: read } }
: email
);
// 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 => selectedEmailKeys.has(emailKeyFor(e)));
const mailboxPatch = applyBatchMailboxCounterUpdate(get(), affectedEmails, (mailbox, group) => {
let deltaUnread = 0;
for (const email of group as Email[]) {
@@ -3081,7 +3103,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
...mailboxPatch,
tagCounts,
retainedInViewIds,
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
isLoading: false
});
} catch (error) {
@@ -3093,13 +3115,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
batchDelete: async (client, permanent = false) => {
const { selectedEmailIds, emails, selectedMailbox } = get();
const { selectedEmailKeys, emails, selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailIds.size === 0) return;
if (selectedEmailKeys.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
// The selection is keyed by owning account; JMAP takes the bare ids of
// exactly those emails, never the keys and never a lookup by id (which
// is what let a namesake in another account ride along).
const selectedEmails = emails.filter(e => selectedEmailKeys.has(emailKeyFor(e)));
const emailIdsArray = selectedEmails.map(e => e.id);
// Determine if the current folder forces permanent deletion.
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
@@ -3183,7 +3209,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({
emails: remainingEmails,
...mailboxPatch,
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
selectedEmail: null,
isLoading: false,
error: 'Some emails could not be moved: trash folder missing for one or more accounts',
@@ -3193,16 +3219,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
// Remove deleted emails from local state
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
const remainingEmails = emails.filter(e => !selectedEmailKeys.has(emailKeyFor(e)));
// 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 => selectedEmailKeys.has(emailKeyFor(e)));
const mailboxPatch = applyBatchMailboxCounterUpdate(get(), deletedEmails, applyDeleteCounters);
set({
emails: remainingEmails,
...mailboxPatch,
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
selectedEmail: null,
isLoading: false
});
@@ -3215,23 +3241,26 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
batchMoveToMailbox: async (client, toMailboxId) => {
const { selectedEmailIds, emails } = get();
if (selectedEmailIds.size === 0) return;
const { selectedEmailKeys, emails } = get();
if (selectedEmailKeys.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
// The selection is keyed by owning account; JMAP takes the bare ids of
// exactly those emails, never the keys and never a lookup by id (which
// is what let a namesake in another account ride along).
const selectedEmails = emails.filter(e => selectedEmailKeys.has(emailKeyFor(e)));
const emailIdsArray = selectedEmails.map(e => e.id);
if (isAggregateListView()) {
// Group by owning JMAP account; dispatch through the reaching login client.
const destMailbox = resolveActionMailboxes().find(mb => mb.id === toMailboxId);
const jmapDestId = destMailbox?.originalId || toMailboxId;
const bySource = new Map<string, { clientAccountId?: string; ids: string[] }>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const key = email?.sourceAccountId || '__default__';
if (!bySource.has(key)) bySource.set(key, { clientAccountId: email?.sourceClientAccountId, ids: [] });
bySource.get(key)!.ids.push(emailId);
for (const email of selectedEmails) {
const key = email.sourceAccountId || '__default__';
if (!bySource.has(key)) bySource.set(key, { clientAccountId: email.sourceClientAccountId, ids: [] });
bySource.get(key)!.ids.push(email.id);
}
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
@@ -3254,11 +3283,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
// Update local state - remove from current view since they moved
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
const remainingEmails = emails.filter(e => !selectedEmailKeys.has(emailKeyFor(e)));
set({
emails: remainingEmails,
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
isLoading: false
});
@@ -3275,9 +3304,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
batchArchive: async (client) => {
const { selectedEmailIds, emails } = get();
const { selectedEmailKeys, emails } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailIds.size === 0) return;
if (selectedEmailKeys.size === 0) return;
// Scope the archive folder to the viewed shared/group account (if any) so the
// move lands on the owner account, not the user's own archive (which appears
@@ -3292,7 +3321,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const mode = useSettingsStore.getState().archiveMode;
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
const selected = emails.filter(e => selectedEmailIds.has(e.id));
const selected = emails.filter(e => selectedEmailKeys.has(emailKeyFor(e)));
if (selected.length === 0) return;
set({ isLoading: true, error: null });
@@ -3305,8 +3334,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
archiveMailbox.accountId,
);
const remaining = emails.filter(e => !selectedEmailIds.has(e.id));
set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false });
const remaining = emails.filter(e => !selectedEmailKeys.has(emailKeyFor(e)));
set({ emails: remaining, selectedEmailKeys: new Set(), isLoading: false });
// Refresh the active or viewed account's mailbox cache after the
// archive (a year/month archive can create new sub-folders).
@@ -3546,7 +3575,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return {
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
...patch,
};
});
@@ -3610,7 +3639,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return {
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
selectedEmailKeys: new Set(),
...patch,
};
});
@@ -4703,7 +4732,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
scheduledAccountScope: isScheduledView ? accountScope : null,
selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox,
selectedEmail: leavingScheduled ? null : state.selectedEmail,
selectedEmailIds: leavingScheduled ? new Set<string>() : state.selectedEmailIds,
selectedEmailKeys: leavingScheduled ? new Set<string>() : state.selectedEmailKeys,
// Search is unavailable in the scheduled view (the input is disabled there).
// Reset any active search when entering it so a stale query can't linger or
// re-run when the user leaves again.
@@ -4837,7 +4866,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
await client.deleteEmail(emailId);
set(state => ({
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== emailId)),
selectedEmailKeys: dropFromSelection(state, e => e.id === emailId),
}));
}
if (get().pendingUndoSend?.submissionId === submissionId) {
@@ -4857,7 +4886,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
await client.deleteEmail(email.id);
set(state => ({
selectedEmail: state.selectedEmail?.id === email.id ? null : state.selectedEmail,
selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== email.id)),
selectedEmailKeys: dropFromSelection(state, e => e.id === email.id),
}));
await get().fetchScheduledEmails(client);
return null;
@@ -4907,7 +4936,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
await client.deleteEmail(pending.emailId);
set(state => ({
selectedEmail: state.selectedEmail?.id === pending.emailId ? null : state.selectedEmail,
selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== pending.emailId)),
selectedEmailKeys: dropFromSelection(state, e => e.id === pending.emailId),
}));
} else if (pending.emailId) {
const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes();