diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index 37c42021d..99156b232 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -781,3 +781,31 @@ describe('ThreadListItem date (#891)', () => { expect(container.textContent).not.toMatch(/2024/); }); }); + +describe('ThreadListItem hover actions', () => { + // When the row above is deleted from its hover actions, the next row slides + // under the resting pointer. The browser then reports a pointerover whose + // relatedTarget is the list around the row, and React builds no + // onPointerEnter from that, so the row has to arm its actions on pointerover. + const renderInList = (thread: ReturnType[number]) => + render( +
+ {}} onEmailSelect={() => {}} onDelete={() => {}} /> +
, + ); + + it.each([ + ['a single message', () => groupEmailsByThread([makeEmail()])[0]], + ['a conversation', () => groupEmailsByThread([ + makeEmail({ id: 'a', receivedAt: '2024-01-15T10:00:00Z' }), + makeEmail({ id: 'b', receivedAt: '2024-01-15T11:00:00Z' }), + ])[0]], + ])('mounts them for %s that slides under a resting pointer', (_name, makeThread) => { + const { container } = renderInList(makeThread()); + expect(screen.queryByTestId('hover-action-delete')).toBeNull(); + const row = screen.queryByTestId('email-list-item') ?? container.querySelector('[data-unread]'); + expect(row).not.toBeNull(); + fireEvent.pointerOver(row!, { relatedTarget: screen.getByTestId('list') }); + expect(screen.getByTestId('hover-action-delete')).toBeTruthy(); + }); +}); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 2ccafb6b6..eb04344a8 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -59,8 +59,11 @@ function isMousePressOnRow(e: React.PointerEvent): boolean { * Whether a row has been pointed at yet. The hover actions stay display:none * until the pointer is over the row, so mounting them for every row up front * only added a handful of icon buttons per row to every list render. They - * mount on the first pointer-enter and stay from then on, so the hover - * behaviour itself is unchanged. + * mount on the first pointer-over and stay from then on, so the hover + * behaviour itself is unchanged. Pointer-over rather than pointer-enter: when + * a row slides under a resting pointer (the row above was just deleted from + * its hover actions), the browser's pointerover comes from the list around + * it, and React builds no onPointerEnter from that. */ /** * Role of the folder the list shows (sender vs recipient, junk actions). In @@ -77,7 +80,7 @@ export function listMailboxRole(state: { ?? (state.isUnifiedView ? (state.unifiedRole ?? undefined) : undefined); } -function useArmOnPointerEnter(): [boolean, () => void] { +function useArmOnPointerOver(): [boolean, () => void] { const [armed, setArmed] = React.useState(false); const arm = React.useCallback(() => setArmed(true), []); return [armed, arm]; @@ -429,7 +432,7 @@ const SingleEmailItem = React.forwardRef( sourceMailboxId: selectedMailbox, }); - const [hoverActionsArmed, armHoverActions] = useArmOnPointerEnter(); + const [hoverActionsArmed, armHoverActions] = useArmOnPointerOver(); const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress( useCallback((pos) => { onContextMenu?.( @@ -526,7 +529,7 @@ const SingleEmailItem = React.forwardRef( {...dragHandlers} {...touchHandlers} data-testid="email-list-item" - onPointerEnter={armHoverActions} + onPointerOver={armHoverActions} onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(); }} data-email-id={email.id} data-subject={email.subject || ''} @@ -980,7 +983,7 @@ export const ThreadListItem = React.forwardRef { onContextMenu?.( @@ -1126,7 +1129,7 @@ export const ThreadListItem = React.forwardRef { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }} aria-current={isSelected ? 'true' : undefined} data-unread={hasUnread ? 'true' : 'false'} diff --git a/components/mail/__tests__/blob-routing.test.tsx b/components/mail/__tests__/blob-routing.test.tsx index 668edfa81..b0777c3db 100644 --- a/components/mail/__tests__/blob-routing.test.tsx +++ b/components/mail/__tests__/blob-routing.test.tsx @@ -75,6 +75,8 @@ vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: ({ o const { MailApp } = await import('../mail-app'); const { ConversationEmailBody } = await import('@/components/email/thread-conversation-view'); const { EmailViewer } = await import('@/components/email/email-viewer'); +const { ProEmailTabBody } = await import('@/components/pro/pro-email-tab-body'); +const { ProFolderTabBody } = await import('@/components/pro/pro-folder-tab-body'); const sharedMailbox = { id: 'nrichmond:inbox', originalId: 'inbox', name: 'Shared', isShared: true, accountId: 'nrichmond' } as Mailbox; const primaryMailbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; @@ -602,3 +604,132 @@ describe('conversation blob routing', () => { expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owner-a'); }); }); + + +// Pro hosts must carry the same message-scoped context as MailApp, including +// when the global mail selection is absent or belongs to another message. +type ProBlobAction = 'download' | 'preview click' | 'CID' | 'export'; +const proBlobActions: ProBlobAction[] = ['download', 'preview click', 'CID', 'export']; +const proRoutes = [ + { label: 'direct shared folder', secondary: false, owner: 'nrichmond', selection: 'null', unified: false }, + { label: 'secondary shared folder', secondary: true, owner: 'nrichmond', selection: 'unrelated', unified: false }, + { label: 'primary folder', secondary: false, owner: 'postal12345', selection: 'null', unified: false }, + { label: 'secondary primary folder', secondary: true, owner: 'secondary-primary', selection: 'unrelated', unified: false }, + { label: 'Unified tab, null selection', secondary: true, owner: 'nrichmond', selection: 'null', unified: true }, + { label: 'Unified tab, unrelated selection', secondary: true, owner: 'nrichmond', selection: 'unrelated', unified: true }, + { label: 'explicit secondary email tab', secondary: true, owner: 'nrichmond', selection: 'null', unified: false, emailTab: true }, +]; + +async function openProMessage(route: typeof proRoutes[number], action: ProBlobAction) { + const { a, b, fetchMock } = await conversationClients(); + const origin = route.secondary ? b : a; + const full = message({ + subject: 'Pro message', + attachments: [{ ...cardAttachment, name: 'photo.png', ...(action === 'CID' ? { cid: 'test-image' } : {}) }], + ...(action === 'CID' ? { + textBody: [], htmlBody: [{ partId: '1', blobId: 'html-blob', type: 'text/html', size: 30 }], + bodyValues: { '1': { value: '', isTruncated: false, isEncodingProblem: false } }, + } : {}), + }); + const stub = { ...full, sourceClientAccountId: 'login-other', sourceAccountId: route.owner }; + vi.spyOn(a, 'getEmail').mockResolvedValue(full); + vi.spyOn(b, 'getEmail').mockResolvedValue(full); + useSettingsStore.setState({ emailDownloadTemplate: '{subject}', mailAttachmentAction: action === 'preview click' ? 'preview' : 'download' }); + useEmailStore.setState({ + emails: route.unified ? [stub] : [], + selectedEmail: route.selection === 'null' ? null : message({ id: 'unrelated', sourceClientAccountId: 'login-primary', sourceAccountId: 'unrelated-owner' }), + isUnifiedView: route.unified, selectedMailbox: route.unified ? '__unified_inbox__' : sharedMailbox.id, + accountMailboxes: { 'login-primary': [primaryMailbox, sharedMailbox], 'login-other': [primaryMailbox, sharedMailbox] }, + }); + if (route.unified || route.emailTab) { + render(); + } else { + const mailbox = route.owner === 'nrichmond' ? sharedMailbox : primaryMailbox; + vi.spyOn(origin, 'getEmails').mockResolvedValue({ emails: [full], total: 1, hasMore: false }); + render(); + fireEvent.click(await screen.findByText('Pro message')); + } + if (action === 'CID') await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + else await screen.findAllByText('photo.png'); + return { a, b, origin, fetchMock, full }; +} + +async function actOnProBlob(action: ProBlobAction) { + if (action === 'download') await downloadAttachment(); + if (action === 'preview click') { + fireEvent.click((await screen.findAllByText('photo.png'))[0]); + // Preserve Pro's existing action: Preview clicks download, with no modal. + expect(screen.queryByText('Preview fetch')).not.toBeInTheDocument(); + } + if (action === 'export') await exportEmail(); +} +function expectProBlob(route: typeof proRoutes[number], action: ProBlobAction, index = 0) { + const host = route.secondary ? 'secondary' : 'primary'; + const blobPath = action === 'export' ? 'raw-blob/Pro%20message.eml?accept=message%2Frfc822' : 'same-blob/photo.png?accept=image%2Fpng'; + expectBlobRequest(`https://${host}.example.com/download/${route.owner}/${blobPath}`, `${host}-login`, index); +} + +describe('Pro blob routing', () => { + it.each(proRoutes.flatMap(route => proBlobActions.map(action => ({ ...route, action }))))( + '$label: $action uses the message owner and authenticated client', async route => { + const { origin, fetchMock } = await openProMessage(route, route.action); + await actOnProBlob(route.action); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expectProBlob(route, route.action); + if (route.owner === 'nrichmond') expect(origin.getEmail).toHaveBeenCalledWith('email', route.owner); + }, + ); + + it.each([proRoutes[0], proRoutes[1], proRoutes[4]].flatMap(route => proBlobActions.map(action => ({ ...route, action }))))( + '$label: $action retains its source after account and selection changes', async route => { + const { a, b, fetchMock } = await openProMessage(route, route.action); + if (route.action === 'CID') await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + // Leave the loaded reader mounted while a different Mail pane navigates. + act(() => { + useEmailStore.setState({ emails: [], selectedEmail: message({ id: 'other', sourceClientAccountId: 'login-primary', sourceAccountId: 'unrelated-owner' }), selectedMailbox: 'inbox', isUnifiedView: false }); + useAuthStore.setState({ client: route.secondary ? b : a, activeAccountId: route.secondary ? 'login-other' : 'login-primary' }); + }); + // Switch through null, as reconnecting account switches can do. + act(() => useAuthStore.setState({ client: null })); + act(() => useAuthStore.setState({ client: route.secondary ? a : b, activeAccountId: route.secondary ? 'login-primary' : 'login-other' })); + await act(async () => {}); + await actOnProBlob(route.action); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + for (let index = 0; index < fetchMock.mock.calls.length; index++) expectProBlob(route, route.action, index); + }, + ); + + it('retains an in-flight Unified load across account and selection changes', async () => { + const { a, b, fetchMock } = await conversationClients(); + let finish!: (email: Email) => void; + vi.spyOn(b, 'getEmail').mockImplementation(() => new Promise(resolve => { finish = resolve; })); + const wrongClient = vi.spyOn(a, 'getEmail'); + useEmailStore.setState({ emails: [message({ sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond' })], selectedEmail: null, isUnifiedView: true }); + render(); + await waitFor(() => expect(b.getEmail).toHaveBeenCalledWith('email', 'nrichmond')); + act(() => { + useEmailStore.setState({ emails: [], selectedEmail: message({ id: 'unrelated' }), isUnifiedView: false, selectedMailbox: 'inbox' }); + useAuthStore.setState({ client: b, activeAccountId: 'login-other' }); + }); + await act(async () => finish(message({ attachments: [{ ...cardAttachment, name: 'photo.png' }] }))); + await downloadAttachment(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expectProBlob(proRoutes[4], 'download'); + expect(wrongClient).not.toHaveBeenCalled(); + expect(b.getEmail).toHaveBeenCalledTimes(1); + }); + + it('changes CID routing when a reused reader opens identical ids on another client/owner', async () => { + const { a, b, fetchMock } = await conversationClients(); + const full = inlineMessage({ attachments: [{ ...cardAttachment, name: 'photo.png', cid: 'test-image' }] }); + vi.spyOn(a, 'getEmail').mockResolvedValue(full); + vi.spyOn(b, 'getEmail').mockResolvedValue({ ...full }); + const reader = (login: string, owner: string) => ; + const { rerender } = render(reader('login-primary', 'owner-a')); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expectBlobRequest('https://primary.example.com/download/owner-a/same-blob/photo.png?accept=image%2Fpng', 'primary-login'); + rerender(reader('login-other', 'owner-b')); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expectBlobRequest('https://secondary.example.com/download/owner-b/same-blob/photo.png?accept=image%2Fpng', 'secondary-login', 1); + }); +}); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index b51b900ac..88dd3d838 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { EmailViewer } from "@/components/email/email-viewer"; import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error"; import { useAuthStore } from "@/stores/auth-store"; -import { useEmailStore } from "@/stores/email-store"; +import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext } from "@/stores/email-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { findDraftIdentityId } from "@/lib/reply-identity"; @@ -100,6 +100,17 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa const openComposeTabNow = useProTabStore((s) => s.openComposeTab); const [email, setEmail] = useState(null); + const [blobSource, setBlobSource] = useState({ client: null }); + // A reader owns its load, including an in-flight one across account switches. + // Only a different message or explicit tab context starts a new load. + const loadRef = useRef<{ + emailId: string; + clientOverride: typeof clientOverride; + accountId: typeof accountId; + blobSource: EmailBlobContext; + promise: Promise; + applied: boolean; + } | null>(null); const [isLoading, setIsLoading] = useState(true); const composerSessionIdRef = useRef(0); @@ -137,11 +148,34 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa useEffect(() => { let cancelled = false; if (!client) return; + let load = loadRef.current; + if (!load || load.emailId !== emailId || load.clientOverride !== clientOverride || load.accountId !== accountId) { + const state = useEmailStore.getState(); + const ref = state.selectedEmail?.id === emailId + ? state.selectedEmail + : state.emails.find((e) => e.id === emailId); + // Folder/search panes supply their own context. A primary folder must + // not inherit an unrelated globally selected shared folder's owner. + const source = accountId || clientOverride + ? { client, accountId: accountId ?? client.getAccountId() } + : resolveEmailBlobContext(ref ?? null, client); + load = { + emailId, clientOverride, accountId, blobSource: source, applied: false, + promise: accountId || clientOverride + ? client.getEmail(emailId, source.accountId) + : fetchEmailContent(client, emailId), + }; + loadRef.current = load; + } + const pending = load; + if (pending.applied) return; setIsLoading(true); - (accountId ? client.getEmail(emailId, accountId) : fetchEmailContent(client, emailId)) + pending.promise .then((loaded) => { if (cancelled) return; + pending.applied = true; setEmail(loaded); + setBlobSource(pending.blobSource); if (loaded) onLoadedRef.current?.(loaded); }) .catch((err) => { @@ -153,7 +187,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa return () => { cancelled = true; }; - }, [client, emailId, accountId, fetchEmailContent]); + }, [client, clientOverride, emailId, accountId, fetchEmailContent]); const currentMailboxRole = useMemo(() => { if (!email) return undefined; @@ -326,14 +360,15 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa } }, [client, email, moveToMailbox, onClose]); - const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string) => { - if (!client) return; + const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string, _forceDownload?: boolean, source?: EmailBlobContext) => { + const context = source ?? blobSource; + if (!context.client) return; try { - await client.downloadBlob(blobId, name, type); + await context.client.downloadBlob(blobId, name, type, context.accountId); } catch (err) { console.error('Download failed:', err); } - }, [client]); + }, [blobSource]); const handleQuickReply = useCallback(async (body: string) => { handleReply(body); @@ -394,6 +429,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa ({ 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 diff --git a/lib/__tests__/push-preferences.test.ts b/lib/__tests__/push-preferences.test.ts index c1063835f..47a08b510 100644 --- a/lib/__tests__/push-preferences.test.ts +++ b/lib/__tests__/push-preferences.test.ts @@ -31,6 +31,20 @@ describe('writePushPreferences', () => { await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined(); expect(put).not.toHaveBeenCalled(); }); + + it('does nothing where reading the Cache store throws', async () => { + // A plugin sandbox frame has an opaque origin: reading `caches` there, + // typeof included, throws a SecurityError. + Object.defineProperty(globalThis, 'caches', { + configurable: true, + get() { + throw new DOMException('Cache storage is disabled because the context is sandboxed', 'SecurityError'); + }, + }); + const { writePushPreferences } = await import('../push-preferences'); + await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined(); + expect(put).not.toHaveBeenCalled(); + }); }); describe('settings store push preference mirror', () => { diff --git a/lib/push-preferences.ts b/lib/push-preferences.ts index fe8aa3c7e..a148e34a9 100644 --- a/lib/push-preferences.ts +++ b/lib/push-preferences.ts @@ -21,8 +21,11 @@ export function pushPreferencesKey(origin: string): string { } export async function writePushPreferences(prefs: PushPreferences): Promise { - if (typeof window === 'undefined' || typeof caches === 'undefined') return; + if (typeof window === 'undefined') return; try { + // A frame sandboxed without allow-same-origin (the plugin sandbox loads + // the settings store too) throws on any read of `caches`, typeof included. + if (typeof caches === 'undefined') return; const cache = await caches.open(PUSH_STATE_CACHE); await cache.put( pushPreferencesKey(window.location.origin),