Files
webmail/stores/__tests__/email-selection.test.ts
T
lucamzanon fac3bdf31c 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.
2026-09-19 08:59:21 +02:00

125 lines
4.6 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import { useEmailStore } from '../email-store';
function makeEmail(id: string, threadId = `thread-${id}`) {
return {
id,
threadId,
mailboxIds: { inbox: true },
keywords: {},
size: 100,
receivedAt: new Date().toISOString(),
from: [{ name: 'Test', email: 'test@example.com' }],
to: [{ name: 'User', email: 'user@example.com' }],
subject: `Email ${id}`,
preview: 'preview',
hasAttachment: false,
textBody: [],
htmlBody: [],
bodyValues: {},
};
}
describe('email-store selection', () => {
beforeEach(() => {
useEmailStore.setState({
emails: [makeEmail('a'), makeEmail('b'), makeEmail('c'), makeEmail('d'), makeEmail('e')],
selectedEmailKeys: new Set(),
lastSelectedEmailKey: null,
selectedEmail: null,
});
});
describe('toggleEmailSelection', () => {
it('should add email to selection', () => {
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({ id: 'b' });
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
expect(useEmailStore.getState().selectedEmailKeys.has('b')).toBe(false);
});
it('should support selecting multiple emails', () => {
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);
});
});
describe('selectRangeEmails', () => {
it('should select range from last selected to target (forward)', () => {
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);
expect(ids.size).toBe(3);
});
it('should select range backward', () => {
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);
expect(ids.size).toBe(3);
});
it('should use first email as anchor when no previous selection', () => {
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);
expect(ids.size).toBe(3);
});
it('should add to existing selection', () => {
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);
expect(ids.has('c')).toBe(true);
expect(ids.has('d')).toBe(true);
expect(ids.size).toBe(4);
});
it('should handle single-item range', () => {
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);
});
});
describe('selectAllEmails', () => {
it('should select all emails', () => {
useEmailStore.getState().selectAllEmails();
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(5);
});
});
describe('clearSelection', () => {
it('should clear all selections and reset anchor', () => {
useEmailStore.getState().toggleEmailSelection({ id: 'a' });
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
useEmailStore.getState().clearSelection();
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
expect(useEmailStore.getState().lastSelectedEmailKey).toBeNull();
});
});
});