Merge remote-tracking branch 'origin/main'

This commit is contained in:
Linus Rath
2026-10-21 16:56:59 +02:00
8 changed files with 888 additions and 79 deletions
@@ -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<typeof groupEmailsByThread>[number]) =>
render(
<div data-testid="list">
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} onDelete={() => {}} />
</div>,
);
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();
});
});
+10 -7
View File
@@ -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<HTMLDivElement, SingleEmailItemProps>(
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<HTMLDivElement, SingleEmailItemProps>(
{...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<HTMLDivElement, ThreadListItemPro
threadEmails: thread.emails,
});
const [hoverActionsArmed, armHoverActions] = useArmOnPointerEnter();
const [hoverActionsArmed, armHoverActions] = useArmOnPointerOver();
const { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel, isPressed: isThreadPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
@@ -1126,7 +1129,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
<div
{...dragHandlers}
{...threadLongPressHandlers}
onPointerEnter={armHoverActions}
onPointerOver={armHoverActions}
onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }}
aria-current={isSelected ? 'true' : undefined}
data-unread={hasUnread ? 'true' : 'false'}
@@ -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: '<img src="cid:test-image">', 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(<ProEmailTabBody tabId="reader" data={{ accountId: route.owner, clientAccountId: route.emailTab ? 'login-other' : undefined, emailId: full.id, mailboxId: '__unified_inbox__', title: full.subject! }} />);
} else {
const mailbox = route.owner === 'nrichmond' ? sharedMailbox : primaryMailbox;
vi.spyOn(origin, 'getEmails').mockResolvedValue({ emails: [full], total: 1, hasMore: false });
render(<ProFolderTabBody tabId="folder" data={{ accountId: route.secondary ? 'login-other' : 'login-primary', mailboxId: mailbox.id, title: mailbox.name }} />);
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(<ProEmailTabBody tabId="reader" data={{ accountId: 'nrichmond', emailId: 'email', mailboxId: null, title: 'Loading' }} />);
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) => <ProEmailTabBody tabId="reader" data={{ accountId: owner, clientAccountId: login, emailId: full.id, mailboxId: null, title: full.subject! }} />;
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);
});
});
+43 -7
View File
@@ -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<Email | null>(null);
const [blobSource, setBlobSource] = useState<EmailBlobContext>({ 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<Email | null>;
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
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={email}
blobSource={blobSource}
isLoading={isLoading}
onReply={handleReply}
onReplyAll={handleReplyAll}
@@ -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
View File
@@ -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
+14
View File
@@ -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', () => {
+4 -1
View File
@@ -21,8 +21,11 @@ export function pushPreferencesKey(origin: string): string {
}
export async function writePushPreferences(prefs: PushPreferences): Promise<void> {
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),