mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
fix: route dragged emails by owning account
This commit is contained in:
@@ -526,7 +526,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{...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<HTMLDivElement, ThreadListItemPro
|
||||
<div
|
||||
{...dragHandlers}
|
||||
{...threadLongPressHandlers}
|
||||
onPointerEnter={armHoverActions}
|
||||
onPointerEnter={() => {
|
||||
armHoverActions();
|
||||
dragHandlers.onPointerEnter?.();
|
||||
}}
|
||||
onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }}
|
||||
aria-current={isSelected ? 'true' : undefined}
|
||||
data-unread={hasUnread ? 'true' : 'false'}
|
||||
|
||||
@@ -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.
|
||||
@@ -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<string, JMAPClient>;
|
||||
let liveClients: JMAPClient[];
|
||||
let objects: Map<string, Blob>;
|
||||
let requests: { owner: string; blob: string; auth: string }[];
|
||||
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||
let revoke: ReturnType<typeof vi.fn<(url: string) => void>>;
|
||||
|
||||
function mail(overrides: Partial<Email> = {}): 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<JMAPClient> {
|
||||
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<typeof useEmailDrag>['dragHandlers']) {
|
||||
const payload = new Map<string, string>();
|
||||
const dataTransfer = {
|
||||
setData: (type: string, value: string) => payload.set(type, value),
|
||||
setDragImage: vi.fn(), effectAllowed: '',
|
||||
};
|
||||
act(() => handlers.onDragStart({ dataTransfer } as unknown as DragEvent<HTMLDivElement>));
|
||||
return payload;
|
||||
}
|
||||
|
||||
function payloadBlob(payload: Map<string, string>) {
|
||||
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<ArrayBuffer> {
|
||||
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<typeof useEmailDrag> }) {
|
||||
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<typeof fetch>(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<HTMLDivElement>));
|
||||
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<HTMLDivElement>));
|
||||
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<HTMLDivElement>));
|
||||
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<HTMLDivElement>));
|
||||
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<HTMLDivElement>));
|
||||
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(<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} 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<string, string>();
|
||||
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));
|
||||
});
|
||||
});
|
||||
+89
-64
@@ -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<IJMAPClient, number>();
|
||||
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<string | null> {
|
||||
const eligible = emails.filter((em) => !!em.blobId);
|
||||
type RoutedEmail = ReturnType<typeof routeEmail>;
|
||||
|
||||
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<string | null> {
|
||||
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<string>();
|
||||
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<string | null>(null);
|
||||
const inFlightRef = useRef<Promise<string | null> | null>(null);
|
||||
const singleRef = useRef<BundleEntry | null>(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<HTMLDivElement>) => {
|
||||
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: <mime>:<filename>:<url>. 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
|
||||
|
||||
Reference in New Issue
Block a user