From 345fe64515efd128e638c5261338d904bf01eca9 Mon Sep 17 00:00:00 2001 From: VasilisNtovolos Date: Tue, 20 Oct 2026 15:45:47 +0300 Subject: [PATCH] fix: route dragged emails by owning account --- components/email/thread-list-item.tsx | 10 +- hooks/__tests__/use-email-drag-routing.md | 125 ++++ .../__tests__/use-email-drag-routing.test.tsx | 569 ++++++++++++++++++ hooks/use-email-drag.ts | 153 +++-- 4 files changed, 791 insertions(+), 66 deletions(-) create mode 100644 hooks/__tests__/use-email-drag-routing.md create mode 100644 hooks/__tests__/use-email-drag-routing.test.tsx diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 2ccafb6b6..4e8404229 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -526,7 +526,10 @@ const SingleEmailItem = React.forwardRef( {...dragHandlers} {...touchHandlers} data-testid="email-list-item" - onPointerEnter={armHoverActions} + onPointerEnter={() => { + armHoverActions(); + dragHandlers.onPointerEnter?.(); + }} onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(); }} data-email-id={email.id} data-subject={email.subject || ''} @@ -1126,7 +1129,10 @@ export const ThreadListItem = React.forwardRef { + armHoverActions(); + dragHandlers.onPointerEnter?.(); + }} onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }} aria-current={isSelected ? 'true' : undefined} data-unread={hasUnread ? 'true' : 'false'} diff --git a/hooks/__tests__/use-email-drag-routing.md b/hooks/__tests__/use-email-drag-routing.md new file mode 100644 index 000000000..32b78c3fd --- /dev/null +++ b/hooks/__tests__/use-email-drag-routing.md @@ -0,0 +1,125 @@ +# Message drag blob routing + +- Worktree: `/home/billy/work/bulwark-deploy/bulwark-drag-out` +- Branch: `fix/message-drag-blob-routing` +- Audited base and unchanged HEAD: `640d4b8c2e44bcb61b099a5511e06dbd5d7ca537`. + +Read repository guidance in `CONTRIBUTING.md`; no applicable `AGENTS.md` was +present. The worktree was clean before adding these changes. Before editing the +hook, verified that single-message prefetch and ZIP retrieval used the active +client without an owner account, and that cache keys omitted routing identity. + +The hook now reuses `resolveEmailBlobContext` for each originating message. +Selections use the existing account-aware selection resolver, including loaded +expanded-thread members. Cache identity includes the authenticated client object, +login, owning JMAP account, message identity, blob ID and filename. Drag start +checks that identity again; obsolete asynchronous completions cannot replace the +current entry and their URLs receive the existing 60-second cleanup grace period. + +`use-email-drag-routing.test.tsx` uses synthetic JMAP sessions and RFC 822 bytes +with the real JMAP connection, URL expansion, authentication and blob-fetch code. +Both logins use the same synthetic server to exercise authentication collisions. +The tests inspect single-message bytes and ZIP entry contents/counts. No real +messages or mail server are used. The existing `use-email-drag.test.tsx` assertions +are retained unchanged. + +Run the maintained drag regressions with: + +```sh +./node_modules/.bin/vitest run hooks/__tests__/use-email-drag.test.tsx hooks/__tests__/use-email-drag-routing.test.tsx --maxWorkers=1 --no-file-parallelism --reporter=verbose +``` + +Dependencies were installed from the existing lockfile using `npm ci --cache +/tmp/bulwark-drag-out-npm-cache --no-audit --no-fund --ignore-scripts`. Dependencies, +Vitest caches and Next build outputs belong to this worktree; no sibling +worktree or cache was modified. + +## Row hover and lifecycle correction + +Single-message rows and thread headers now call both the existing hover-action +handler and the hook's hover-prefetch handler. The maintained tests render the +real `ThreadListItem` and its real hover actions and drag hook, using synthetic +network responses with real JMAP URL/authentication code. + +A single-message fetch that is still pending when dragend fires can finish into +the cache for a later drag. Completion checks both the current cache entry and +its freshly resolved client/owner/message identity. Navigation to another owner +or login, message/blob changes, and unmount cannot publish obsolete bytes. +Already-ready single-message URLs retain the existing dragend retirement and +60-second revocation grace period. ZIP caches still survive dragend. + +## Remaining cold-cache limitation + +`DownloadURL` is written only synchronously during dragstart when the matching +cache is ready. A cold first drag can still show the email preview and support +internal mailbox dragging while providing no desktop file. Fetch completion +never changes that drag's DataTransfer. If the pending request finishes after +dragend in the same context, the next drag can use it synchronously. If it +finishes before dragend, dragend retires that ready cache as before, so another +hover-prefetch must warm the row before a later attempt. No asynchronous +DataTransfer updates or native desktop delivery guarantees were added. + +## Remaining failure-handling gap + +ZIP retrieval still silently skips failed entries, messages without blob IDs and +messages whose source login is unavailable. An archive can contain fewer entries +than the selection count in its filename; if every eligible fetch fails it can +be empty. Failed/partial bundle builds remain cached until the selection/routing +key changes. The regression suite explicitly retains the existing partial-failure +behavior. No failure UI or unrelated export behavior was redesigned. + +DataTransfer, object URLs and browser user agents are mocked under jsdom. These +checks verify payload construction, routing, archive bytes and URL retirement; +native browser/desktop drag-to-filesystem behavior has not been verified. + +## Initial routing validation + +- Exact final regression tests with only the hook replaced by the audited base: + **20 failed, 4 passed**. Direct shared-folder requests used the primary owner; + secondary-login requests used primary credentials; mixed ZIP contents were + incorrect. The fixed hook was restored byte-for-byte after the replay. +- With the fix: **24/24 drag tests passed**, including the retained original test. + The broader focused drag/blob/account-selection/thread run passed **138/138** + tests across seven files. +- `npm run typecheck`: passed. +- `npm run lint`: passed; 16 warnings in unchanged files. Targeted lint on the + changed hook/test passed without warnings. +- `npm run build`: passed; 16 filesystem-tracing warnings in unchanged files. +- `git diff --check`, plus whitespace checks on both new files: passed. +- `vitest run --maxWorkers=2`: **8,377 passed, 2 skipped, 1 failed**, across 783 + test files. The sole failure is `lib/__tests__/idn.test.ts:39`, where + `toAsciiDomain('xn--zz.de')` returns the input instead of `null`. It also fails + in isolation. Both the test and `lib/idn.ts` are byte-identical to the audited + base; neither imports the drag hook. This unrelated behavior was left unchanged. + +Session logs are in `/tmp/bulwark-drag-out-base-final-tests.log`, +`/tmp/bulwark-drag-out-focused.log`, `/tmp/bulwark-drag-out-unit-tests.log`, +`/tmp/bulwark-drag-out-final-drag-and-idn.log`, and the corresponding +`bulwark-drag-out-{typecheck,lint,build}.log` files. No integration suite, real +mailbox operation or native desktop drag verification was run. Nothing was +staged, committed, pushed or published. + +## Row/lifecycle validation + +Before correcting the current uncommitted routing patch, the extended routing +suite had **5 failures and 28 passes**. The failures were: + +- Single-message row pointer entry did not start blob prefetch. +- Thread-header pointer entry did not start ZIP prefetch. +- Pointer entry on a selected real row did not prefetch the mixed-owner ZIP. +- A pending cold drag could not supply `DownloadURL` on a retry after dragend and + fetch completion, both through direct hook handlers and through the real row. + +After correction, the drag suites passed **34/34** tests, including all retained +original assertions. The focused row/drag/routing/selection/thread/context/JMAP +run passed **262/262** tests across **17 files**. `npm run typecheck` passed; +`npm run lint` passed with 16 warnings in unchanged files. Whitespace checks +passed. The prior full-suite and build results above describe the initial +routing validation; those broader checks were not repeated for this correction. + +The before/after evidence is in `/tmp/bulwark-drag-out-lifecycle-before.log` and +`/tmp/bulwark-drag-out-lifecycle-after.log`; the focused, typecheck and lint logs +use the same prefix. The existing proxy, Next configuration, environment files, +package manifest and lockfile were preserved byte-for-byte. Dependencies were +unchanged. Native Chrome-to-Windows desktop file creation remains unverified +until manually tested. Nothing was staged, committed or pushed. diff --git a/hooks/__tests__/use-email-drag-routing.test.tsx b/hooks/__tests__/use-email-drag-routing.test.tsx new file mode 100644 index 000000000..eb4780834 --- /dev/null +++ b/hooks/__tests__/use-email-drag-routing.test.tsx @@ -0,0 +1,569 @@ +import { act, cleanup, fireEvent, render, renderHook, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DragEvent } from 'react'; +import JSZip from 'jszip'; +import { JMAPClient } from '@/lib/jmap/client'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { emailKeyFor, groupEmailsByThread } from '@/lib/thread-utils'; +import { ThreadListItem } from '@/components/email/thread-list-item'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useUIStore } from '@/stores/ui-store'; +import { useEmailDrag } from '../use-email-drag'; + +const { startDrag, endDrag } = vi.hoisted(() => ({ startDrag: vi.fn(), endDrag: vi.fn() })); +vi.mock('@/contexts/drag-drop-context', () => ({ + useDragDropContext: () => ({ startDrag, endDrag, isDragging: false, draggedEmails: [] }), +})); + +// Synthetic sessions and message bytes only. Do not mock the JMAP client: URL +// expansion, owner scoping, Basic/Bearer authentication and blob methods are real. +// DataTransfer is mocked; this does not verify native browser/desktop dragging. +const MAIL = 'urn:ietf:params:jmap:mail'; +const server = 'https://drag.example.test'; +const authA = `Basic ${btoa('a@example.test:synthetic-password')}`; +const authB = 'Bearer synthetic-secondary-token'; +const initialAuth = useAuthStore.getState(); +const initialEmail = useEmailStore.getState(); +const initialSettings = useSettingsStore.getState(); +const initialUI = useUIStore.getState(); +let clients: Map; +let liveClients: JMAPClient[]; +let objects: Map; +let requests: { owner: string; blob: string; auth: string }[]; +let fetchMock: ReturnType>; +let revoke: ReturnType void>>; + +function mail(overrides: Partial = {}): Email { + return { + id: 'same-id', blobId: 'same-blob', threadId: 'same-thread', + mailboxIds: { inbox: true }, keywords: {}, subject: 'Message', + receivedAt: '2026-10-01T00:00:00Z', size: 1, hasAttachment: false, + ...overrides, + } as Email; +} + +function shared(owner: string): Mailbox { + return { id: `${owner}:inbox`, originalId: 'inbox', name: 'Shared', isShared: true, accountId: owner } as Mailbox; +} + +function browse(owner: string, login?: string) { + const mailbox = shared(owner); + act(() => useEmailStore.setState({ + viewingAccountId: login ?? null, selectedMailbox: mailbox.id, + mailboxes: [mailbox], accountMailboxes: login ? { [login]: [mailbox] } : {}, + })); +} + +function bytes(owner: string, auth: string, blob = 'same-blob') { + return `From: synthetic@example.test\r\nSubject: ${owner}\r\n\r\n${auth}\r\n${blob}\r\n`; +} + +function blobResponse(content: string): Response { + const response = new Response(content, { headers: { 'Content-Type': 'message/rfc822' } }); + // jsdom's FileReader and JSZip need a jsdom Blob, rather than Node's Blob. + response.blob = async () => new Blob([content], { type: 'message/rfc822' }); + return response; +} + +async function connect(login: string, primary: string, bearer = false): Promise { + const client = bearer + ? JMAPClient.withBearer(server, 'synthetic-secondary-token', 'b@example.test') + : new JMAPClient(server, 'a@example.test', 'synthetic-password'); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + capabilities: { 'urn:ietf:params:jmap:core': {}, [MAIL]: {} }, + accounts: { [primary]: { name: primary, isPersonal: true, accountCapabilities: { [MAIL]: {} } } }, + primaryAccounts: { [MAIL]: primary }, apiUrl: `${server}/api`, + downloadUrl: `${server}/download/{accountId}/{blobId}/{name}?type={type}`, + uploadUrl: `${server}/upload/{accountId}`, eventSourceUrl: `${server}/events`, + }), { headers: { 'Content-Type': 'application/json' } })); + await client.connect(); + clients.set(login, client); + liveClients.push(client); + return client; +} + +function drag(handlers: ReturnType['dragHandlers']) { + const payload = new Map(); + const dataTransfer = { + setData: (type: string, value: string) => payload.set(type, value), + setDragImage: vi.fn(), effectAllowed: '', + }; + act(() => handlers.onDragStart({ dataTransfer } as unknown as DragEvent)); + return payload; +} + +function payloadBlob(payload: Map) { + const value = payload.get('DownloadURL'); + expect(value).toBeDefined(); + const url = value!.slice(value!.indexOf('blob:')); + expect(objects.has(url)).toBe(true); + return objects.get(url)!; +} + +function readBlob(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); +} + +async function text(blob: Blob) { + return new TextDecoder().decode(await readBlob(blob)); +} + +async function warm(result: { current: ReturnType }) { + act(() => result.current.dragHandlers.onPointerEnter?.()); + await waitFor(() => expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(true), { container: document.createElement('div') }); + return drag(result.current.dragHandlers); +} + +function select(emails: Email[]) { + useEmailStore.setState({ emails, selectedEmailKeys: new Set(emails.map(emailKeyFor)), isUnifiedView: true }); +} + +beforeEach(async () => { + clients = new Map(); + liveClients = []; + objects = new Map(); + requests = []; + startDrag.mockClear(); + endDrag.mockClear(); + revoke = vi.fn<(url: string) => void>(); + vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + const url = `blob:synthetic-${objects.size}`; + objects.set(url, blob as Blob); + return url; + }); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(revoke); + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Chrome/130.0'); + fetchMock = vi.fn(async (input, init) => { + const url = new URL(String(input)); + const [, route, owner, blob] = url.pathname.split('/').map(decodeURIComponent); + // No real network fallback, including for a bad route. + if (url.origin !== server || route !== 'download') throw new Error(`Unexpected synthetic request: ${url}`); + const auth = new Headers(init?.headers).get('Authorization') ?? ''; + requests.push({ owner, blob, auth }); + return blobResponse(bytes(owner, auth, blob)); + }); + vi.stubGlobal('fetch', fetchMock); + const primary = await connect('login-a', 'primary-a'); + await connect('login-b', 'primary-b', true); + useAuthStore.setState({ + client: primary, activeAccountId: 'login-a', + getClientForAccount: (id) => clients.get(id), + }); + useEmailStore.setState({ + ...initialEmail, emails: [], selectedEmailKeys: new Set(), threadEmailsCache: new Map(), + selectedMailbox: 'inbox', viewingAccountId: null, mailboxes: [], accountMailboxes: {}, + searchQuery: '', isUnifiedView: false, + }); + useUIStore.setState({ isMobile: false }); + useSettingsStore.setState({ emailDownloadTemplate: '{subject}', bundleDownloadTemplate: 'emails-{count}' }); +}); + +afterEach(() => { + cleanup(); + for (const client of liveClients) client.disconnect(); + useAuthStore.setState(initialAuth); + useEmailStore.setState(initialEmail); + useSettingsStore.setState(initialSettings); + useUIStore.setState(initialUI); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('message drag blob routing', () => { + it('keeps primary-account routing, filenames and drag payloads compatible', async () => { + const email = mail({ subject: 'Hello world' }); + const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); + const payload = await warm(result); + expect(requests).toEqual([{ owner: 'primary-a', blob: 'same-blob', auth: authA }]); + expect(await text(payloadBlob(payload))).toBe(bytes('primary-a', authA)); + expect(payload.get('DownloadURL')).toMatch(/^message\/rfc822:Hello world.eml:blob:/); + expect(payload.get('application/x-email-ids')).toBe('["same-id"]'); + expect(payload.get('text/plain')).toBe('Hello world'); + expect(payload.get('text/uri-list')).toContain('/mail/message/same-id?view=fullscreen'); + expect(startDrag).toHaveBeenLastCalledWith([email], 'inbox'); + }); + + it.each([undefined, 'login-b'])('routes a direct shared-folder single message through %s', async (login) => { + browse('shared owner', login); + const { result } = renderHook(() => useEmailDrag({ email: mail(), sourceMailboxId: 'shared owner:inbox' })); + const payload = await warm(result); + const auth = login ? authB : authA; + expect(requests).toEqual([{ owner: 'shared owner', blob: 'same-blob', auth }]); + expect(await text(payloadBlob(payload))).toBe(bytes('shared owner', auth)); + }); + + it('honors a shared message source through a secondary login even outside Unified', async () => { + const email = mail({ sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b', accountId: 'display-only' }); + const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); + expect(await text(payloadBlob(await warm(result)))).toBe(bytes('shared-b', authB)); + expect(requests).toEqual([{ owner: 'shared-b', blob: 'same-blob', auth: authB }]); + }); + + it('never substitutes the active client for a disconnected source login', async () => { + clients.delete('login-b'); + const email = mail({ sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b' }); + const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); + act(() => result.current.dragHandlers.onPointerEnter?.()); + await act(async () => { await Promise.resolve(); }); + expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(false); + expect(requests).toEqual([]); + }); + + it('routes every ZIP entry independently despite colliding email/blob IDs and filenames', async () => { + const emails = [ + mail({ sourceClientAccountId: 'login-a', sourceAccountId: 'primary-a' }), + mail({ sourceClientAccountId: 'login-a', sourceAccountId: 'shared-a' }), + mail({ sourceClientAccountId: 'login-b', sourceAccountId: 'primary-b' }), + mail({ sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b' }), + ]; + select(emails); + const { result } = renderHook(() => useEmailDrag({ email: emails[0], sourceMailboxId: '__unified_inbox__' })); + const payload = await warm(result); + const zip = await JSZip.loadAsync(await readBlob(payloadBlob(payload))); + expect(Object.keys(zip.files)).toHaveLength(4); + expect(await Promise.all(Object.values(zip.files).map((entry) => entry.async('string')))) + .toEqual([bytes('primary-a', authA), bytes('shared-a', authA), bytes('primary-b', authB), bytes('shared-b', authB)]); + expect(requests).toEqual([ + { owner: 'primary-a', blob: 'same-blob', auth: authA }, + { owner: 'shared-a', blob: 'same-blob', auth: authA }, + { owner: 'primary-b', blob: 'same-blob', auth: authB }, + { owner: 'shared-b', blob: 'same-blob', auth: authB }, + ]); + expect(payload.get('DownloadURL')).toMatch(/^application\/zip:emails-4.zip:blob:/); + expect(payload.get('application/x-email-ids')).toBe(JSON.stringify(emails.map((email) => email.id))); + expect(startDrag).toHaveBeenLastCalledWith(emails, '__unified_inbox__'); + }); + + it('selects by account-aware identity without including an unselected namesake', async () => { + const own = mail({ sourceClientAccountId: 'login-a', sourceAccountId: 'primary-a' }); + const other = mail({ sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b' }); + useEmailStore.setState({ emails: [own, other], selectedEmailKeys: new Set([emailKeyFor(other)]) }); + const { result } = renderHook(() => useEmailDrag({ email: other, sourceMailboxId: 'inbox' })); + expect(await text(payloadBlob(await warm(result)))).toBe(bytes('shared-b', authB)); + expect(startDrag).toHaveBeenLastCalledWith([other], 'inbox'); + expect(requests).toHaveLength(1); + }); + + it('includes selected expanded-thread members outside the visible list', async () => { + const row = mail({ id: 'row', sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b' }); + const member = mail({ id: 'expanded', blobId: 'expanded-blob', sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b' }); + useEmailStore.setState({ emails: [row], threadEmailsCache: new Map([['thread', [row, member]]]), selectedEmailKeys: new Set([emailKeyFor(row), emailKeyFor(member)]) }); + const { result } = renderHook(() => useEmailDrag({ email: row, sourceMailboxId: 'inbox' })); + const zip = await JSZip.loadAsync(await readBlob(payloadBlob(await warm(result)))); + expect(Object.keys(zip.files)).toHaveLength(2); + expect(await Promise.all(Object.values(zip.files).map((entry) => entry.async('string')))) + .toEqual([bytes('shared-b', authB), bytes('shared-b', authB, 'expanded-blob')]); + expect(startDrag).toHaveBeenLastCalledWith([row, member], 'inbox'); + }); + + it('keeps explicit thread-header members and routes their ZIP', async () => { + const threadEmails = [mail({ id: 'one', sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b' }), mail({ id: 'two', blobId: 'two-blob', sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b' })]; + const { result } = renderHook(() => useEmailDrag({ email: threadEmails[0], threadEmails, sourceMailboxId: 'inbox' })); + const zip = await JSZip.loadAsync(await readBlob(payloadBlob(await warm(result)))); + expect(Object.keys(zip.files)).toHaveLength(2); + expect(requests.every(({ owner, auth }) => owner === 'shared-b' && auth === authB)).toBe(true); + expect(startDrag).toHaveBeenLastCalledWith(threadEmails, 'inbox'); + }); + + it.each(['owner', 'client', 'blob', 'message', 'replacement client'] as const)('does not reuse a single prefetch after changing %s', async (change) => { + browse('owner-one'); + const { result, rerender } = renderHook(({ email }) => useEmailDrag({ email, sourceMailboxId: 'inbox' }), { initialProps: { email: mail() } }); + const old = await warm(result); + let email = mail(); + if (change === 'owner') browse('owner-two'); + if (change === 'client') act(() => useAuthStore.setState({ client: clients.get('login-b'), activeAccountId: 'login-b' })); + if (change === 'blob') email = mail({ blobId: 'new-blob' }); + if (change === 'message') email = mail({ id: 'new-id' }); + if (change === 'replacement client') { + const replacement = await connect('login-a', 'replacement-primary'); + act(() => useAuthStore.setState({ client: replacement })); + } + rerender({ email }); + expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(false); + const next = await warm(result); + expect(next.get('DownloadURL')).not.toBe(old.get('DownloadURL')); + expect(requests).toHaveLength(2); + expect(await text(payloadBlob(next))).toBe(bytes(change === 'owner' ? 'owner-two' : 'owner-one', change === 'client' ? authB : authA, email.blobId)); + }); + + it('shares a bundle between rows but rebuilds for a new owner with the same raw IDs', async () => { + const emails = [mail({ id: 'one' }), mail({ id: 'two' })]; + browse('owner-one'); + select(emails); + const first = renderHook(() => useEmailDrag({ email: emails[0], sourceMailboxId: 'inbox' })); + const second = renderHook(() => useEmailDrag({ email: emails[1], sourceMailboxId: 'inbox' })); + const old = await warm(first.result); + expect((await warm(second.result)).get('DownloadURL')).toBe(old.get('DownloadURL')); + expect(requests).toHaveLength(2); + browse('owner-two', 'login-b'); + expect(drag(first.result.current.dragHandlers).has('DownloadURL')).toBe(false); + const zip = await JSZip.loadAsync(await readBlob(payloadBlob(await warm(first.result)))); + expect(Object.keys(zip.files)).toHaveLength(2); + expect(await Promise.all(Object.values(zip.files).map((entry) => entry.async('string')))) + .toEqual([bytes('owner-two', authB), bytes('owner-two', authB)]); + expect(requests).toHaveLength(4); + }); + + it('discards a late single prefetch after a context switch and revokes it after the grace period', async () => { + let finish!: (response: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const { result, rerender } = renderHook(({ email }) => useEmailDrag({ email, sourceMailboxId: 'inbox' }), { initialProps: { email: mail({ sourceClientAccountId: 'login-a', sourceAccountId: 'primary-a' }) } }); + act(() => result.current.dragHandlers.onPointerEnter?.()); + rerender({ email: mail({ sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b' }) }); + const next = await warm(result); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + await act(async () => { finish(blobResponse('stale bytes')); }); + expect(objects.size).toBe(2); + expect(await text(payloadBlob(drag(result.current.dragHandlers)))).toBe(bytes('shared-b', authB)); + expect(drag(result.current.dragHandlers).get('DownloadURL')).toBe(next.get('DownloadURL')); + act(() => result.current.dragHandlers.onDragEnd({} as DragEvent)); + expect(revoke).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(60_000); + expect(revoke).toHaveBeenCalledWith(next.get('DownloadURL')!.split(':').slice(2).join(':')); + expect(revoke).toHaveBeenCalledWith([...objects.keys()][1]); + }); + + it('revokes a single prefetch that finishes after unmount', async () => { + let finish!: (response: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const { result, unmount } = renderHook(() => useEmailDrag({ email: mail(), sourceMailboxId: 'inbox' })); + act(() => result.current.dragHandlers.onPointerEnter?.()); + unmount(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + await act(async () => { finish(blobResponse('unmounted bytes')); }); + await vi.advanceTimersByTimeAsync(60_000); + expect(revoke).toHaveBeenCalledWith([...objects.keys()][0]); + }); + + it.each(['client', 'blob', 'replacement client'] as const)('rebuilds a ZIP after changing %s while raw message IDs stay the same', async (change) => { + const emails = [mail({ id: 'one' }), mail({ id: 'two' })]; + browse('same-owner'); + select(emails); + const { result } = renderHook(() => useEmailDrag({ email: emails[0], sourceMailboxId: 'inbox' })); + const old = await warm(result); + const owner = 'same-owner'; + let auth = authA; + if (change === 'client') { + act(() => useAuthStore.setState({ client: clients.get('login-b'), activeAccountId: 'login-b' })); + auth = authB; + } else if (change === 'replacement client') { + const replacement = await connect('login-a', 'replacement-primary'); + act(() => useAuthStore.setState({ client: replacement })); + } else { + act(() => select(emails.map((email) => ({ ...email, blobId: 'new-blob' })))); + } + expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(false); + const next = await warm(result); + expect(next.get('DownloadURL')).not.toBe(old.get('DownloadURL')); + const zip = await JSZip.loadAsync(await readBlob(payloadBlob(next))); + expect(Object.keys(zip.files)).toHaveLength(2); + expect(await Promise.all(Object.values(zip.files).map((entry) => entry.async('string')))) + .toEqual([bytes(owner, auth, change === 'blob' ? 'new-blob' : 'same-blob'), bytes(owner, auth, change === 'blob' ? 'new-blob' : 'same-blob')]); + expect(requests).toHaveLength(4); + }); + + it('retires a late ZIP build without replacing the current context bundle', async () => { + const emails = [mail({ id: 'one' }), mail({ id: 'two' })]; + select(emails); + let finish!: (response: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const { result } = renderHook(() => useEmailDrag({ email: emails[0], sourceMailboxId: 'inbox' })); + act(() => result.current.dragHandlers.onPointerEnter?.()); + await waitFor(() => expect(finish).toBeDefined()); + browse('next-owner', 'login-b'); + const next = await warm(result); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + await act(async () => { finish(blobResponse('obsolete ZIP entry')); }); + // JSZip uses setImmediate internally; wait for the obsolete build to finish. + await vi.waitFor(() => expect(objects.size).toBe(2)); + expect(drag(result.current.dragHandlers).get('DownloadURL')).toBe(next.get('DownloadURL')); + expect(revoke).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(60_000); + expect(revoke).toHaveBeenCalledWith([...objects.keys()][1]); + }); + + it('retains the existing partial-failure gap: failed/missing blobs are silently omitted', async () => { + const emails = [mail({ id: 'ok' }), mail({ id: 'failed' }), mail({ id: 'missing', blobId: undefined })]; + select(emails); + fetchMock.mockResolvedValueOnce(blobResponse('successful entry')).mockResolvedValueOnce(new Response(null, { status: 404 })); + const { result } = renderHook(() => useEmailDrag({ email: emails[0], sourceMailboxId: 'inbox' })); + const payload = await warm(result); + const zip = await JSZip.loadAsync(await readBlob(payloadBlob(payload))); + expect(Object.keys(zip.files)).toHaveLength(1); + expect(await Object.values(zip.files)[0].async('string')).toBe('successful entry'); + expect(payload.get('DownloadURL')).toMatch(/^application\/zip:emails-3.zip:blob:/); + }); + + it('keeps a pending cold-drag prefetch across dragend and publishes only on the synchronous retry', async () => { + let finish!: (response: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const email = mail({ sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b' }); + const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); + const initialFetches = fetchMock.mock.calls.length; + const coldPayload = drag(result.current.dragHandlers); + expect(coldPayload.has('DownloadURL')).toBe(false); + expect(startDrag).toHaveBeenLastCalledWith([email], 'inbox'); + act(() => result.current.dragHandlers.onDragEnd({} as DragEvent)); + expect(endDrag).toHaveBeenCalledTimes(1); + await act(async () => { finish(blobResponse(bytes('shared-b', authB))); }); + expect(objects.size).toBe(1); + // A completed request must never mutate an earlier drag's DataTransfer. + expect(coldPayload.has('DownloadURL')).toBe(false); + const retry = drag(result.current.dragHandlers); + expect(retry.get('DownloadURL')).toMatch(/^message\/rfc822:Message.eml:blob:/); + expect(fetchMock).toHaveBeenCalledTimes(initialFetches + 1); + expect(await text(payloadBlob(retry))).toBe(bytes('shared-b', authB)); + expect(revoke).not.toHaveBeenCalled(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + act(() => result.current.dragHandlers.onDragEnd({} as DragEvent)); + await vi.advanceTimersByTimeAsync(59_999); + expect(revoke).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(revoke).toHaveBeenCalledWith([...objects.keys()][0]); + }); + + it.each(['owner', 'login', 'message', 'blob'] as const)('rejects a pending cold-drag result after changing %s', async (change) => { + browse('owner-one'); + let finish!: (response: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const { result, rerender } = renderHook(({ email }) => useEmailDrag({ email, sourceMailboxId: 'inbox' }), { initialProps: { email: mail() } }); + const coldPayload = drag(result.current.dragHandlers); + act(() => result.current.dragHandlers.onDragEnd({} as DragEvent)); + if (change === 'owner') browse('owner-two'); + if (change === 'login') browse('owner-one', 'login-b'); + if (change === 'message') rerender({ email: mail({ id: 'next-message' }) }); + if (change === 'blob') rerender({ email: mail({ blobId: 'next-blob' }) }); + await act(async () => { finish(blobResponse('obsolete bytes')); }); + expect(coldPayload.has('DownloadURL')).toBe(false); + expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(false); + const expectedOwner = change === 'owner' ? 'owner-two' : 'owner-one'; + const expectedAuth = change === 'login' ? authB : authA; + const next = await warm(result); + expect(await text(payloadBlob(next))).toBe(bytes(expectedOwner, expectedAuth, change === 'blob' ? 'next-blob' : 'same-blob')); + expect(next.get('DownloadURL')).not.toContain([...objects.keys()][0]); + }); + + it('retires a pending cold-drag result when its row unmounts after dragend', async () => { + let finish!: (response: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const { result, unmount } = renderHook(() => useEmailDrag({ email: mail(), sourceMailboxId: 'inbox' })); + const payload = drag(result.current.dragHandlers); + act(() => result.current.dragHandlers.onDragEnd({} as DragEvent)); + unmount(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + await act(async () => { finish(blobResponse('retired bytes')); }); + expect(payload.has('DownloadURL')).toBe(false); + expect(objects.size).toBe(1); + await vi.advanceTimersByTimeAsync(60_000); + expect(revoke).toHaveBeenCalledWith([...objects.keys()][0]); + }); + + it('keeps drag-out disabled on mobile and unsupported browsers', () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Firefox/130.0'); + const { result, rerender } = renderHook(() => useEmailDrag({ email: mail(), sourceMailboxId: 'inbox' })); + expect(result.current.dragHandlers.onPointerEnter).toBeUndefined(); + expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(false); + act(() => useUIStore.setState({ isMobile: true })); + rerender(); + expect(result.current.dragHandlers.draggable).toBe(false); + expect(requests).toEqual([]); + }); +}); + +// Render the actual row component and real drag/JMAP hooks. Only DataTransfer +// and the network are synthetic; do not call the hook's pointer handler here. +describe('message row drag hover wiring', () => { + beforeEach(() => { + useSettingsStore.setState({ interfaceLayout: 'default', mailLayout: 'split', density: 'regular', hoverActions: ['delete'] }); + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); + }); + + function renderRows(emails: Email[]) { + const [thread] = groupEmailsByThread(emails); + const onDelete = vi.fn(); + const ui = render( {}} onEmailSelect={() => {}} onDelete={onDelete} />); + const row = emails.length === 1 + ? ui.getByTestId('email-list-item') + : ui.getByText('Message').closest('[draggable]') as HTMLElement; + expect(row).not.toBeNull(); + return { ...ui, row, onDelete }; + } + + function rowDrag(row: HTMLElement) { + const payload = new Map(); + const dataTransfer = { effectAllowed: '', setDragImage: vi.fn(), setData: vi.fn((type: string, value: string) => payload.set(type, value)) }; + fireEvent.dragStart(row, { dataTransfer }); + return { payload, dataTransfer }; + } + + it.each(['single message', 'thread header'] as const)('prefetches on real %s pointer entry while retaining working hover actions', async (kind) => { + const stamp = { sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b' }; + const emails = kind === 'single message' + ? [mail(stamp)] + : [mail({ ...stamp, id: 'one' }), mail({ ...stamp, id: 'two', blobId: 'two-blob' })]; + useEmailStore.setState({ emails }); + const { row, onDelete } = renderRows(emails); + expect(within(row).queryByTestId('hover-action-delete')).toBeNull(); + fireEvent.pointerEnter(row); + const button = within(row).getByTestId('hover-action-delete'); + expect(button).toBeInTheDocument(); + // Wait for bytes without starting a drag: this must be hover prefetch. + await waitFor(() => expect(objects.size).toBe(1)); + const { payload, dataTransfer } = rowDrag(row); + expect(payload.has('DownloadURL')).toBe(true); // synchronous dragstart + expect(dataTransfer.effectAllowed).toBe('copyMove'); + expect(startDrag).toHaveBeenLastCalledWith(expect.arrayContaining(emails), 'inbox'); + if (kind === 'single message') { + expect(await text(payloadBlob(payload))).toBe(bytes('shared-b', authB)); + } else { + const zip = await JSZip.loadAsync(await readBlob(payloadBlob(payload))); + expect(Object.keys(zip.files)).toHaveLength(2); + expect(await Promise.all(Object.values(zip.files).map((entry) => entry.async('string')))) + .toEqual([bytes('shared-b', authB), bytes('shared-b', authB, 'two-blob')]); + } + fireEvent.click(button); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it('prefetches the account-aware mixed-owner selection from a real single row', async () => { + const emails = [ + mail({ sourceClientAccountId: 'login-a', sourceAccountId: 'primary-a' }), + mail({ sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b' }), + ]; + select(emails); + const { row } = renderRows([emails[0]]); + fireEvent.pointerEnter(row); + await waitFor(() => expect(objects.size).toBe(1)); + const { payload } = rowDrag(row); + expect(payload.get('application/x-email-ids')).toBe(JSON.stringify(emails.map((email) => email.id))); + const zip = await JSZip.loadAsync(await readBlob(payloadBlob(payload))); + expect(Object.keys(zip.files)).toHaveLength(2); + expect(await Promise.all(Object.values(zip.files).map((entry) => entry.async('string')))) + .toEqual([bytes('primary-a', authA), bytes('shared-b', authB)]); + expect(startDrag).toHaveBeenLastCalledWith(emails, 'inbox'); + }); + + it('warms a real cold row across dragend without writing its first payload asynchronously', async () => { + let finish!: (response: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const email = mail({ sourceClientAccountId: 'login-b', sourceAccountId: 'shared-b' }); + const { row } = renderRows([email]); + const cold = rowDrag(row); + expect(cold.payload.has('DownloadURL')).toBe(false); + const writesAtStart = cold.dataTransfer.setData.mock.calls.length; + fireEvent.dragEnd(row); + await act(async () => { finish(blobResponse(bytes('shared-b', authB))); }); + expect(cold.dataTransfer.setData).toHaveBeenCalledTimes(writesAtStart); + const retry = rowDrag(row); + expect(retry.payload.get('DownloadURL')).toMatch(/^message\/rfc822:Message.eml:blob:/); + expect(await text(payloadBlob(retry.payload))).toBe(bytes('shared-b', authB)); + }); +}); diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index fdf51e672..11dad3025 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react"; import { Email } from "@/lib/jmap/types"; import { IJMAPClient } from "@/lib/jmap/client-interface"; -import { useEmailStore } from "@/stores/email-store"; +import { resolveEmailBlobContext, resolveSelectedEmails, useEmailStore } from "@/stores/email-store"; import { emailKeyFor } from "@/lib/thread-utils"; import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; @@ -63,10 +63,8 @@ function bundleFilename(count: number, options: EmailFilenameOptions): string { return bundleExportFilename(count, options); } -// Shared bundle cache. The .zip is keyed by the sorted list of email IDs in -// the selection, so two rows in the same selection reuse the same in-flight -// build. When the selection changes, the previous bundle URL is scheduled for -// revoke and a new build starts. +// Rows in the same selection share a bundle. Scope cached bytes to the actual +// authenticated client, owner and message, since raw email/blob IDs can collide. type BundleEntry = { key: string; name: string; @@ -76,26 +74,49 @@ type BundleEntry = { let currentBundle: BundleEntry | null = null; -function selectionKey(ids: string[]): string { - return [...ids].sort().join(","); +// Object identity also distinguishes a reconnected/replaced client for the same login. +const clientKeys = new WeakMap(); +let nextClientKey = 0; + +function routeEmail(email: Email, options: EmailFilenameOptions) { + const { client, accountId: owner, clientAccountId } = resolveEmailBlobContext(email, useAuthStore.getState().client); + const accountId = owner ?? client?.getAccountId(); + if (client && !clientKeys.has(client)) clientKeys.set(client, ++nextClientKey); + const name = emailExportFilename(email, options); + const key = JSON.stringify([ + client ? clientKeys.get(client) : null, clientAccountId, + accountId, emailKeyFor(email), email.blobId, name, + ]); + return { email, client, accountId, name, key }; } -async function buildEmailZip(client: IJMAPClient, emails: Email[], options: EmailFilenameOptions): Promise { - const eligible = emails.filter((em) => !!em.blobId); +type RoutedEmail = ReturnType; + +function selectionKey(emails: RoutedEmail[], bundleOptions: EmailFilenameOptions): string { + return JSON.stringify([emails.map((entry) => entry.key).sort(), bundleOptions]); +} + +function revokeLater(url: string): void { + // Chromium may read the URL asynchronously after the drop completes. + setTimeout(() => URL.revokeObjectURL(url), 60_000); +} + +async function buildEmailZip(emails: RoutedEmail[]): Promise { + const eligible = emails.filter(({ email, client }) => !!email.blobId && !!client); if (eligible.length === 0) return null; const { default: JSZip } = await import("jszip"); const zip = new JSZip(); const used = new Set(); await Promise.all( - eligible.map(async (em) => { - const base = emailExportFilename(em, options).replace(/\.eml$/, ""); + eligible.map(async ({ email: em, client, accountId, name: filename }) => { + const base = filename.replace(/\.eml$/, ""); // Full id, not a prefix: messages in one thread share their leading // characters (Stalwart packs the thread id into the high bits), and a // prefix collision here used to spin the main thread forever (#1039). const name = uniqueFilename(used, base, ".eml", em.id); used.add(name); try { - const blob = await client.fetchBlob(em.blobId!, name, "message/rfc822"); + const blob = await client!.fetchBlob(em.blobId!, name, "message/rfc822", accountId); zip.file(name, blob); } catch { // Skip individual failures; remaining messages still bundle. @@ -107,16 +128,14 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[], options: Emai } function prefetchEmailBundle( - client: IJMAPClient, - emails: Email[], - emailOptions: EmailFilenameOptions, + emails: RoutedEmail[], bundleOptions: EmailFilenameOptions, ): void { - const key = selectionKey(emails.map((e) => e.id)); + const key = selectionKey(emails, bundleOptions); if (currentBundle && currentBundle.key === key) return; if (currentBundle?.url) { const old = currentBundle.url; - setTimeout(() => URL.revokeObjectURL(old), 60_000); + revokeLater(old); } const entry: BundleEntry = { key, @@ -124,17 +143,20 @@ function prefetchEmailBundle( url: null, promise: null, }; - entry.promise = buildEmailZip(client, emails, emailOptions) + entry.promise = buildEmailZip(emails) .then((url) => { - if (url && currentBundle === entry) entry.url = url; + if (url) { + if (currentBundle === entry) entry.url = url; + else revokeLater(url); + } return url; }) .catch(() => null); currentBundle = entry; } -function getReadyBundle(emails: Email[]): { url: string; name: string } | null { - const key = selectionKey(emails.map((e) => e.id)); +function getReadyBundle(emails: RoutedEmail[], bundleOptions: EmailFilenameOptions): { url: string; name: string } | null { + const key = selectionKey(emails, bundleOptions); if (currentBundle && currentBundle.key === key && currentBundle.url) { return { url: currentBundle.url, name: currentBundle.name }; } @@ -165,63 +187,63 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD ); const dragOutEnabled = !isMobile && isDragOutSupported() && !!client; - const singleBlobUrlRef = useRef(null); - const inFlightRef = useRef | null>(null); + const singleRef = useRef(null); useEffect(() => { return () => { - if (singleBlobUrlRef.current) { - const url = singleBlobUrlRef.current; - singleBlobUrlRef.current = null; - // Defer revoke - Chromium asynchronously reads the blob: URL after the - // drop completes, so revoking immediately can race the OS. - setTimeout(() => URL.revokeObjectURL(url), 60_000); - } - inFlightRef.current = null; + if (singleRef.current?.url) revokeLater(singleRef.current.url); + // A late completion must not repopulate a retired row's cache. + singleRef.current = null; }; - }, [email.id]); + }, [email.id, email.blobId, email.sourceClientAccountId, email.sourceAccountId, client, filenameOptions]); - const prefetchSingle = useCallback(() => { - if (!dragOutEnabled || !client || !email.blobId) return; - if (singleBlobUrlRef.current || inFlightRef.current) return; - const name = emailExportFilename(email, filenameOptions); - inFlightRef.current = client - .fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822") + const prefetchSingle = useCallback((target: Email) => { + if (!dragOutEnabled || !target.blobId) return; + const route = routeEmail(target, filenameOptions); + if (!route.client) return; + const previous = singleRef.current; + if (previous?.key === route.key && (previous.url || previous.promise)) return; + if (previous?.url) revokeLater(previous.url); + const entry: BundleEntry = { key: route.key, name: route.name, url: null, promise: null }; + singleRef.current = entry; + entry.promise = route.client + .fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId) .then((url) => { - if (url && !singleBlobUrlRef.current) singleBlobUrlRef.current = url; + if (url) { + if (singleRef.current === entry && routeEmail(target, filenameOptions).key === entry.key) entry.url = url; + else revokeLater(url); + } return url; }) .catch(() => null) - .finally(() => { - inFlightRef.current = null; - }); - }, [dragOutEnabled, client, email, filenameOptions]); + .finally(() => { entry.promise = null; }); + }, [dragOutEnabled, filenameOptions]); const handlePointerEnter = useCallback(() => { - const { selectedEmailKeys, emails } = useEmailStore.getState(); - if (!dragOutEnabled || !client) return; - const isSelected = selectedEmailKeys.has(emailKeyFor(email)); - const isMulti = isSelected && selectedEmailKeys.size > 1; - if (isMulti) { - const selected = emails.filter((em) => selectedEmailKeys.has(emailKeyFor(em))); - // Only worth bundling when at least one selected email has a blobId. + const state = useEmailStore.getState(); + if (!dragOutEnabled) return; + const selected = state.selectedEmailKeys.has(emailKeyFor(email)) + ? resolveSelectedEmails(state) + : threadEmails || [email]; + if (selected.length > 1) { if (selected.some((em) => em.blobId)) { - prefetchEmailBundle(client, selected, filenameOptions, bundleOptions); + prefetchEmailBundle(selected.map((em) => routeEmail(em, filenameOptions)), bundleOptions); } - } else { - prefetchSingle(); + } else if (selected[0]) { + prefetchSingle(selected[0]); } - }, [dragOutEnabled, client, email, prefetchSingle, filenameOptions, bundleOptions]); + }, [dragOutEnabled, email, threadEmails, prefetchSingle, filenameOptions, bundleOptions]); const handleDragStart = useCallback((e: DragEvent) => { - const { selectedEmailKeys, emails } = useEmailStore.getState(); + const state = useEmailStore.getState(); + const { selectedEmailKeys } = state; // Determine which emails to drag: // - If current email is selected, drag all selected // - If threadEmails provided (thread header), drag all thread emails // - Otherwise, drag only this email const isSelected = selectedEmailKeys.has(emailKeyFor(email)); const emailsToDrag = isSelected - ? emails.filter(em => selectedEmailKeys.has(emailKeyFor(em))) + ? resolveSelectedEmails(state) : threadEmails || [email]; e.dataTransfer.effectAllowed = "copyMove"; @@ -249,7 +271,8 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD // Drag-out to file explorer. if (dragOutEnabled && client) { if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) { - const url = singleBlobUrlRef.current; + const route = routeEmail(emailsToDrag[0], filenameOptions); + const url = singleRef.current?.key === route.key ? singleRef.current.url : null; if (url) { const name = emailExportFilename(emailsToDrag[0], filenameOptions); // `DownloadURL` format: ::. Chromium expects @@ -264,10 +287,11 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD } else { // Not warmed up yet — kick off so the next attempt works. Don't // preventDefault: in-app drop still has to function. - prefetchSingle(); + prefetchSingle(emailsToDrag[0]); } } else if (emailsToDrag.length > 1) { - const ready = getReadyBundle(emailsToDrag); + const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions)); + const ready = getReadyBundle(routed, bundleOptions); if (ready) { e.dataTransfer.setData( "DownloadURL", @@ -275,7 +299,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD ); } else { // Kick off the bundle build for the next attempt. - prefetchEmailBundle(client, emailsToDrag, filenameOptions, bundleOptions); + prefetchEmailBundle(routed, bundleOptions); } } } @@ -297,11 +321,12 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD // Defer-revoke the per-row single .eml URL. The shared bundle URL stays // cached until the selection changes - revoking it here would break a // subsequent drag of the same selection. - if (singleBlobUrlRef.current) { - const url = singleBlobUrlRef.current; - singleBlobUrlRef.current = null; - setTimeout(() => URL.revokeObjectURL(url), 60_000); + if (singleRef.current?.url) { + revokeLater(singleRef.current.url); + singleRef.current = null; } + // A cold drag has no DownloadURL. Keep its pending fetch so a retry can + // use the completed bytes, provided the originating context is still valid. }, [endDrag]); // Check if this specific email is being dragged