diff --git a/components/mail/__tests__/blob-routing.test.tsx b/components/mail/__tests__/blob-routing.test.tsx index 668edfa81..b0777c3db 100644 --- a/components/mail/__tests__/blob-routing.test.tsx +++ b/components/mail/__tests__/blob-routing.test.tsx @@ -75,6 +75,8 @@ vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: ({ o const { MailApp } = await import('../mail-app'); const { ConversationEmailBody } = await import('@/components/email/thread-conversation-view'); const { EmailViewer } = await import('@/components/email/email-viewer'); +const { ProEmailTabBody } = await import('@/components/pro/pro-email-tab-body'); +const { ProFolderTabBody } = await import('@/components/pro/pro-folder-tab-body'); const sharedMailbox = { id: 'nrichmond:inbox', originalId: 'inbox', name: 'Shared', isShared: true, accountId: 'nrichmond' } as Mailbox; const primaryMailbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; @@ -602,3 +604,132 @@ describe('conversation blob routing', () => { expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owner-a'); }); }); + + +// Pro hosts must carry the same message-scoped context as MailApp, including +// when the global mail selection is absent or belongs to another message. +type ProBlobAction = 'download' | 'preview click' | 'CID' | 'export'; +const proBlobActions: ProBlobAction[] = ['download', 'preview click', 'CID', 'export']; +const proRoutes = [ + { label: 'direct shared folder', secondary: false, owner: 'nrichmond', selection: 'null', unified: false }, + { label: 'secondary shared folder', secondary: true, owner: 'nrichmond', selection: 'unrelated', unified: false }, + { label: 'primary folder', secondary: false, owner: 'postal12345', selection: 'null', unified: false }, + { label: 'secondary primary folder', secondary: true, owner: 'secondary-primary', selection: 'unrelated', unified: false }, + { label: 'Unified tab, null selection', secondary: true, owner: 'nrichmond', selection: 'null', unified: true }, + { label: 'Unified tab, unrelated selection', secondary: true, owner: 'nrichmond', selection: 'unrelated', unified: true }, + { label: 'explicit secondary email tab', secondary: true, owner: 'nrichmond', selection: 'null', unified: false, emailTab: true }, +]; + +async function openProMessage(route: typeof proRoutes[number], action: ProBlobAction) { + const { a, b, fetchMock } = await conversationClients(); + const origin = route.secondary ? b : a; + const full = message({ + subject: 'Pro message', + attachments: [{ ...cardAttachment, name: 'photo.png', ...(action === 'CID' ? { cid: 'test-image' } : {}) }], + ...(action === 'CID' ? { + textBody: [], htmlBody: [{ partId: '1', blobId: 'html-blob', type: 'text/html', size: 30 }], + bodyValues: { '1': { value: '', isTruncated: false, isEncodingProblem: false } }, + } : {}), + }); + const stub = { ...full, sourceClientAccountId: 'login-other', sourceAccountId: route.owner }; + vi.spyOn(a, 'getEmail').mockResolvedValue(full); + vi.spyOn(b, 'getEmail').mockResolvedValue(full); + useSettingsStore.setState({ emailDownloadTemplate: '{subject}', mailAttachmentAction: action === 'preview click' ? 'preview' : 'download' }); + useEmailStore.setState({ + emails: route.unified ? [stub] : [], + selectedEmail: route.selection === 'null' ? null : message({ id: 'unrelated', sourceClientAccountId: 'login-primary', sourceAccountId: 'unrelated-owner' }), + isUnifiedView: route.unified, selectedMailbox: route.unified ? '__unified_inbox__' : sharedMailbox.id, + accountMailboxes: { 'login-primary': [primaryMailbox, sharedMailbox], 'login-other': [primaryMailbox, sharedMailbox] }, + }); + if (route.unified || route.emailTab) { + render(); + } else { + const mailbox = route.owner === 'nrichmond' ? sharedMailbox : primaryMailbox; + vi.spyOn(origin, 'getEmails').mockResolvedValue({ emails: [full], total: 1, hasMore: false }); + render(); + fireEvent.click(await screen.findByText('Pro message')); + } + if (action === 'CID') await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + else await screen.findAllByText('photo.png'); + return { a, b, origin, fetchMock, full }; +} + +async function actOnProBlob(action: ProBlobAction) { + if (action === 'download') await downloadAttachment(); + if (action === 'preview click') { + fireEvent.click((await screen.findAllByText('photo.png'))[0]); + // Preserve Pro's existing action: Preview clicks download, with no modal. + expect(screen.queryByText('Preview fetch')).not.toBeInTheDocument(); + } + if (action === 'export') await exportEmail(); +} +function expectProBlob(route: typeof proRoutes[number], action: ProBlobAction, index = 0) { + const host = route.secondary ? 'secondary' : 'primary'; + const blobPath = action === 'export' ? 'raw-blob/Pro%20message.eml?accept=message%2Frfc822' : 'same-blob/photo.png?accept=image%2Fpng'; + expectBlobRequest(`https://${host}.example.com/download/${route.owner}/${blobPath}`, `${host}-login`, index); +} + +describe('Pro blob routing', () => { + it.each(proRoutes.flatMap(route => proBlobActions.map(action => ({ ...route, action }))))( + '$label: $action uses the message owner and authenticated client', async route => { + const { origin, fetchMock } = await openProMessage(route, route.action); + await actOnProBlob(route.action); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expectProBlob(route, route.action); + if (route.owner === 'nrichmond') expect(origin.getEmail).toHaveBeenCalledWith('email', route.owner); + }, + ); + + it.each([proRoutes[0], proRoutes[1], proRoutes[4]].flatMap(route => proBlobActions.map(action => ({ ...route, action }))))( + '$label: $action retains its source after account and selection changes', async route => { + const { a, b, fetchMock } = await openProMessage(route, route.action); + if (route.action === 'CID') await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + // Leave the loaded reader mounted while a different Mail pane navigates. + act(() => { + useEmailStore.setState({ emails: [], selectedEmail: message({ id: 'other', sourceClientAccountId: 'login-primary', sourceAccountId: 'unrelated-owner' }), selectedMailbox: 'inbox', isUnifiedView: false }); + useAuthStore.setState({ client: route.secondary ? b : a, activeAccountId: route.secondary ? 'login-other' : 'login-primary' }); + }); + // Switch through null, as reconnecting account switches can do. + act(() => useAuthStore.setState({ client: null })); + act(() => useAuthStore.setState({ client: route.secondary ? a : b, activeAccountId: route.secondary ? 'login-primary' : 'login-other' })); + await act(async () => {}); + await actOnProBlob(route.action); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + for (let index = 0; index < fetchMock.mock.calls.length; index++) expectProBlob(route, route.action, index); + }, + ); + + it('retains an in-flight Unified load across account and selection changes', async () => { + const { a, b, fetchMock } = await conversationClients(); + let finish!: (email: Email) => void; + vi.spyOn(b, 'getEmail').mockImplementation(() => new Promise(resolve => { finish = resolve; })); + const wrongClient = vi.spyOn(a, 'getEmail'); + useEmailStore.setState({ emails: [message({ sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond' })], selectedEmail: null, isUnifiedView: true }); + render(); + await waitFor(() => expect(b.getEmail).toHaveBeenCalledWith('email', 'nrichmond')); + act(() => { + useEmailStore.setState({ emails: [], selectedEmail: message({ id: 'unrelated' }), isUnifiedView: false, selectedMailbox: 'inbox' }); + useAuthStore.setState({ client: b, activeAccountId: 'login-other' }); + }); + await act(async () => finish(message({ attachments: [{ ...cardAttachment, name: 'photo.png' }] }))); + await downloadAttachment(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expectProBlob(proRoutes[4], 'download'); + expect(wrongClient).not.toHaveBeenCalled(); + expect(b.getEmail).toHaveBeenCalledTimes(1); + }); + + it('changes CID routing when a reused reader opens identical ids on another client/owner', async () => { + const { a, b, fetchMock } = await conversationClients(); + const full = inlineMessage({ attachments: [{ ...cardAttachment, name: 'photo.png', cid: 'test-image' }] }); + vi.spyOn(a, 'getEmail').mockResolvedValue(full); + vi.spyOn(b, 'getEmail').mockResolvedValue({ ...full }); + const reader = (login: string, owner: string) => ; + const { rerender } = render(reader('login-primary', 'owner-a')); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expectBlobRequest('https://primary.example.com/download/owner-a/same-blob/photo.png?accept=image%2Fpng', 'primary-login'); + rerender(reader('login-other', 'owner-b')); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expectBlobRequest('https://secondary.example.com/download/owner-b/same-blob/photo.png?accept=image%2Fpng', 'secondary-login', 1); + }); +}); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index b51b900ac..88dd3d838 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { EmailViewer } from "@/components/email/email-viewer"; import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error"; import { useAuthStore } from "@/stores/auth-store"; -import { useEmailStore } from "@/stores/email-store"; +import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext } from "@/stores/email-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { findDraftIdentityId } from "@/lib/reply-identity"; @@ -100,6 +100,17 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa const openComposeTabNow = useProTabStore((s) => s.openComposeTab); const [email, setEmail] = useState(null); + const [blobSource, setBlobSource] = useState({ client: null }); + // A reader owns its load, including an in-flight one across account switches. + // Only a different message or explicit tab context starts a new load. + const loadRef = useRef<{ + emailId: string; + clientOverride: typeof clientOverride; + accountId: typeof accountId; + blobSource: EmailBlobContext; + promise: Promise; + applied: boolean; + } | null>(null); const [isLoading, setIsLoading] = useState(true); const composerSessionIdRef = useRef(0); @@ -137,11 +148,34 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa useEffect(() => { let cancelled = false; if (!client) return; + let load = loadRef.current; + if (!load || load.emailId !== emailId || load.clientOverride !== clientOverride || load.accountId !== accountId) { + const state = useEmailStore.getState(); + const ref = state.selectedEmail?.id === emailId + ? state.selectedEmail + : state.emails.find((e) => e.id === emailId); + // Folder/search panes supply their own context. A primary folder must + // not inherit an unrelated globally selected shared folder's owner. + const source = accountId || clientOverride + ? { client, accountId: accountId ?? client.getAccountId() } + : resolveEmailBlobContext(ref ?? null, client); + load = { + emailId, clientOverride, accountId, blobSource: source, applied: false, + promise: accountId || clientOverride + ? client.getEmail(emailId, source.accountId) + : fetchEmailContent(client, emailId), + }; + loadRef.current = load; + } + const pending = load; + if (pending.applied) return; setIsLoading(true); - (accountId ? client.getEmail(emailId, accountId) : fetchEmailContent(client, emailId)) + pending.promise .then((loaded) => { if (cancelled) return; + pending.applied = true; setEmail(loaded); + setBlobSource(pending.blobSource); if (loaded) onLoadedRef.current?.(loaded); }) .catch((err) => { @@ -153,7 +187,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa return () => { cancelled = true; }; - }, [client, emailId, accountId, fetchEmailContent]); + }, [client, clientOverride, emailId, accountId, fetchEmailContent]); const currentMailboxRole = useMemo(() => { if (!email) return undefined; @@ -326,14 +360,15 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa } }, [client, email, moveToMailbox, onClose]); - const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string) => { - if (!client) return; + const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string, _forceDownload?: boolean, source?: EmailBlobContext) => { + const context = source ?? blobSource; + if (!context.client) return; try { - await client.downloadBlob(blobId, name, type); + await context.client.downloadBlob(blobId, name, type, context.accountId); } catch (err) { console.error('Download failed:', err); } - }, [client]); + }, [blobSource]); const handleQuickReply = useCallback(async (body: string) => { handleReply(body); @@ -394,6 +429,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa