diff --git a/components/email/__tests__/permanent-delete-feedback.test.tsx b/components/email/__tests__/permanent-delete-feedback.test.tsx new file mode 100644 index 000000000..384f2eb2c --- /dev/null +++ b/components/email/__tests__/permanent-delete-feedback.test.tsx @@ -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; +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' ? {}} /> + : ); + 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((_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(); + } + }); +}); diff --git a/lib/__tests__/jmap-bulk-delete-results.test.ts b/lib/__tests__/jmap-bulk-delete-results.test.ts new file mode 100644 index 000000000..d049be8c0 --- /dev/null +++ b/lib/__tests__/jmap-bulk-delete-results.test.ts @@ -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, 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 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); + }); +}); diff --git a/lib/__tests__/jmap-request-limits.test.ts b/lib/__tests__/jmap-request-limits.test.ts index d9e6843d5..e84c81eb2 100644 --- a/lib/__tests__/jmap-request-limits.test.ts +++ b/lib/__tests__/jmap-request-limits.test.ts @@ -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); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 7bb3c8f56..551f32010 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -343,6 +343,7 @@ export interface IJMAPClient { migrateKeyword(oldKeyword: string, newKeyword: string): Promise; deleteEmail(emailId: string, accountId?: string): Promise; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise; + /** Resolves only when every ID is destroyed; IncompleteDeleteError carries partial confirmations. */ batchDeleteEmails(emailIds: string[], accountId?: string): Promise; batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise; batchArchiveEmails( diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index ea24786fa..73d016625 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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 { 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 = {}; + 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')); + } } } diff --git a/lib/jmap/delete-error.ts b/lib/jmap/delete-error.ts new file mode 100644 index 000000000..f2efdb17e --- /dev/null +++ b/lib/jmap/delete-error.ts @@ -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; + readonly unknownIds: string[]; + readonly unattemptedIds: string[]; + jmapType?: string; + jmapProperties?: string[]; + jmapSetError?: boolean; + jmapFailedIds?: string[]; + + constructor(progress: { + destroyedIds: string[]; + notDestroyed: Record; + 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; + } +} diff --git a/stores/__tests__/email-selection-cross-account.test.ts b/stores/__tests__/email-selection-cross-account.test.ts index cedb7a64f..fa5c49d19 100644 --- a/stores/__tests__/email-selection-cross-account.test.ts +++ b/stores/__tests__/email-selection-cross-account.test.ts @@ -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(); }); }); diff --git a/stores/__tests__/email-store-bulk-delete-routing.test.ts b/stores/__tests__/email-store-bulk-delete-routing.test.ts index d4dd1ac50..66fa2cafa 100644 --- a/stores/__tests__/email-store-bulk-delete-routing.test.ts +++ b/stores/__tests__/email-store-bulk-delete-routing.test.ts @@ -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 => { diff --git a/stores/__tests__/email-store-permanent-delete.test.ts b/stores/__tests__/email-store-permanent-delete.test.ts new file mode 100644 index 000000000..5a73f9cff --- /dev/null +++ b/stores/__tests__/email-store-permanent-delete.test.ts @@ -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() { + let resolve!: (v: T) => void; + const promise = new Promise(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; +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(), started = deferred(); + 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(), started = deferred(); + 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(), started = deferred(); 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(), started = deferred(); + 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(), started = deferred(); + 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(), started = deferred(); + 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(), started = deferred(); + 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(), started = deferred(); + 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(), started = deferred(); + 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(), started = deferred(); + 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(), started = deferred(); + 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 }); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 0204e2058..32d7e2503 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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 { + 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(); + 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; 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(); + 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(); + 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((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((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((set, get) => ({ // Ids the server confirmed, for onAfterEmailDelete. const deletedIds = new Set(); - 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((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",