Merge pull request #1384 from VasilisNtovolos/fix/bulk-delete-partial-results

fix: reconcile confirmed permanent bulk deletions
This commit is contained in:
Linus Rath
2026-10-27 23:14:11 +01:00
committed by GitHub
10 changed files with 803 additions and 37 deletions
@@ -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);
});
});
+1 -1
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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'));
}
}
}
+32
View File
@@ -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
View File
@@ -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",