From 2b01866399329433345a2212c4989f2c5022895e Mon Sep 17 00:00:00 2001 From: dealerweb Date: Tue, 20 Oct 2026 11:38:34 +0200 Subject: [PATCH 1/8] fix: show the hover actions on a row that moves under a resting pointer --- .../email/__tests__/thread-list-item.test.tsx | 28 +++++++++++++++++++ components/email/thread-list-item.tsx | 17 ++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) 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'} From 7a1aaf5f190244478a1d8a0926e5688073594839 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Tue, 20 Oct 2026 12:23:55 +0200 Subject: [PATCH 2/8] fix: stop the push preference mirror from throwing in sandboxed plugin frames --- lib/__tests__/push-preferences.test.ts | 14 ++++++++++++++ lib/push-preferences.ts | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) 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), From 41b70853fa05eeab28a56c8399de72ac772da1a6 Mon Sep 17 00:00:00 2001 From: VasilisNtovolos Date: Tue, 20 Oct 2026 14:33:42 +0300 Subject: [PATCH 3/8] fix: preserve message ownership in Pro blob downloads --- .../mail/__tests__/blob-routing.test.tsx | 131 ++++++++++++++++++ components/pro/pro-email-tab-body.tsx | 50 ++++++- 2 files changed, 174 insertions(+), 7 deletions(-) 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 Date: Tue, 20 Oct 2026 15:45:47 +0300 Subject: [PATCH 4/8] 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 From cfa16e1bc22ea6754c8b4be84af70a6ac1593a67 Mon Sep 17 00:00:00 2001 From: VasilisNtovolos Date: Tue, 20 Oct 2026 15:49:05 +0300 Subject: [PATCH 5/8] docs: clarify message drag routing verification --- hooks/__tests__/use-email-drag-routing.md | 155 ++++++---------------- 1 file changed, 43 insertions(+), 112 deletions(-) diff --git a/hooks/__tests__/use-email-drag-routing.md b/hooks/__tests__/use-email-drag-routing.md index 32b78c3fd..8b78ea796 100644 --- a/hooks/__tests__/use-email-drag-routing.md +++ b/hooks/__tests__/use-email-drag-routing.md @@ -1,125 +1,56 @@ -# Message drag blob routing +# Message drag regression tests -- Worktree: `/home/billy/work/bulwark-deploy/bulwark-drag-out` -- Branch: `fix/message-drag-blob-routing` -- Audited base and unchanged HEAD: `640d4b8c2e44bcb61b099a5511e06dbd5d7ca537`. +The maintained drag tests use synthetic JMAP sessions and message bytes with real +JMAP URL expansion, authentication and blob-fetch code. They verify: -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. +- Primary, directly shared and secondary-login message routing; each mixed-owner + ZIP entry uses its originating client and owner, including colliding IDs. +- Account-aware selection and expanded-thread members, filenames and drag payloads. +- Pointer entry on actual message rows and thread headers prepares drag content + while retaining working hover actions. +- Cache identity across clients, owners, messages and blobs; stable-context + pending fetches can warm a retry after dragend, while changed contexts and + unmounted rows cannot publish stale content. Retired object URLs retain the + 60-second cleanup grace period. -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: +Run from the repository root with dependencies installed: ```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 +./node_modules/.bin/vitest run \ + hooks/__tests__/use-email-drag.test.tsx \ + hooks/__tests__/use-email-drag-routing.test.tsx ``` -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. +## Limitations -## Row hover and lifecycle correction +`DownloadURL` must be available synchronously at dragstart. Hover preparation or +a later attempt may be necessary when the cache is cold. Fetch completion never +adds a file to a drag already in progress. Ready single-message caches are +retired on dragend, so another hover may be needed before a later attempt. -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. +ZIP export silently omits failed fetches, messages without blob IDs and messages +whose source login is unavailable. An archive can contain fewer entries than its +filename suggests, or be empty if every eligible fetch fails. Partial or failed +bundle builds remain cached until their cache key changes; the same selection +is not automatically retried. -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. +## Manual desktop verification -## Remaining cold-cache limitation +DataTransfer, object URLs and browser user agents are mocked under jsdom. Tests +check payload construction, synthetic message/ZIP bytes and URL lifetime; these +assertions do not establish native desktop file creation. -`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. +1. In a supported desktop Chromium browser, hover a message row, confirm its + hover actions still work, then drag its sender/subject area to the desktop. + Confirm that an `.eml` file is created. Repeat for primary, shared and + secondary-login messages using controlled test data. +2. Try a cold-cache drag. If no file is created, allow preparation to complete, + hover again and retry. Check file creation separately from the drag preview + and successful network responses. +3. Select controlled messages from multiple clients/owners, allow preparation, + and drag them to the desktop. Confirm that a ZIP file is created; separately + inspect its expected entry count and message contents. -## 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. +Record the browser version, operating system, file-creation results and whether +ZIP entry contents were actually inspected. Observing a payload or a successful +GET alone is not a successful native desktop check. From 8428219699c8868b09301c97ccba77538e173b84 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:15:36 +0200 Subject: [PATCH 6/8] chore: drop the drag test notes from the test folder --- hooks/__tests__/use-email-drag-routing.md | 56 ----------------------- 1 file changed, 56 deletions(-) delete mode 100644 hooks/__tests__/use-email-drag-routing.md diff --git a/hooks/__tests__/use-email-drag-routing.md b/hooks/__tests__/use-email-drag-routing.md deleted file mode 100644 index 8b78ea796..000000000 --- a/hooks/__tests__/use-email-drag-routing.md +++ /dev/null @@ -1,56 +0,0 @@ -# Message drag regression tests - -The maintained drag tests use synthetic JMAP sessions and message bytes with real -JMAP URL expansion, authentication and blob-fetch code. They verify: - -- Primary, directly shared and secondary-login message routing; each mixed-owner - ZIP entry uses its originating client and owner, including colliding IDs. -- Account-aware selection and expanded-thread members, filenames and drag payloads. -- Pointer entry on actual message rows and thread headers prepares drag content - while retaining working hover actions. -- Cache identity across clients, owners, messages and blobs; stable-context - pending fetches can warm a retry after dragend, while changed contexts and - unmounted rows cannot publish stale content. Retired object URLs retain the - 60-second cleanup grace period. - -Run from the repository root with dependencies installed: - -```sh -./node_modules/.bin/vitest run \ - hooks/__tests__/use-email-drag.test.tsx \ - hooks/__tests__/use-email-drag-routing.test.tsx -``` - -## Limitations - -`DownloadURL` must be available synchronously at dragstart. Hover preparation or -a later attempt may be necessary when the cache is cold. Fetch completion never -adds a file to a drag already in progress. Ready single-message caches are -retired on dragend, so another hover may be needed before a later attempt. - -ZIP export silently omits failed fetches, messages without blob IDs and messages -whose source login is unavailable. An archive can contain fewer entries than its -filename suggests, or be empty if every eligible fetch fails. Partial or failed -bundle builds remain cached until their cache key changes; the same selection -is not automatically retried. - -## Manual desktop verification - -DataTransfer, object URLs and browser user agents are mocked under jsdom. Tests -check payload construction, synthetic message/ZIP bytes and URL lifetime; these -assertions do not establish native desktop file creation. - -1. In a supported desktop Chromium browser, hover a message row, confirm its - hover actions still work, then drag its sender/subject area to the desktop. - Confirm that an `.eml` file is created. Repeat for primary, shared and - secondary-login messages using controlled test data. -2. Try a cold-cache drag. If no file is created, allow preparation to complete, - hover again and retry. Check file creation separately from the drag preview - and successful network responses. -3. Select controlled messages from multiple clients/owners, allow preparation, - and drag them to the desktop. Confirm that a ZIP file is created; separately - inspect its expected entry count and message contents. - -Record the browser version, operating system, file-creation results and whether -ZIP entry contents were actually inspected. Observing a payload or a successful -GET alone is not a successful native desktop check. From b812fe62f4a36c7b8cde741248c531bfd9fad7c8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:24:03 +0200 Subject: [PATCH 7/8] fix: keep the composer's editor-ready handler stable so a repeated report can't loop --- components/email/email-composer.tsx | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index cc9972586..211c89f6c 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2753,6 +2753,17 @@ export function EmailComposer({ const wantsPluginSelection = wantsPluginDraft; const [pluginSelection, setPluginSelection] = useState<{ text: string; empty: boolean }>(EMPTY_SELECTION); const [pluginEditor, setPluginEditor] = useState(null); + // One callback for the composer's life: the editor reports itself ready + // whenever this changes, and every report now sets state, so a new arrow + // per render would report, render and report again without end. + const handleEditorReady = useCallback((ed: Editor) => { + editorRef.current = ed; + setPluginEditor(ed); + if (!replyFocusedRef.current && (mode === 'reply' || mode === 'replyAll')) { + replyFocusedRef.current = true; + ed.commands.focus('start'); + } + }, [mode]); useEffect(() => { if (!wantsPluginSelection) return; let timer: ReturnType | null = null; @@ -4158,14 +4169,7 @@ export function EmailComposer({ hasError={validationErrors.body} collapseToolbarOnMobile={!showFormattingOnMobile} toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : 'top'} - onEditorReady={(ed) => { - editorRef.current = ed; - setPluginEditor(ed); - if (!replyFocusedRef.current && (mode === 'reply' || mode === 'replyAll')) { - replyFocusedRef.current = true; - ed.commands.focus('start'); - } - }} + onEditorReady={handleEditorReady} mentionCandidates={recipientMentionsEnabled ? mentionCandidates : undefined} findMentionContacts={recipientMentionsEnabled && mentionAddsRecipientTo !== 'off' ? findMentionContacts : undefined} mentionAddsTo={mentionAddsRecipientTo === 'cc' ? 'cc' : 'to'} From 24317a7d926a2979675553a20e5d6c2c6874ecd7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:24:27 +0200 Subject: [PATCH 8/8] test: drag the composer's plugin panels with pointer events --- .../__tests__/composer-plugin-surfaces.test.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index f236cba0b..c9812f7c9 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -518,17 +518,19 @@ describe('composer-sidebar and composer-sidebar-right', () => { offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']); const { unmount } = render(); + // A mouse drag, as pointer events with the button held (the handle follows pointers). + const drag = (handle: HTMLElement, from: number, to: number) => { + fireEvent.pointerDown(handle, { pointerId: 1, button: 0, buttons: 1, pointerType: 'mouse', clientX: from, clientY: 10 }); + fireEvent.pointerMove(document, { pointerId: 1, buttons: 1, pointerType: 'mouse', clientX: to, clientY: 10 }); + fireEvent.pointerUp(document, { pointerId: 1, button: 0, buttons: 0, pointerType: 'mouse' }); + }; // The right panel's handle is on its left edge: dragging left widens it. const right = screen.getByTestId('composer-sidebar-resize-right'); - fireEvent.mouseDown(right, { clientX: 1000 }); - fireEvent.mouseMove(document, { clientX: 900 }); - fireEvent.mouseUp(document); + drag(right, 1000, 900); expect(panel('right').style.width).toBe('360px'); // The left panel's handle is on its right edge: dragging left narrows it, down to the minimum. const left = screen.getByTestId('composer-sidebar-resize-left'); - fireEvent.mouseDown(left, { clientX: 300 }); - fireEvent.mouseMove(document, { clientX: 100 }); - fireEvent.mouseUp(document); + drag(left, 300, 100); expect(panel('left').style.width).toBe('220px'); fireEvent.keyDown(left, { key: 'ArrowRight' }); expect(panel('left').style.width).toBe('230px');