mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge pull request #1384 from VasilisNtovolos/fix/bulk-delete-partial-results
fix: reconcile confirmed permanent bulk deletions
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import { clearAllStores } from '@/lib/account-state-manager';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { clearAllHooks, emailHooks } from '@/lib/plugin-hooks';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { MvListToolbar } from '../mv-list-toolbar';
|
||||
import { EmailList } from '../email-list';
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-email-drag', () => ({ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }) }));
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
const initial = useEmailStore.getState();
|
||||
let calls: string[][];
|
||||
let result: object | Promise<object>;
|
||||
beforeEach(() => {
|
||||
clearAllHooks(); vi.clearAllMocks(); calls = []; result = { destroyed: ['ok', 'no'] };
|
||||
const client = JMAPClient.withBearer('https://delete.example.invalid', 'synthetic', 'test@example.invalid');
|
||||
Object.assign(client, { apiUrl: 'https://delete.example.invalid/jmap', accountId: 'c' });
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
|
||||
expect(url).toBe('https://delete.example.invalid/jmap');
|
||||
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer synthetic');
|
||||
const [[method, args]] = JSON.parse(String(init.body)).methodCalls;
|
||||
if (method === 'Mailbox/get') return new Response(JSON.stringify({ methodResponses: [[method, { list: [] }, '0']] }));
|
||||
expect(method).toBe('Email/set'); expect(args.accountId).toBe('c'); calls.push(args.destroy);
|
||||
return new Response(JSON.stringify({ methodResponses: [['Email/set', await result, '0']] }));
|
||||
}));
|
||||
useAuthStore.setState({ activeAccountId: 'test', client, getClientForAccount: () => client } as never);
|
||||
useSettingsStore.setState({ confirmBulkDelete: false, permanentlyDeleteJunk: false, deleteAction: 'trash', interfaceLayout: 'default' });
|
||||
useEmailStore.setState(initial, true);
|
||||
const emails = ['ok', 'no'].map(id => ({ id, threadId: 't-' + id, blobId: 'b-' + id,
|
||||
mailboxIds: { trash: true }, keywords: {}, subject: id, receivedAt: '2026-10-01T00:00:00Z',
|
||||
from: [{ email: 'test@example.invalid' }], size: 1, hasAttachment: false } as Email));
|
||||
useEmailStore.setState({ emails, mailboxes: [{ id: 'trash', name: 'Trash', role: 'trash', accountId: 'c',
|
||||
totalEmails: 2, unreadEmails: 2, totalThreads: 2, unreadThreads: 2,
|
||||
myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true } } as Mailbox],
|
||||
selectedMailbox: 'trash', selectedEmailKeys: new Set(['ok', 'no']), selectedEmail: emails[1], totalEmails: 2 });
|
||||
});
|
||||
afterEach(() => { clearAllHooks(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
|
||||
async function clickDelete(surface: string) {
|
||||
if (surface === 'context') {
|
||||
// Give jsdom's real virtualized rows a layout so the actual row menu is reachable.
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(300);
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600);
|
||||
vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(600);
|
||||
vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(300);
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
const height = this.hasAttribute('data-index') ? 60 : 300;
|
||||
return { x: 0, y: 0, width: 600, height, top: 0, left: 0, right: 600, bottom: height, toJSON: () => ({}) } as DOMRect;
|
||||
});
|
||||
}
|
||||
render(surface === 'mountain' ? <MvListToolbar loadedCount={2} onRefresh={() => {}} />
|
||||
: <EmailList emails={useEmailStore.getState().emails} />);
|
||||
if (surface === 'context') {
|
||||
fireEvent.contextMenu(await screen.findByText('ok')); fireEvent.click(await screen.findByTestId('ctx-delete'));
|
||||
} else {
|
||||
fireEvent.click(surface === 'mountain' ? screen.getByLabelText('delete') : screen.getByTitle('batch_actions.delete'));
|
||||
fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'permanent_delete' }));
|
||||
}
|
||||
}
|
||||
|
||||
for (const surface of ['mountain', 'default', 'context']) describe(surface + ' permanent bulk-delete feedback', () => {
|
||||
it.each(['partial', 'empty', 'success'])('reports %s using the real action and serialized JMAP request', async kind => {
|
||||
result = kind === 'partial' ? { destroyed: ['ok'], notDestroyed: { no: { type: 'forbidden', description: 'Synthetic refusal' } } }
|
||||
: kind === 'empty' ? {} : { destroyed: ['ok', 'no'] };
|
||||
await clickDelete(surface);
|
||||
await waitFor(() => expect(toast[kind === 'success' ? 'success' : 'error']).toHaveBeenCalledTimes(1));
|
||||
expect(calls).toEqual([['ok', 'no']]);
|
||||
expect(toast[kind === 'success' ? 'error' : 'success']).not.toHaveBeenCalled();
|
||||
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(kind === 'success' ? [] : kind === 'partial' ? ['no'] : ['ok', 'no']);
|
||||
if (kind === 'partial') expect(toast.error).toHaveBeenCalledWith('error_deleting', expect.stringContaining('Synthetic refusal'));
|
||||
});
|
||||
|
||||
it('keeps a plugin veto silent without sending a delete', async () => {
|
||||
const before = vi.fn(() => false); emailHooks.onBeforeEmailDelete.register('veto', before);
|
||||
await clickDelete(surface); await waitFor(() => expect(before).toHaveBeenCalledTimes(1));
|
||||
expect(calls).toEqual([]); expect(toast.success).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled();
|
||||
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
for (const surface of ['mountain', 'default', 'context']) describe(surface + ' pending permanent-delete failure feedback', () => {
|
||||
it.each(['same-view', 'navigation', 'reset'])('does not report success after rejection in %s', async kind => {
|
||||
let reject!: (error: Error) => void;
|
||||
result = new Promise<object>((_resolve, fail) => { reject = fail; });
|
||||
const batchDelete = vi.fn(initial.batchDelete);
|
||||
useEmailStore.setState({ batchDelete });
|
||||
await clickDelete(surface); await waitFor(() => expect(calls).toHaveLength(1));
|
||||
const completion = batchDelete.mock.results[0].value;
|
||||
let expected = useEmailStore.getState();
|
||||
await act(async () => {
|
||||
if (kind === 'reset') clearAllStores();
|
||||
if (kind === 'navigation') {
|
||||
useEmailStore.getState().selectMailbox('new-folder');
|
||||
const fresh = { ...initial.emails[0], id: 'fresh', threadId: 'fresh', keywords: {}, mailboxIds: { 'new-folder': true },
|
||||
size: 1, hasAttachment: false, receivedAt: '2026-10-01T00:00:00Z' } as Email;
|
||||
useEmailStore.setState({ emails: [fresh], selectedEmailKeys: new Set(['fresh']), selectedEmail: fresh,
|
||||
isLoading: true, error: null });
|
||||
}
|
||||
expected = useEmailStore.getState();
|
||||
reject(new Error('Old delete failed'));
|
||||
expect(await completion).toBe(false);
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
if (kind === 'same-view') {
|
||||
expect(useEmailStore.getState().error).toContain('Old delete failed');
|
||||
expect(toast.error).toHaveBeenCalledWith('error_deleting', expect.stringContaining('Old delete failed'));
|
||||
} else {
|
||||
const current = useEmailStore.getState();
|
||||
expect(current.emails).toBe(expected.emails); expect(current.selectedEmailKeys).toBe(expected.selectedEmailKeys);
|
||||
expect(current.selectedEmail).toBe(expected.selectedEmail); expect(current.selectedMailbox).toBe(expected.selectedMailbox);
|
||||
expect(current.isLoading).toBe(expected.isLoading); expect(current.error).toBe(expected.error);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { JMAPClient, RequestTimeoutError } from '../jmap/client';
|
||||
import { jmapErrorFailedIds, jmapErrorProperties, jmapErrorType } from '../jmap/jmap-error';
|
||||
|
||||
const refused = { type: 'invalidProperties', properties: ['id'], description: 'Synthetic refusal' };
|
||||
const response = (result: object) => new Response(JSON.stringify({ methodResponses: [['Email/set', result, '0']] }));
|
||||
function setup(replies: Array<Response | Error>, size = 2) {
|
||||
const client = JMAPClient.withBearer('https://delete.example.invalid', 'synthetic-delete', 'test@example.invalid');
|
||||
Object.assign(client, { accountId: 'owner', apiUrl: 'https://delete.example.invalid/jmap',
|
||||
capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInSet: size } } });
|
||||
const requests: string[][] = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
|
||||
expect(url).toBe('https://delete.example.invalid/jmap');
|
||||
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer synthetic-delete');
|
||||
const [[method, args]] = JSON.parse(String(init.body)).methodCalls;
|
||||
expect(method).toBe('Email/set'); expect(args.accountId).toBe('owner');
|
||||
requests.push(args.destroy);
|
||||
const reply = replies.shift();
|
||||
if (reply instanceof Error) throw reply;
|
||||
if (!reply) throw new Error('Unexpected synthetic request');
|
||||
return reply;
|
||||
}));
|
||||
return { client, requests };
|
||||
}
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('bulk permanent deletion confirmations', () => {
|
||||
it('keeps Promise<void> success and request limits when every ID is confirmed', async () => {
|
||||
const { client, requests } = setup([response({ destroyed: ['a', 'b'] }), response({ destroyed: ['c'] })]);
|
||||
await expect(client.batchDeleteEmails(['a', 'b', 'c'])).resolves.toBeUndefined();
|
||||
expect(requests).toEqual([['a', 'b'], ['c']]);
|
||||
});
|
||||
|
||||
it('throws typed partial progress and preserves SetError metadata and cause', async () => {
|
||||
const { client } = setup([response({ destroyed: ['a'], notDestroyed: { b: refused } })]);
|
||||
const error = await client.batchDeleteEmails(['a', 'b']).catch(e => e);
|
||||
expect(error).toMatchObject({ name: 'IncompleteDeleteError', destroyedIds: ['a'], notDestroyed: { b: refused }, unknownIds: [], unattemptedIds: [] });
|
||||
expect(jmapErrorType(error)).toBe('invalidProperties'); expect(jmapErrorProperties(error)).toEqual(['id']);
|
||||
expect(jmapErrorFailedIds(error)).toEqual(['b']); expect(error.cause).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it.each([{}, { destroyed: [] }, { destroyed: ['a'] }])('does not infer success from missing rejection maps: %j', async result => {
|
||||
const { client } = setup([response(result)]);
|
||||
const error = await client.batchDeleteEmails(['a', 'b']).catch(e => e);
|
||||
const destroyed = (result as { destroyed?: string[] }).destroyed ?? [];
|
||||
expect(error).toMatchObject({ name: 'IncompleteDeleteError', destroyedIds: destroyed,
|
||||
unknownIds: destroyed.length ? ['b'] : ['a', 'b'], unattemptedIds: [] });
|
||||
});
|
||||
|
||||
it('ignores confirmations from an unexpected method response', async () => {
|
||||
const { client } = setup([new Response(JSON.stringify({ methodResponses: [['Email/get', { destroyed: ['a'] }, '0']] }))]);
|
||||
await expect(client.batchDeleteEmails(['a'])).rejects.toMatchObject({ destroyedIds: [], unknownIds: ['a'] });
|
||||
});
|
||||
|
||||
it('retains earlier confirmations and stops after an incomplete response', async () => {
|
||||
const { client, requests } = setup([response({ destroyed: ['a', 'b'] }), response({ destroyed: ['d'], notDestroyed: { c: refused } })]);
|
||||
await expect(client.batchDeleteEmails(['a', 'b', 'c', 'd', 'e'])).rejects.toMatchObject({
|
||||
destroyedIds: ['a', 'b', 'd'], notDestroyed: { c: refused }, unknownIds: [], unattemptedIds: ['e'],
|
||||
});
|
||||
expect(requests).toEqual([['a', 'b'], ['c', 'd']]);
|
||||
});
|
||||
|
||||
it('preserves method refusal details separately from a missing confirmation', async () => {
|
||||
const reply = new Response(JSON.stringify({ methodResponses: [['error', { type: 'accountReadOnly', description: 'Read only' }, '0']] }));
|
||||
const { client } = setup([response({ destroyed: ['a', 'b'] }), reply]);
|
||||
const error = await client.batchDeleteEmails(['a', 'b', 'c', 'd', 'e']).catch(e => e);
|
||||
expect(error).toMatchObject({ destroyedIds: ['a', 'b'], notDestroyed: { c: { type: 'accountReadOnly' }, d: { type: 'accountReadOnly' } }, unknownIds: [], unattemptedIds: ['e'] });
|
||||
expect(jmapErrorType(error)).toBe('accountReadOnly');
|
||||
});
|
||||
|
||||
it('keeps the lost-response batch unknown, later IDs unattempted, and the transport cause', async () => {
|
||||
const cause = new RequestTimeoutError(30_000);
|
||||
const { client, requests } = setup([response({ destroyed: ['a', 'b'] }), cause]);
|
||||
const error = await client.batchDeleteEmails(['a', 'b', 'c', 'd', 'e']).catch(e => e);
|
||||
expect(error).toMatchObject({ destroyedIds: ['a', 'b'], notDestroyed: {}, unknownIds: ['c', 'd'], unattemptedIds: ['e'], cause });
|
||||
expect(requests).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -215,7 +215,7 @@ describe('JMAPClient request limits', () => {
|
||||
|
||||
it('splits batchDeleteEmails at maxObjectsInSet', async () => {
|
||||
const client = await connectedClient({ maxObjectsInSet: 500 });
|
||||
const sent = recordRequests(() => ({ methodResponses: [['Email/set', { destroyed: [] }, '0']] }));
|
||||
const sent = recordRequests(calls => ({ methodResponses: [['Email/set', { destroyed: calls[0][1].destroy }, '0']] }));
|
||||
|
||||
await client.batchDeleteEmails(ids);
|
||||
|
||||
|
||||
@@ -343,6 +343,7 @@ export interface IJMAPClient {
|
||||
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<KeywordMigration>;
|
||||
deleteEmail(emailId: string, accountId?: string): Promise<void>;
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
/** Resolves only when every ID is destroyed; IncompleteDeleteError carries partial confirmations. */
|
||||
batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void>;
|
||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
batchArchiveEmails(
|
||||
|
||||
+39
-8
@@ -1,3 +1,4 @@
|
||||
import { IncompleteDeleteError } from "./delete-error";
|
||||
import { ArchiveEmailsError } from "./archive-error";
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
@@ -3317,14 +3318,44 @@ export class JMAPClient implements IJMAPClient {
|
||||
async batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void> {
|
||||
if (emailIds.length === 0) return;
|
||||
|
||||
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||||
const response = await this.request([
|
||||
["Email/set", {
|
||||
accountId: accountId || this.accountId,
|
||||
destroy: batch,
|
||||
}, "0"],
|
||||
]);
|
||||
this.assertEmailSetSucceeded(response, 'delete emails');
|
||||
const batches = batched(emailIds, this.getMaxObjectsInSet());
|
||||
const destroyedIds: string[] = [];
|
||||
for (const [index, batch] of batches.entries()) {
|
||||
let response: JMAPResponse;
|
||||
try {
|
||||
response = await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, destroy: batch }, "0"],
|
||||
]);
|
||||
} catch (cause) {
|
||||
// The server may have applied this batch; only later batches are unsent.
|
||||
throw new IncompleteDeleteError({ destroyedIds, notDestroyed: {}, unknownIds: batch,
|
||||
unattemptedIds: batches.slice(index + 1).flat() }, cause);
|
||||
}
|
||||
const [name, result] = response.methodResponses?.[0] ?? [];
|
||||
const notDestroyed: Record<string, JmapErrorInfo> = {};
|
||||
const unknownIds: string[] = [];
|
||||
for (const id of batch) {
|
||||
if (name === 'Email/set' && Object.prototype.hasOwnProperty.call(result?.notDestroyed ?? {}, id)) {
|
||||
notDestroyed[id] = result.notDestroyed[id];
|
||||
} else if (name === 'Email/set' && Array.isArray(result?.destroyed) && result.destroyed.includes(id)) {
|
||||
destroyedIds.push(id);
|
||||
} else if (name === 'error') {
|
||||
notDestroyed[id] = result;
|
||||
} else {
|
||||
unknownIds.push(id);
|
||||
}
|
||||
}
|
||||
let cause: unknown;
|
||||
try {
|
||||
this.assertEmailSetSucceeded(response, 'delete emails');
|
||||
} catch (error) {
|
||||
cause = error;
|
||||
}
|
||||
if (cause || Object.keys(notDestroyed).length > 0 || unknownIds.length > 0) {
|
||||
throw new IncompleteDeleteError({ destroyedIds, notDestroyed, unknownIds,
|
||||
unattemptedIds: batches.slice(index + 1).flat() },
|
||||
cause ?? new Error('Failed to delete emails: deletion was not confirmed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { JmapError, JmapErrorInfo } from './jmap-error';
|
||||
|
||||
/** A bulk destroy stopped before every requested message was confirmed deleted. */
|
||||
export class IncompleteDeleteError extends Error implements JmapError {
|
||||
readonly destroyedIds: string[];
|
||||
readonly notDestroyed: Record<string, JmapErrorInfo>;
|
||||
readonly unknownIds: string[];
|
||||
readonly unattemptedIds: string[];
|
||||
jmapType?: string;
|
||||
jmapProperties?: string[];
|
||||
jmapSetError?: boolean;
|
||||
jmapFailedIds?: string[];
|
||||
|
||||
constructor(progress: {
|
||||
destroyedIds: string[];
|
||||
notDestroyed: Record<string, JmapErrorInfo>;
|
||||
unknownIds: string[];
|
||||
unattemptedIds: string[];
|
||||
}, cause: unknown) {
|
||||
super(cause instanceof Error ? cause.message : 'Failed to delete emails: deletion was not confirmed', { cause });
|
||||
this.name = 'IncompleteDeleteError';
|
||||
this.destroyedIds = [...progress.destroyedIds];
|
||||
this.notDestroyed = { ...progress.notDestroyed };
|
||||
this.unknownIds = [...progress.unknownIds];
|
||||
this.unattemptedIds = [...progress.unattemptedIds];
|
||||
const metadata = cause as JmapError | undefined;
|
||||
this.jmapType = metadata?.jmapType;
|
||||
this.jmapProperties = metadata?.jmapProperties;
|
||||
this.jmapSetError = metadata?.jmapSetError;
|
||||
this.jmapFailedIds = metadata?.jmapFailedIds;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { emailKeyFor } from '@/lib/thread-utils';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
@@ -17,13 +19,34 @@ const twin = (account: string, subject: string): Email => ({
|
||||
sourceClientAccountId: account, sourceAccountId: account,
|
||||
} as unknown as Email);
|
||||
|
||||
const initialAuthState = useAuthStore.getState();
|
||||
const initialEmailState = useEmailStore.getState();
|
||||
const makeClient = () => ({
|
||||
batchDeleteEmails: vi.fn(async () => {}),
|
||||
getMailboxes: vi.fn(async () => []),
|
||||
}) as unknown as IJMAPClient;
|
||||
let clientA: IJMAPClient;
|
||||
let clientB: IJMAPClient;
|
||||
|
||||
const mine = twin('account-a', 'mia');
|
||||
const theirs = twin('account-b', 'di un altro account');
|
||||
|
||||
beforeEach(() => {
|
||||
clientA = makeClient();
|
||||
clientB = makeClient();
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'account-a', client: clientA,
|
||||
getClientForAccount: (id: string) => (id === 'account-a' ? clientA : id === 'account-b' ? clientB : undefined) as never,
|
||||
});
|
||||
useEmailStore.setState(initialEmailState, true);
|
||||
useEmailStore.setState({ emails: [mine, theirs], selectedEmailKeys: new Set(), lastSelectedEmailKey: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useEmailStore.setState(initialEmailState, true);
|
||||
useAuthStore.setState(initialAuthState, true);
|
||||
});
|
||||
|
||||
describe('selection across accounts sharing an email id', () => {
|
||||
it('ticks only the row that was clicked', () => {
|
||||
useEmailStore.getState().toggleEmailSelection(mine);
|
||||
@@ -49,14 +72,28 @@ describe('selection across accounts sharing an email id', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
expect(await useEmailStore.getState().batchDelete(clientA, true)).toBe(true);
|
||||
const remaining = useEmailStore.getState().emails;
|
||||
expect(remaining.map(e => e.sourceAccountId)).toEqual(['account-a']);
|
||||
expect(remaining[0]).toBe(mine);
|
||||
expect(clientB.batchDeleteEmails).toHaveBeenCalledTimes(1);
|
||||
expect(clientB.batchDeleteEmails).toHaveBeenCalledWith(['eiaaaabc'], 'account-b');
|
||||
expect(clientA.batchDeleteEmails).not.toHaveBeenCalled();
|
||||
expect(useEmailStore.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
it('retains both copies when the selected source client is unavailable', async () => {
|
||||
useAuthStore.setState({
|
||||
getClientForAccount: (id: string) => (id === 'account-a' ? clientA : undefined) as never,
|
||||
});
|
||||
useEmailStore.getState().toggleEmailSelection(theirs);
|
||||
expect(await useEmailStore.getState().batchDelete(clientA, true)).toBe(false);
|
||||
const state = useEmailStore.getState();
|
||||
expect(state.error).toBe('Not connected');
|
||||
expect(state.emails).toEqual([mine, theirs]);
|
||||
expect([...state.selectedEmailKeys]).toEqual([emailKeyFor(theirs)]);
|
||||
expect(clientA.batchDeleteEmails).not.toHaveBeenCalled();
|
||||
expect(clientB.batchDeleteEmails).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('bulk Delete/Trash authenticated-client routing', () => {
|
||||
expectWrite('b', { accountId: 'c', destroy: ['q'] });
|
||||
expect(useEmailStore.getState().emails).toEqual([unselected]);
|
||||
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
|
||||
expect(requests.filter(r => r.method === 'Mailbox/get')).toEqual([]);
|
||||
expect(requests.filter(r => r.method === 'Mailbox/get').map(r => [r.login, r.args.accountId])).toEqual([['a', 'c'], ['b', 'c']]);
|
||||
});
|
||||
|
||||
it('resolves each Trash in the same login and owner, despite a colliding folder cache', async () => {
|
||||
@@ -141,7 +141,7 @@ describe('bulk Delete/Trash authenticated-client routing', () => {
|
||||
expect(await useEmailStore.getState().batchDelete(a, permanent)).toBe(true);
|
||||
expectWrite('a', permanent ? { accountId: 'c', destroy: ['p'] }
|
||||
: { accountId: 'c', update: { p: { mailboxIds: { 'primary-trash': true } } } });
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests).toHaveLength(permanent ? 2 : 1);
|
||||
});
|
||||
|
||||
it.each([false, true])('preserves direct shared-folder routing through the viewing client (permanent=%s)', async permanent => {
|
||||
@@ -153,7 +153,7 @@ describe('bulk Delete/Trash authenticated-client routing', () => {
|
||||
expect(await useEmailStore.getState().batchDelete(a, permanent)).toBe(true);
|
||||
expectWrite('b', permanent ? { accountId: 'd', destroy: ['p'] }
|
||||
: { accountId: 'd', update: { p: { mailboxIds: { trash: true } } } });
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests).toHaveLength(permanent ? 2 : 1);
|
||||
});
|
||||
|
||||
it.each([false, true])('routes selected expanded-thread members with colliding IDs (permanent=%s)', async permanent => {
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { JMAPClient, RequestTimeoutError } from '@/lib/jmap/client';
|
||||
import { clearAllStores } from '@/lib/account-state-manager';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import { useMessageListTabsStore } from '../message-list-tabs-store';
|
||||
import { useSettingsStore } from '../settings-store';
|
||||
import { clearAllHooks, emailHooks } from '@/lib/plugin-hooks';
|
||||
import { emailKeyFor, threadKeyFor } from '@/lib/thread-utils';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
const initial = useEmailStore.getState();
|
||||
const initialTabs = useMessageListTabsStore.getState();
|
||||
const row = (id: string, login = 'a', owner = 'c'): Email => ({ id, threadId: 't', sourceClientAccountId: login,
|
||||
sourceAccountId: owner, size: 100, hasAttachment: false, keywords: {}, mailboxIds: { inbox: true }, receivedAt: '2026-10-01T00:00:00Z' } as Email);
|
||||
const folder = (id = 'inbox', accountId = 'c'): Mailbox => ({ id, accountId, name: id, role: 'inbox',
|
||||
totalEmails: 10, unreadEmails: 10, totalThreads: 10, unreadThreads: 10 } as Mailbox);
|
||||
function makeClient(login: string, size = 500) {
|
||||
const client = JMAPClient.withBearer(`https://${login}.example.invalid`, `synthetic-${login}`, `${login}@example.invalid`);
|
||||
Object.assign(client, { accountId: 'c', apiUrl: `https://${login}.example.invalid/jmap`,
|
||||
capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInSet: size } } });
|
||||
return client;
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
const promise = new Promise<T>(r => { resolve = r; }); return { promise, resolve };
|
||||
}
|
||||
type Request = { login: string; accountId: string; destroy: string[] };
|
||||
let a: JMAPClient, b: JMAPClient, requests: Request[];
|
||||
let reply: (r: Request, index: number) => object | Promise<object>;
|
||||
let after = vi.fn<(...args: unknown[]) => void>();
|
||||
let refresh = vi.fn(async () => {});
|
||||
function select(rows: Email[], selected = rows) {
|
||||
useEmailStore.setState({ emails: rows, selectedEmailKeys: new Set(selected.map(emailKeyFor)), selectedEmail: selected.at(-1) ?? null });
|
||||
}
|
||||
const state = () => useEmailStore.getState();
|
||||
const no = { type: 'forbidden', description: 'Synthetic refusal' };
|
||||
beforeEach(() => {
|
||||
useMessageListTabsStore.setState(initialTabs, true);
|
||||
clearAllHooks(); a = makeClient('a', 2); b = makeClient('b'); requests = [];
|
||||
reply = r => ({ destroyed: r.destroy });
|
||||
after = vi.fn(); refresh = vi.fn(async () => {});
|
||||
emailHooks.onAfterEmailDelete.register('test', after);
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
|
||||
const login = ['a', 'b'].find(id => url === `https://${id}.example.invalid/jmap`);
|
||||
if (!login) throw new Error(`Blocked unexpected URL: ${url}`);
|
||||
expect(new Headers(init.headers).get('Authorization')).toBe(`Bearer synthetic-${login}`);
|
||||
const [[method, args]] = JSON.parse(String(init.body)).methodCalls;
|
||||
if (method === 'Mailbox/get') return new Response(JSON.stringify({ methodResponses: [[method, { list: [] }, '0']] }));
|
||||
expect(method).toBe('Email/set');
|
||||
const r = { login, ...args }; const index = requests.length; requests.push(r);
|
||||
const result = await reply(r, index);
|
||||
return new Response(JSON.stringify({ methodResponses: [['Email/set', result, '0']] }));
|
||||
}));
|
||||
useAuthStore.setState({ activeAccountId: 'a', client: a, getClientForAccount: (id: string) => id === 'a' ? a : id === 'b' ? b : undefined } as never);
|
||||
useSettingsStore.setState({ deleteAction: 'trash', permanentlyDeleteJunk: false });
|
||||
useEmailStore.setState(initial, true);
|
||||
useEmailStore.setState({ isUnifiedView: true, unifiedRole: 'inbox', selectedMailbox: '__unified_inbox__',
|
||||
mailboxes: [folder()], accountMailboxes: { b: [folder()] }, totalEmails: 10,
|
||||
refreshCurrentMailbox: refresh });
|
||||
});
|
||||
afterEach(() => { clearAllHooks(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe('selected permanent-delete reconciliation', () => {
|
||||
it('keeps refused rows selected, reconciles confirmed IDs, and retries only the remainder', async () => {
|
||||
const ok = row('ok'), refused = row('no'); select([ok, refused]);
|
||||
reply = () => ({ destroyed: ['ok'], notDestroyed: { no } });
|
||||
expect(await state().batchDelete(a, true)).toBe(false);
|
||||
expect(state().emails).toEqual([refused]); expect([...state().selectedEmailKeys]).toEqual([emailKeyFor(refused)]);
|
||||
expect(state().selectedEmail).toBe(refused); expect(state().error).toContain('Synthetic refusal');
|
||||
expect(state().totalEmails).toBe(9); expect(state().mailboxes[0].totalEmails).toBe(9);
|
||||
expect(after).toHaveBeenCalledWith({ emailIds: ['ok'], permanent: true });
|
||||
reply = r => ({ destroyed: r.destroy }); await state().batchDelete(a, true);
|
||||
expect(requests.map(r => r.destroy)).toEqual([['ok', 'no'], ['no']]);
|
||||
expect(state().selectedEmailKeys.size).toBe(0); expect(state().selectedEmail).toBeNull();
|
||||
});
|
||||
|
||||
it.each([{}, { destroyed: [] }])('retains everything when nothing is confirmed: %j', async result => {
|
||||
const selected = row('missing'); select([selected]); reply = () => result;
|
||||
expect(await state().batchDelete(a, true)).toBe(false);
|
||||
expect(state().emails).toEqual([selected]); expect(state().selectedEmail).toBe(selected);
|
||||
expect(state().selectedEmailKeys.has(emailKeyFor(selected))).toBe(true);
|
||||
expect(after).not.toHaveBeenCalled(); expect(state().mailboxes[0].totalEmails).toBe(10);
|
||||
});
|
||||
|
||||
it.each(['refusal', 'transport'])('keeps confirmed earlier chunks after %s', async kind => {
|
||||
const rows = ['p', 'q', 'r', 's', 'unsent'].map(id => row(id)); select(rows);
|
||||
reply = (r, i) => {
|
||||
if (!i) return { destroyed: r.destroy };
|
||||
if (kind === 'transport') throw new RequestTimeoutError(30_000);
|
||||
return { destroyed: ['s'], notDestroyed: { r: no } };
|
||||
};
|
||||
expect(await state().batchDelete(a, true)).toBe(false);
|
||||
expect(state().emails.map(e => e.id)).toEqual(kind === 'transport' ? ['r', 's', 'unsent'] : ['r', 'unsent']);
|
||||
expect(requests).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('awaits a slower sibling, and keeps colliding failed IDs and unselected namesakes', async () => {
|
||||
const slow = deferred<object>(), started = deferred<void>();
|
||||
const ok = row('same', 'a'), refused = row('same', 'b'), namesake = row('other', 'a');
|
||||
select([ok, refused, namesake], [ok, refused]);
|
||||
reply = r => r.login === 'a' ? (started.resolve(), slow.promise) : { notDestroyed: { same: no } };
|
||||
const pending = state().batchDelete(a, true); let settled = false; void pending.then(() => { settled = true; });
|
||||
await started.promise; await Promise.resolve(); expect(settled).toBe(false);
|
||||
slow.resolve({ destroyed: ['same'] }); expect(await pending).toBe(false);
|
||||
expect(state().emails).toEqual([refused, namesake]); expect([...state().selectedEmailKeys]).toEqual([emailKeyFor(refused)]);
|
||||
expect(requests).toEqual([{ login: 'a', accountId: 'c', destroy: ['same'] }, { login: 'b', accountId: 'c', destroy: ['same'] }]);
|
||||
});
|
||||
|
||||
it('never sends an unselected same owner/email ID through another authenticated client', async () => {
|
||||
const namesake = row('same', 'a'), selected = row('same', 'b'); select([namesake, selected], [selected]);
|
||||
expect(await state().batchDelete(a, true)).toBe(true); expect(state().emails).toEqual([namesake]);
|
||||
expect(requests).toEqual([{ login: 'b', accountId: 'c', destroy: ['same'] }]);
|
||||
});
|
||||
|
||||
it('retains unavailable groups and never falls back to the active client', async () => {
|
||||
const unavailable = row('same', 'b'); select([unavailable]);
|
||||
useAuthStore.setState({ getClientForAccount: () => undefined } as never);
|
||||
expect(await state().batchDelete(a, true)).toBe(false); expect(requests).toEqual([]);
|
||||
expect(state().emails).toEqual([unavailable]); expect(state().error).toMatch(/connected/i);
|
||||
expect(state().selectedEmailKeys.has(emailKeyFor(unavailable))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not fall back when a directly viewed login is unavailable', async () => {
|
||||
const selected = { ...row('x'), sourceAccountId: undefined, sourceClientAccountId: undefined };
|
||||
select([selected]); useEmailStore.setState({ viewingAccountId: 'gone', isUnifiedView: false });
|
||||
expect(await state().batchDelete(a, true)).toBe(false); expect(requests).toEqual([]);
|
||||
expect(state().emails).toEqual([selected]);
|
||||
});
|
||||
|
||||
it('prunes confirmed thread children, updates counts, and preserves an unrelated viewer', async () => {
|
||||
const parent = row('parent'), child = row('child'), key = threadKeyFor(parent); select([parent], [child]);
|
||||
useEmailStore.setState({ selectedEmail: parent, threadEmailsCache: new Map([[key, [parent, child]]]),
|
||||
expandedThreadIds: new Set([key]), threadEmailCounts: new Map([[key, 2]]) });
|
||||
expect(await state().batchDelete(a, true)).toBe(true);
|
||||
expect(state().threadEmailsCache.get(key)).toEqual([parent]); expect(state().threadEmailCounts.get(key)).toBe(1);
|
||||
expect(state().selectedEmail).toBe(parent); expect(state().expandedThreadIds.has(key)).toBe(true);
|
||||
expect(state().totalEmails).toBe(10); // A child outside this query is not a removed query row.
|
||||
await state().batchDelete(a, true); expect(requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches a stamped viewer to an unstamped row without clearing another owner namesake', async () => {
|
||||
const selected = { ...row('same'), sourceAccountId: undefined, sourceClientAccountId: undefined };
|
||||
select([selected]); useEmailStore.setState({ isUnifiedView: false, selectedMailbox: 'inbox', selectedEmail: row('same', 'b') });
|
||||
await state().batchDelete(a, true); expect(state().selectedEmail).toEqual(row('same', 'b'));
|
||||
select([selected]); useEmailStore.setState({ selectedEmail: row('same', 'a') });
|
||||
await state().batchDelete(a, true); expect(state().selectedEmail).toBeNull();
|
||||
});
|
||||
|
||||
it('does not overwrite a new view when its request completes', async () => {
|
||||
select([row('old'), row('old-other')], [row('old')]); const gate = deferred<object>(), started = deferred<void>();
|
||||
reply = () => { started.resolve(); return gate.promise; };
|
||||
const pending = state().batchDelete(a, true); await started.promise;
|
||||
state().selectMailbox('new-folder'); const fresh = row('fresh', 'b'); select([fresh]);
|
||||
gate.resolve({ destroyed: ['old'] }); await pending;
|
||||
expect(state().selectedMailbox).toBe('new-folder'); expect(state().emails).toEqual([fresh]);
|
||||
expect(state().selectedEmail).toBe(fresh); expect([...state().selectedEmailKeys]).toEqual([emailKeyFor(fresh)]);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rebases on same-view arrivals and preserves selections/viewers changed while pending', async () => {
|
||||
const old = row('old'), other = row('other'), arrived = row('arrived'); select([old, other], [old]);
|
||||
const gate = deferred<object>(), started = deferred<void>(); reply = () => { started.resolve(); return gate.promise; };
|
||||
const pending = state().batchDelete(a, true); await started.promise;
|
||||
select([old, other, arrived], [other]); gate.resolve({ destroyed: ['old'] }); await pending;
|
||||
expect(state().emails).toEqual([other, arrived]); expect(state().selectedEmail).toBe(other);
|
||||
expect([...state().selectedEmailKeys]).toEqual([emailKeyFor(other)]);
|
||||
});
|
||||
|
||||
it('uses the approved selection and captured routing after asynchronous approval', async () => {
|
||||
const old = row('approved'), other = row('other', 'b'); select([old, other], [old]);
|
||||
const gate = deferred<boolean>(), started = deferred<void>();
|
||||
const before = vi.fn(() => { started.resolve(); return gate.promise; }); emailHooks.onBeforeEmailDelete.register('delay', before);
|
||||
const pending = state().batchDelete(a, true); await started.promise;
|
||||
select([old, other], [other]); gate.resolve(true); await pending;
|
||||
expect(before).toHaveBeenCalledWith({ emailIds: ['approved'], permanent: true });
|
||||
expect(requests).toEqual([{ login: 'a', accountId: 'c', destroy: ['approved'] }]);
|
||||
expect(state().emails).toEqual([other]); expect(state().selectedEmail).toBe(other);
|
||||
});
|
||||
|
||||
it('never restores failed selections deliberately cleared while pending', async () => {
|
||||
select([row('ok'), row('no')]); const gate = deferred<object>(), started = deferred<void>();
|
||||
reply = () => { started.resolve(); return gate.promise; };
|
||||
const pending = state().batchDelete(a, true); await started.promise; state().clearSelection();
|
||||
gate.resolve({ destroyed: ['ok'], notDestroyed: { no } }); await pending;
|
||||
expect(state().selectedEmailKeys.size).toBe(0); expect(state().emails.map(e => e.id)).toEqual(['no']);
|
||||
});
|
||||
|
||||
it('preserves the incomplete-operation error through owner refresh', async () => {
|
||||
select([row('ok'), row('no')]); reply = () => ({ destroyed: ['ok'], notDestroyed: { no } });
|
||||
const folders = vi.spyOn(a, 'getMailboxes').mockImplementation(async () => { useEmailStore.setState({ error: null }); return [folder()]; });
|
||||
expect(await state().batchDelete(a, true)).toBe(false);
|
||||
expect(folders).toHaveBeenCalledWith('c'); expect(state().error).toContain('Synthetic refusal');
|
||||
});
|
||||
|
||||
it('preserves full-success and plugin-veto behavior', async () => {
|
||||
const selected = row('ok'); select([selected]); const veto = emailHooks.onBeforeEmailDelete.register('veto', () => false);
|
||||
expect(await state().batchDelete(a, true)).toBe(false); expect(requests).toEqual([]);
|
||||
expect(state().emails).toEqual([selected]); expect(after).not.toHaveBeenCalled();
|
||||
veto.dispose(); expect(await state().batchDelete(a, true)).toBe(true);
|
||||
expect(state().emails).toEqual([]); expect(state().error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permanent-delete source snapshots and refreshes', () => {
|
||||
it('keeps a shared owner and viewing client pinned across navigation during plugin approval', async () => {
|
||||
const selected = { ...row('same'), sourceClientAccountId: undefined, sourceAccountId: undefined };
|
||||
select([selected]);
|
||||
useEmailStore.setState({ isUnifiedView: false, viewingAccountId: 'b', selectedMailbox: 'd:inbox',
|
||||
accountMailboxes: { b: [{ ...folder('d:inbox', 'd'), originalId: 'inbox', isShared: true }] } });
|
||||
const gate = deferred<boolean>(), started = deferred<void>();
|
||||
emailHooks.onBeforeEmailDelete.register('delay', () => { started.resolve(); return gate.promise; });
|
||||
const pending = state().batchDelete(a, true); await started.promise;
|
||||
state().selectAccountMailbox('a', 'inbox'); const fresh = row('same', 'a'); select([fresh]);
|
||||
gate.resolve(true); expect(await pending).toBe(true);
|
||||
expect(requests).toEqual([{ login: 'b', accountId: 'd', destroy: ['same'] }]);
|
||||
expect(state().emails).toEqual([fresh]); expect(state().selectedEmail).toBe(fresh);
|
||||
expect([...state().selectedEmailKeys]).toEqual([emailKeyFor(fresh)]);
|
||||
});
|
||||
|
||||
it('does not reuse a client disconnected during plugin approval', async () => {
|
||||
const selected = row('same', 'b'); select([selected]); const gate = deferred<boolean>(), started = deferred<void>();
|
||||
emailHooks.onBeforeEmailDelete.register('delay', () => { started.resolve(); return gate.promise; });
|
||||
const pending = state().batchDelete(a, true); await started.promise;
|
||||
useAuthStore.setState({ getClientForAccount: () => undefined } as never); gate.resolve(true);
|
||||
expect(await pending).toBe(false); expect(requests).toEqual([]); expect(state().emails).toEqual([selected]);
|
||||
});
|
||||
|
||||
it('refreshes only the confirmed delegated owner and keeps other authenticated caches intact', async () => {
|
||||
const selected = { ...row('ok', 'a', 'd'), mailboxIds: { 'd:inbox': true } }; select([selected]);
|
||||
const unrelated = [folder('unrelated', 'd')];
|
||||
useEmailStore.setState({ mailboxes: [folder(), { ...folder('d:inbox', 'd'), originalId: 'inbox', isShared: true }],
|
||||
accountMailboxes: { 'b|d': unrelated } });
|
||||
const fetchFolders = vi.spyOn(a, 'getMailboxes').mockResolvedValue([{ ...folder('inbox', 'd'), isShared: true, parentId: 'root' }]);
|
||||
expect(await state().batchDelete(a, true)).toBe(true);
|
||||
expect(fetchFolders).toHaveBeenCalledWith('d');
|
||||
expect(state().mailboxes.map(f => f.id)).toEqual(['inbox', 'd:inbox']);
|
||||
expect(state().accountMailboxes['a|d'][0]).toMatchObject({ id: 'd:inbox', originalId: 'inbox', parentId: 'd:root' });
|
||||
expect(state().accountMailboxes['b|d']).toBe(unrelated);
|
||||
});
|
||||
|
||||
it('refills a fully successful page without resurrecting confirmed IDs from a stale refresh', async () => {
|
||||
const selected = row('ok'), other = row('other'); select([selected]); useEmailStore.setState({ hasMoreEmails: true });
|
||||
refresh.mockImplementation(async () => { useEmailStore.setState({ emails: [selected, other], totalEmails: 2 }); });
|
||||
expect(await state().batchDelete(a, true)).toBe(true); expect(refresh).toHaveBeenCalledTimes(1);
|
||||
expect(state().emails).toEqual([other]); expect(state().totalEmails).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('new search and category navigation during deletion', () => {
|
||||
it.each(['search', 'filter', 'category'])('does not refill or end loading for a new %s view', async kind => {
|
||||
select([row('old')]); const gate = deferred<object>(), started = deferred<void>();
|
||||
reply = () => { started.resolve(); return gate.promise; };
|
||||
const pending = state().batchDelete(a, true); await started.promise;
|
||||
if (kind === 'search') state().setSearchQuery('other');
|
||||
if (kind === 'filter') state().setSearchFilters({ from: 'other@example.invalid' });
|
||||
if (kind === 'category') useMessageListTabsStore.setState({ activeTabId: 'other' });
|
||||
const fresh = row('fresh'); select([fresh]); useEmailStore.setState({ hasMoreEmails: true, isLoading: true });
|
||||
gate.resolve({ destroyed: ['old'] }); await pending;
|
||||
expect(refresh).not.toHaveBeenCalled(); expect(state().isLoading).toBe(true);
|
||||
expect(state().emails).toEqual([fresh]); expect(state().selectedEmail).toBe(fresh);
|
||||
expect([...state().selectedEmailKeys]).toEqual([emailKeyFor(fresh)]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('late permanent-delete failures', () => {
|
||||
function visibleState() {
|
||||
const s = state();
|
||||
return { emails: s.emails, selectedEmailKeys: s.selectedEmailKeys, selectedEmail: s.selectedEmail,
|
||||
selectedMailbox: s.selectedMailbox, isLoading: s.isLoading, error: s.error };
|
||||
}
|
||||
function leaveOperation(kind: string) {
|
||||
if (kind === 'reset') clearAllStores();
|
||||
else {
|
||||
state().selectMailbox('new-folder');
|
||||
const fresh = row('fresh', 'b'); select([fresh]);
|
||||
useEmailStore.setState({ isLoading: true, error: 'New view failed to load more mail' });
|
||||
}
|
||||
return visibleState();
|
||||
}
|
||||
|
||||
it.each(['navigation', 'reset'])('keeps %s state unchanged after the old delete rejects', async kind => {
|
||||
select([row('old')]); const gate = deferred<object>(), started = deferred<void>();
|
||||
reply = async () => { started.resolve(); await gate.promise; throw new Error('Old delete failed'); };
|
||||
const pending = state().batchDelete(a, true); await started.promise;
|
||||
const expected = leaveOperation(kind);
|
||||
gate.resolve({}); expect(await pending).toBe(false);
|
||||
expect(visibleState()).toEqual(expected);
|
||||
expect(state().emails).toBe(expected.emails); expect(state().selectedEmailKeys).toBe(expected.selectedEmailKeys);
|
||||
expect(state().selectedEmail).toBe(expected.selectedEmail); expect(after).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['navigation', 'reset'])('keeps %s state unchanged after an old owner refresh rejects', async kind => {
|
||||
select([row('confirmed')]); const gate = deferred<void>(), started = deferred<void>();
|
||||
const folders = vi.spyOn(a, 'getMailboxes').mockImplementation(async () => {
|
||||
started.resolve(); await gate.promise; throw new Error('Old owner refresh failed');
|
||||
});
|
||||
const pending = state().batchDelete(a, true); await started.promise;
|
||||
// Server progress is already reconciled before the owner refresh finishes.
|
||||
expect(state().emails).toEqual([]); expect(state().totalEmails).toBe(9);
|
||||
expect(after).toHaveBeenCalledWith({ emailIds: ['confirmed'], permanent: true });
|
||||
const expected = leaveOperation(kind);
|
||||
gate.resolve(); expect(await pending).toBe(false);
|
||||
expect(folders).toHaveBeenCalledWith('c'); expect(visibleState()).toEqual(expected);
|
||||
expect(state().emails).toBe(expected.emails); expect(state().selectedEmailKeys).toBe(expected.selectedEmailKeys);
|
||||
expect(state().selectedEmail).toBe(expected.selectedEmail);
|
||||
});
|
||||
|
||||
it('still sets the incomplete-delete error and returns false in the captured view', async () => {
|
||||
const refused = row('no'); select([row('ok'), refused]);
|
||||
const gate = deferred<object>(), started = deferred<void>();
|
||||
reply = () => { started.resolve(); return gate.promise; };
|
||||
const pending = state().batchDelete(a, true); await started.promise;
|
||||
gate.resolve({ destroyed: ['ok'], notDestroyed: { no } });
|
||||
expect(await pending).toBe(false); expect(state().error).toContain('Synthetic refusal');
|
||||
expect(state().isLoading).toBe(false); expect(state().emails).toEqual([refused]);
|
||||
expect([...state().selectedEmailKeys]).toEqual([emailKeyFor(refused)]); expect(state().selectedEmail).toBe(refused);
|
||||
expect(after).toHaveBeenCalledWith({ emailIds: ['ok'], permanent: true });
|
||||
});
|
||||
});
|
||||
+159
-18
@@ -1,3 +1,4 @@
|
||||
import { IncompleteDeleteError } from "@/lib/jmap/delete-error";
|
||||
import { ArchiveEmailsError } from "@/lib/jmap/archive-error";
|
||||
import { RescheduleRestoredError } from "@/lib/jmap/reschedule-error";
|
||||
import { create } from "zustand";
|
||||
@@ -2000,6 +2001,158 @@ function sourceMailboxes(
|
||||
return (loginMailboxes ?? []).filter((mailbox) => mailbox.accountId === jmapAccountId);
|
||||
}
|
||||
|
||||
/** Permanent deletion has its own confirmation contract; Trash keeps its move path. */
|
||||
async function permanentlyDeleteSelection(passedClient: IJMAPClient): Promise<boolean> {
|
||||
const get = useEmailStore.getState;
|
||||
const set = useEmailStore.setState;
|
||||
const initial = get();
|
||||
const epoch = currentStoreEpoch();
|
||||
const isSameList = captureEmailListView();
|
||||
const tabId = useMessageListTabsStore.getState().activeTabId;
|
||||
const isCurrentView = () => currentStoreEpoch() === epoch && isSameList()
|
||||
&& get().viewToken === initial.viewToken && get().searchQuery === initial.searchQuery
|
||||
&& get().searchFilters === initial.searchFilters && get().searchMailboxId === initial.searchMailboxId
|
||||
&& useMessageListTabsStore.getState().activeTabId === tabId;
|
||||
const { rows: selection, unknown } = resolveSelectionRows();
|
||||
const rows = selection.map(email => ({ ...email, mailboxIds: { ...email.mailboxIds }, keywords: { ...email.keywords } }));
|
||||
const failures = notFoundFailures(unknown);
|
||||
const viewingLogin = initial.viewingAccountId ?? useAuthStore.getState().activeAccountId;
|
||||
const viewClient = initial.viewingAccountId
|
||||
? useAuthStore.getState().getClientForAccount(initial.viewingAccountId) : passedClient;
|
||||
const viewMailbox = resolveActionMailboxes().find(m => m.id === initial.selectedMailbox);
|
||||
const viewOwner = viewMailbox?.isShared ? viewMailbox.accountId : undefined;
|
||||
const clients = new Map<string, IJMAPClient | undefined>();
|
||||
const clientFor = (login: string) => {
|
||||
if (!clients.has(login)) clients.set(login, useAuthStore.getState().getClientForAccount(login));
|
||||
return clients.get(login);
|
||||
};
|
||||
type Group = { client?: IJMAPClient; login?: string | null; accountId?: string; owner?: string;
|
||||
messages: Email[]; confirmed: Set<string>; stamped: boolean };
|
||||
const groups: Group[] = [];
|
||||
// Pin targets and reaching clients before plugin approval can yield to navigation.
|
||||
for (const email of rows) {
|
||||
const stamped = !!email.sourceAccountId;
|
||||
const login = stamped ? email.sourceClientAccountId : viewingLogin;
|
||||
const client = stamped ? (login ? clientFor(login) : undefined) : viewClient;
|
||||
const accountId = stamped ? email.sourceAccountId : viewOwner;
|
||||
const owner = accountId ?? client?.getAccountId?.();
|
||||
const group = groups.find(g => g.client === client && g.owner === owner && (client !== undefined || g.login === login));
|
||||
if (group) {
|
||||
if (!group.messages.some(e => e.id === email.id)) group.messages.push(email);
|
||||
group.stamped ||= stamped;
|
||||
} else groups.push({ client, login, accountId, owner, messages: [email], confirmed: new Set(), stamped });
|
||||
}
|
||||
const viewerLogin = initial.selectedEmail?.sourceClientAccountId;
|
||||
if (viewerLogin) clientFor(viewerLogin);
|
||||
if (rows.length > 0 && emailHooks.onBeforeEmailDelete.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailDelete.intercept({ emailIds: [...new Set(rows.map(e => e.id))], permanent: true }))) return false;
|
||||
if (isCurrentView()) set({ isLoading: true, error: null });
|
||||
await Promise.all(groups.map(async group => {
|
||||
try {
|
||||
if (!group.client || ((group.stamped || initial.viewingAccountId) && group.login
|
||||
&& useAuthStore.getState().getClientForAccount(group.login) !== group.client)) throw new Error('Not connected');
|
||||
await group.client.batchDeleteEmails(group.messages.map(e => e.id), group.accountId);
|
||||
group.messages.forEach(e => group.confirmed.add(e.id));
|
||||
} catch (error) {
|
||||
if (error instanceof IncompleteDeleteError) {
|
||||
group.messages.forEach(e => { if (error.destroyedIds.includes(e.id)) group.confirmed.add(e.id); });
|
||||
}
|
||||
failures.push({ ids: group.messages.filter(e => !group.confirmed.has(e.id)).map(e => e.id), error });
|
||||
}
|
||||
}));
|
||||
const confirmed = groups.flatMap(g => g.messages.filter(e => g.confirmed.has(e.id)));
|
||||
const wasDeleted = (email: Email) => {
|
||||
const stamped = !!email.sourceAccountId;
|
||||
const client = stamped ? (email.sourceClientAccountId ? clientFor(email.sourceClientAccountId) : undefined) : viewClient;
|
||||
const owner = stamped ? email.sourceAccountId : viewOwner ?? viewClient?.getAccountId?.();
|
||||
return groups.some(g => g.client === client && g.owner === owner && g.confirmed.has(email.id));
|
||||
};
|
||||
const reconcileList = (updateCounts: boolean) => {
|
||||
if (!isCurrentView() || confirmed.length === 0) return;
|
||||
set(state => {
|
||||
const remaining = state.emails.filter(e => !wasDeleted(e));
|
||||
const selectedEmailKeys = new Set(state.selectedEmailKeys);
|
||||
const removed = new Map<string, Email>();
|
||||
const remember = (e: Email) => { removed.set(emailKeyFor(e), e); selectedEmailKeys.delete(emailKeyFor(e)); };
|
||||
confirmed.forEach(e => selectedEmailKeys.delete(emailKeyFor(e)));
|
||||
state.emails.filter(wasDeleted).forEach(remember);
|
||||
const threadEmailsCache = new Map(state.threadEmailsCache);
|
||||
const expandedThreadIds = new Set(state.expandedThreadIds);
|
||||
for (const [key, members] of threadEmailsCache) {
|
||||
const kept = members.filter(e => !wasDeleted(e));
|
||||
if (kept.length === members.length) continue;
|
||||
members.filter(wasDeleted).forEach(remember);
|
||||
if (kept.length) threadEmailsCache.set(key, kept);
|
||||
else { threadEmailsCache.delete(key); expandedThreadIds.delete(key); }
|
||||
}
|
||||
const threadEmailCounts = new Map(state.threadEmailCounts);
|
||||
if (updateCounts) {
|
||||
const byThread = new Map<string, number>();
|
||||
for (const email of removed.values()) byThread.set(threadKeyFor(email), (byThread.get(threadKeyFor(email)) ?? 0) + 1);
|
||||
for (const [key, count] of byThread) {
|
||||
if (threadEmailCounts.has(key)) threadEmailCounts.set(key, Math.max(0, threadEmailCounts.get(key)! - count));
|
||||
if (!remaining.some(e => threadKeyFor(e) === key) && !threadEmailsCache.has(key)) {
|
||||
threadEmailCounts.delete(key); expandedThreadIds.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
emails: remaining, selectedEmailKeys,
|
||||
selectedEmail: state.selectedEmail && wasDeleted(state.selectedEmail) ? null : state.selectedEmail,
|
||||
totalEmails: Math.max(remaining.length, state.totalEmails - (state.emails.length - remaining.length)),
|
||||
threadEmailsCache, expandedThreadIds, threadEmailCounts,
|
||||
};
|
||||
});
|
||||
};
|
||||
if (currentStoreEpoch() === epoch && confirmed.length > 0) {
|
||||
// Attribute undecorated rows to their captured login even if another login is now active.
|
||||
const counterRows = groups.flatMap(g => g.messages.filter(e => g.confirmed.has(e.id))
|
||||
.map(e => ({ ...e, sourceClientAccountId: g.login ?? undefined })));
|
||||
set(state => applyBatchMailboxCounterUpdate(state, counterRows, applyDeleteCounters));
|
||||
}
|
||||
reconcileList(true);
|
||||
if (confirmed.length > 0) void emailHooks.onAfterEmailDelete.emit({ emailIds: [...new Set(confirmed.map(e => e.id))], permanent: true });
|
||||
|
||||
// Fetch authoritative owner counters without switching the current mailbox or
|
||||
// using a bare owner cache that can belong to a different authenticated client.
|
||||
await Promise.all(groups.filter(g => g.confirmed.size > 0).map(async group => {
|
||||
try {
|
||||
if (!group.client?.getMailboxes) return;
|
||||
const fresh = await group.client.getMailboxes(group.accountId);
|
||||
if (currentStoreEpoch() !== epoch || fresh.length === 0) return;
|
||||
if (group.login && useAuthStore.getState().getClientForAccount(group.login) !== group.client) return;
|
||||
const owner = group.owner ?? fresh[0]?.accountId;
|
||||
const primary = group.client.getAccountId?.() ? owner === group.client.getAccountId() : !fresh[0]?.isShared;
|
||||
const normalized = fresh.filter(m => !owner || m.accountId === owner || (primary && !m.accountId && !m.isShared))
|
||||
.map(m => m.isShared ? {
|
||||
...m, id: `${m.accountId}:${m.originalId || m.id}`, originalId: m.originalId || m.id,
|
||||
parentId: m.parentId && !m.parentId.startsWith(`${m.accountId}:`) ? `${m.accountId}:${m.parentId}` : m.parentId,
|
||||
} : m);
|
||||
const merge = (folders: Mailbox[]) => [...folders.filter(m => primary ? m.isShared : m.accountId !== owner), ...normalized];
|
||||
set(state => {
|
||||
const accountMailboxes = { ...state.accountMailboxes };
|
||||
if (group.login) {
|
||||
accountMailboxes[group.login] = merge(state.accountMailboxes[group.login] ?? []);
|
||||
if (owner) accountMailboxes[sourceMailboxesKey(group.login, owner)] = normalized;
|
||||
}
|
||||
return { accountMailboxes, mailboxes: useAuthStore.getState().client === group.client ? merge(state.mailboxes) : state.mailboxes };
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push({ ids: [], error });
|
||||
}
|
||||
}));
|
||||
// Only refill a fully deleted page. A partial page must keep its unresolved
|
||||
// rows for retry; neither a refresh nor reconciliation restores cleared selections.
|
||||
if (confirmed.length > 0 && failures.length === 0 && isCurrentView() && get().hasMoreEmails) {
|
||||
try { await get().refreshCurrentMailbox(viewClient ?? passedClient); }
|
||||
catch (error) { failures.push({ ids: [], error }); }
|
||||
reconcileList(false);
|
||||
}
|
||||
const error = failures.length > 0 ? batchFailureMessage(failures, 'delete') : null;
|
||||
if (isCurrentView()) set({ isLoading: false, error });
|
||||
return failures.length === 0;
|
||||
}
|
||||
|
||||
/** sourceMailboxes of a SourceBatch, or the viewed account's for undecorated messages. */
|
||||
function sourceBatchMailboxes(batch: SourceBatch): Mailbox[] {
|
||||
if (!batch.accountId) return resolveActionMailboxes();
|
||||
@@ -5369,13 +5522,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const { selectedEmailKeys, emails, selectedMailbox } = get();
|
||||
const mailboxes = resolveActionMailboxes();
|
||||
if (selectedEmailKeys.size === 0) return true;
|
||||
if (permanent || bulkDeleteIsPermanent(mailboxes.find(m => m.id === selectedMailbox))) {
|
||||
return permanentlyDeleteSelection(client);
|
||||
}
|
||||
|
||||
if (emailHooks.onBeforeEmailDelete.size > 0) {
|
||||
const toDelete = resolveSelectedEmails(get());
|
||||
if (toDelete.length > 0) {
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
const destroys = permanent || bulkDeleteIsPermanent(currentMailbox);
|
||||
if (!(await emailHooks.onBeforeEmailDelete.intercept({ emailIds: [...new Set(toDelete.map(e => e.id))], permanent: destroys }))) return false;
|
||||
if (!(await emailHooks.onBeforeEmailDelete.intercept({ emailIds: [...new Set(toDelete.map(e => e.id))], permanent: false }))) return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5397,12 +5551,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
return true;
|
||||
}
|
||||
|
||||
// Determine if the current folder forces permanent deletion.
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
const isInTrash = currentMailbox?.role === 'trash';
|
||||
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
|
||||
const isInJunk = currentMailbox?.role === 'junk';
|
||||
const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk);
|
||||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
|
||||
|
||||
// Group emails by login and owning JMAP account (handles unified view and
|
||||
@@ -5425,15 +5574,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
// Ids the server confirmed, for onAfterEmailDelete.
|
||||
const deletedIds = new Set<string>();
|
||||
if (forceDestroy) {
|
||||
const promises = batches.map(async (batch) => {
|
||||
const acctClient = sourceBatchClient(batch, client);
|
||||
if (!acctClient) return;
|
||||
await acctClient.batchDeleteEmails(batch.ids, jmapIdFor(batch));
|
||||
batch.ids.forEach(id => deletedIds.add(id));
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
} else {
|
||||
{
|
||||
// Move to trash per account.
|
||||
let failedAccounts = 0;
|
||||
// Keys, not ids: a namesake in an account that failed must stay.
|
||||
@@ -5498,7 +5639,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
isLoading: false,
|
||||
...(notFound.length > 0 ? { error: NOT_IN_LIST } : {}),
|
||||
});
|
||||
if (deletedIds.size > 0) void emailHooks.onAfterEmailDelete.emit({ emailIds: [...deletedIds], permanent: forceDestroy });
|
||||
if (deletedIds.size > 0) void emailHooks.onAfterEmailDelete.emit({ emailIds: [...deletedIds], permanent: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to delete emails",
|
||||
|
||||
Reference in New Issue
Block a user