From bb3ef6d9fc3ba727cb2308d67483bc6598a81038 Mon Sep 17 00:00:00 2001 From: VasilisNtovolos Date: Wed, 7 Oct 2026 15:57:45 +0300 Subject: [PATCH] fix: route shared-account blob downloads by message owner --- components/email/email-viewer.tsx | 32 +- .../mail/__tests__/blob-routing.test.tsx | 358 ++++++++++++++++++ components/mail/mail-app.tsx | 86 +---- stores/email-store.ts | 46 ++- 4 files changed, 433 insertions(+), 89 deletions(-) create mode 100644 components/mail/__tests__/blob-routing.test.tsx diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cdf91d2d0..5c0a04fdf 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -132,6 +132,8 @@ const SUB_MENU_ITEM_SELECTOR = '[role="menuitem"],[role="menuitemcheckbox"],[rol interface EmailViewerProps { email: Email | null; + /** Captured message routing supplied by the standard mail UI. */ + blobSource?: { client: IJMAPClient | null; accountId?: string }; isLoading?: boolean; onReply?: (draftText?: string) => void; onReplyAll?: () => void; @@ -601,7 +603,7 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl getBlobUrl: async () => { if (attachment.blobId && client) { try { - return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type, accountId); + return await client.fetchBlobAsObjectUrl(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId); } catch { return null; } @@ -638,6 +640,7 @@ function SidebarSection({ icon: Icon, title, children }: { icon: React.Component export function EmailViewer({ email, + blobSource, isLoading = false, onReply, onReplyAll, @@ -767,18 +770,17 @@ export function EmailViewer({ const { tabletListVisible } = useUIStore(); const { identities, client, isDemoMode, activeAccountId } = useAuthStore(); const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId)); - // Blobs (inline images, drag-out, TNEF, embedded messages, thumbnails, bundle - // downloads) are account-scoped. In the unified / All-Mail view the open - // message may belong to another login (route to its client) or a delegated - // shared account (same client, owner accountId in the URL). Resolve both from - // the message's source so cross-account blob fetches don't 404 against the - // active account. + // The standard mail UI supplies message routing for preview/download, + // inline images, export and attachment drag-out. Other viewer hosts keep + // their existing fallback routing. const isUnifiedView = useEmailStore((s) => s.isUnifiedView); - const blobClient = useMemo(() => { + const legacyBlobClient = useMemo(() => { const scid = isUnifiedView ? email?.sourceClientAccountId : undefined; return (scid ? useAuthStore.getState().getClientForAccount(scid) : null) ?? client; }, [isUnifiedView, email?.sourceClientAccountId, client]); - const blobAccountId = isUnifiedView ? email?.sourceAccountId : undefined; + const legacyBlobAccountId = isUnifiedView ? email?.sourceAccountId : undefined; + const blobClient = blobSource ? blobSource.client : legacyBlobClient; + const blobAccountId = blobSource ? blobSource.accountId : legacyBlobAccountId; // List-Unsubscribe mailto: send the message ourselves - this is a webmail // client, handing a mailto: URL to the OS mail handler goes nowhere for @@ -1368,7 +1370,7 @@ export function EmailViewer({ // TNEF (winmail.dat) detection and processing useEffect(() => { - if (!email?.attachments || !client) return; + if (!email?.attachments || !blobClient) return; const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type)); if (!tnefAtt?.blobId) { @@ -1460,7 +1462,7 @@ export function EmailViewer({ // often empty Word boilerplate and the real content is inside a message/rfc822 // attachment. Detect this pattern and unwrap the embedded email. useEffect(() => { - if (!email?.attachments || !client) return; + if (!email?.attachments || !blobClient) return; // Find message/rfc822 attachment const rfc822Att = email.attachments.find( @@ -1567,7 +1569,7 @@ export function EmailViewer({ }; } - if (!client || !email?.attachments) { + if (!blobClient || !email?.attachments) { setCidBlobUrls({}); return; } @@ -2660,9 +2662,11 @@ export function EmailViewer({ // Export email as .eml file const handleExportEmail = async () => { - if (!email?.blobId || !client) return; + const exportClient = blobSource ? blobSource.client : client; + const exportAccountId = blobSource?.accountId; + if (!email?.blobId || !exportClient) return; try { - await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822'); + await exportClient.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822', exportAccountId); } catch { toast.error(tNotifications('export_email_error')); return; diff --git a/components/mail/__tests__/blob-routing.test.tsx b/components/mail/__tests__/blob-routing.test.tsx new file mode 100644 index 000000000..3fd259410 --- /dev/null +++ b/components/mail/__tests__/blob-routing.test.tsx @@ -0,0 +1,358 @@ +import React, { Suspense, lazy } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { JMAPClient } from '@/lib/jmap/client'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; + +// Keep the real MailApp selection/download handlers and EmailViewer. Stub +// unrelated shell UI and bootstrap requests so no server is contacted. +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: ({ emails, onEmailSelect, onOpenAttachment }: { + emails: Email[]; onEmailSelect: (email: Email) => void; onOpenAttachment: (email: Email, attachment: NonNullable[number]) => void; +}) =>
{emails.map((email, i) =>
+ + +
)}
})); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: ({ onDownload, getFileContent }: { + onDownload: () => void; getFileContent: () => Promise; +}) =>
})); + +const { MailApp } = await import('../mail-app'); + +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; +function message(overrides: Partial = {}): Email { + return { + id: 'email', threadId: 'thread', blobId: 'raw-blob', subject: 'Hello', + mailboxIds: {}, keywords: { $seen: true }, size: 100, + receivedAt: '2026-01-01T00:00:00Z', from: [{ email: 'sender@example.com' }], to: [], + preview: 'Body', hasAttachment: true, + textBody: [{ partId: '1', type: 'text/plain', size: 4 }], htmlBody: [], + bodyValues: { '1': { value: 'Body', isEncodingProblem: false, isTruncated: false } }, + attachments: [{ id: '3', partId: '3', blobId: 'same-blob', name: 'attached.eml', type: 'message/rfc822', size: 50 }], + ...overrides, + } as Email; +} +function makeClient(accountId: string) { + return { + getAccountId: () => accountId, getUsername: () => `${accountId}@example.com`, + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + getEmail: vi.fn().mockResolvedValue(message()), + downloadBlob: vi.fn().mockResolvedValue(undefined), + fetchBlob: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/png' })), + }; +} +const connectedClients: JMAPClient[] = []; +afterEach(() => { + connectedClients.splice(0).forEach(client => client.disconnect()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +let primary: ReturnType; +let other: ReturnType; +const asClient = (c: ReturnType) => c as unknown as IJMAPClient; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + URL.createObjectURL = vi.fn(() => 'blob:test'); + URL.revokeObjectURL = vi.fn(); + primary = makeClient('postal12345'); + other = makeClient('other-primary'); + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: asClient(primary) as never, + activeAccountId: 'login-primary', identities: [], + getClientForAccount: (id) => (id === 'login-primary' ? asClient(primary) : id === 'login-other' ? asClient(other) : undefined) as never, + getAllConnectedClients: () => new Map([['login-primary', asClient(primary)], ['login-other', asClient(other)]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailAttachmentAction: 'download', mailLayout: 'split', attachmentImagePreviewsEnabled: false, postExportAction: 'none', attachmentDownloadTemplate: '{filename}', filenameLowercase: false } as never); + useEmailStore.setState({ + emails: [message()], selectedEmail: message(), selectedMailbox: sharedMailbox.id, + mailboxes: [primaryMailbox, sharedMailbox], viewingAccountId: null, accountMailboxes: {}, + isUnifiedView: false, unifiedRole: null, isScheduledView: false, isLoadingEmail: false, + isLoading: false, searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', + selectedKeyword: null, selectedEmailIds: new Set(), + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + }); +}); + +async function downloadAttachment() { + // The viewer renders responsive copies; any Download button exercises the + // same production handler. The raw RFC822 part is the live reproduction. + const buttons = await screen.findAllByTitle('download'); + fireEvent.click(buttons[0]); +} +async function exportEmail() { + fireEvent.click((await screen.findAllByRole('button', { name: 'more_actions' }))[0]); + fireEvent.click((await screen.findAllByText('export_email'))[0]); +} + +async function connectDownloadClient(serverUrl: string, username: string, accountId: string) { + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ + capabilities: { 'urn:ietf:params:jmap:core': {} }, + accounts: { + [accountId]: { name: 'Primary', isPersonal: true, accountCapabilities: {} }, + nrichmond: { name: 'Shared', isPersonal: false, accountCapabilities: {} }, + }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': accountId }, + apiUrl: `${serverUrl}/jmap/api`, + downloadUrl: `${serverUrl}/download/{accountId}/{blobId}/{name}?accept={type}`, + uploadUrl: `${serverUrl}/upload/{accountId}`, eventSourceUrl: '', + }), { status: 200 })); + const client = new JMAPClient(serverUrl, username, 'test-password'); + connectedClients.push(client); + await client.connect(); + return client; +} + +describe('standard mail UI blob routing', () => { + it.each([sharedMailbox.id, primaryMailbox.id])('uses the session downloadUrl for %s', async (mailboxId) => { + const fetchMock = vi.mocked(fetch); + const realClient = await connectDownloadClient('https://mail.example.com', 'test-login', 'postal12345'); + fetchMock.mockClear(); + fetchMock.mockResolvedValue(new Response('attachment', { status: 200 })); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + useAuthStore.setState({ + client: realClient, + getClientForAccount: id => id === 'login-primary' ? realClient : undefined, + getAllConnectedClients: () => new Map([['login-primary', realClient]]), + }); + useEmailStore.setState({ selectedMailbox: mailboxId }); + render(); + await downloadAttachment(); + const owner = mailboxId === sharedMailbox.id ? 'nrichmond' : 'postal12345'; + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(String(fetchMock.mock.calls[0][0])).toMatch(new RegExp(`/download/${owner}/same-blob/`)); + expect(String(fetchMock.mock.calls[0][0])).toContain('accept=message%2Frfc822'); + expect(fetchMock.mock.calls[0][1]?.headers).toEqual(expect.objectContaining({ Authorization: `Basic ${btoa('test-login:test-password')}` })); + }); + + it.each([ + { label: 'direct shared folder', owner: 'nrichmond', login: 'login-primary', filename: 'attachment' }, + { label: 'stamped shared message on another login', owner: 'nrichmond', login: 'login-other', filename: 'attachment' }, + { label: 'primary account', owner: 'postal12345', login: 'login-primary', filename: 'attachment' }, + { label: 'formatted unnamed attachment', owner: 'nrichmond', login: 'login-primary', filename: 'hello-attachment', formatted: true }, + ])('routes Chromium hover and Download for $label', async ({ owner, login, filename, formatted }) => { + // Enable the real browser capability gate before the viewer mounts. + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 Chrome/130.0.0.0'); + const primaryClient = await connectDownloadClient('https://primary.example.com', 'primary-login', 'postal12345'); + const secondaryClient = await connectDownloadClient('https://secondary.example.com', 'secondary-login', 'secondary-primary'); + useAuthStore.setState({ + client: primaryClient, + getClientForAccount: id => id === 'login-primary' ? primaryClient : id === 'login-other' ? secondaryClient : undefined, + getAllConnectedClients: () => new Map([['login-primary', primaryClient], ['login-other', secondaryClient]]), + }); + useSettingsStore.setState({ + attachmentDownloadTemplate: formatted ? '{subject}-{filename}' : '{filename}', + filenameLowercase: !!formatted, + }); + const email = message({ + attachments: [{ partId: '3', blobId: 'same-blob', type: 'message/rfc822', size: 50 }], + ...(login === 'login-other' ? { sourceClientAccountId: login, sourceAccountId: owner } : {}), + }); + // message() has a nonempty outer body, so automatic RFC822 unwrapping + // cannot contribute a third request to this interaction. + useEmailStore.setState({ selectedEmail: email, selectedMailbox: owner === 'postal12345' ? primaryMailbox.id : sharedMailbox.id }); + const fetchMock = vi.mocked(fetch); + fetchMock.mockClear(); + fetchMock.mockImplementation(async () => new Response('attachment', { status: 200, headers: { 'Content-Type': 'message/rfc822' } })); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + render(); + const downloadButton = (await screen.findAllByTitle('download'))[0]; + const chip = downloadButton.closest('[draggable="true"]'); + expect(chip).not.toBeNull(); + + fireEvent.pointerOver(downloadButton, { pointerType: 'mouse' }); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(1)); + const host = login === 'login-other' ? 'https://secondary.example.com' : 'https://primary.example.com'; + const expectedUrl = `${host}/download/${owner}/same-blob/${filename}?accept=message%2Frfc822`; + const authorization = `Basic ${btoa(`${login === 'login-other' ? 'secondary-login' : 'primary-login'}:test-password`)}`; + expect(String(fetchMock.mock.calls[0][0])).toBe(expectedUrl); + expect(fetchMock.mock.calls[0][1]?.headers).toEqual(expect.objectContaining({ Authorization: authorization })); + + // Hover still prepares a working DownloadURL for a real drag start. + const dataTransfer = { setData: vi.fn(), effectAllowed: '' }; + fireEvent.dragStart(chip!, { dataTransfer }); + expect(dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', `message/rfc822:${filename}:blob:test`); + expect(dataTransfer.effectAllowed).toBe('copyMove'); + + fireEvent.click(downloadButton); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([expectedUrl, expectedUrl]); + expect(fetchMock.mock.calls[1][1]?.headers).toEqual(expect.objectContaining({ Authorization: authorization })); + }); + + it('downloads a direct shared-folder message/rfc822 part through its owner', async () => { + render(); + await downloadAttachment(); + expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond'); + expect(other.downloadBlob).not.toHaveBeenCalled(); + }); + + it('keeps primary-account downloads compatible', async () => { + useEmailStore.setState({ selectedMailbox: 'inbox' }); + render(); + await downloadAttachment(); + expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', undefined); + }); + + it('exports the raw shared message through its owner', async () => { + render(); + await exportEmail(); + expect(primary.downloadBlob).toHaveBeenCalledWith('raw-blob', expect.stringMatching(/\.eml$/), 'message/rfc822', 'nrichmond'); + }); + + it.each([false, true])('routes Unified selection (automatic=%s) after Email/get replaces the stub', async (automatic) => { + const stub = message({ bodyValues: undefined, accountId: 'nrichmond', accountLabel: 'Richmond', sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond' }); + useEmailStore.setState({ emails: [stub], selectedEmail: automatic ? stub : null, isUnifiedView: true, selectedMailbox: '' }); + render(); + if (!automatic) fireEvent.click(screen.getByText('Select 0')); + await waitFor(() => expect(other.getEmail).toHaveBeenCalledWith('email', 'nrichmond')); + await waitFor(() => expect(useEmailStore.getState().selectedEmail?.bodyValues).toBeDefined()); + await downloadAttachment(); + await exportEmail(); + expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond'); + expect(other.downloadBlob).toHaveBeenCalledWith('raw-blob', expect.any(String), 'message/rfc822', 'nrichmond'); + expect(primary.downloadBlob).not.toHaveBeenCalled(); + expect(useEmailStore.getState().selectedEmail).toMatchObject({ sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond', accountLabel: 'Richmond' }); + }); + + it('keeps preview fetch and download on the originating owner after navigation', async () => { + useSettingsStore.setState({ mailAttachmentAction: 'preview' }); + const email = message({ attachments: [{ partId: '3', blobId: 'same-blob', name: 'photo.png', type: 'image/png', size: 50 }] }); + useEmailStore.setState({ selectedEmail: email }); + render(); + fireEvent.click((await screen.findAllByText('photo.png'))[0]); + await screen.findByText('Preview download'); + act(() => { + useEmailStore.setState({ selectedMailbox: 'inbox', selectedEmail: message() }); + useAuthStore.setState({ client: asClient(other) as never, activeAccountId: 'login-other' }); + }); + fireEvent.click(screen.getByText('Preview fetch')); + fireEvent.click(screen.getByText('Preview download')); + expect(primary.fetchBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'image/png', 'nrichmond'); + expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'image/png', 'nrichmond'); + expect(other.downloadBlob).not.toHaveBeenCalled(); + }); + + it('honors stamps outside Unified view and isolates overlapping blob ids', async () => { + const first = message({ sourceClientAccountId: 'login-other', sourceAccountId: 'owner-a' }); + const second = message({ sourceClientAccountId: 'login-primary', sourceAccountId: 'owner-b' }); + useEmailStore.setState({ emails: [first, second], selectedEmail: first }); + render(); + await downloadAttachment(); + act(() => useEmailStore.setState({ selectedEmail: second })); + await downloadAttachment(); + expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'owner-a'); + expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'owner-b'); + }); + + it('keeps a direct folder source when Email/get completes after an account switch', async () => { + let finish!: (email: Email) => void; + primary.getEmail.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); + useEmailStore.setState({ selectedEmail: message({ bodyValues: undefined }) }); + render(); + await waitFor(() => expect(primary.getEmail).toHaveBeenCalledWith('email', 'nrichmond')); + act(() => { + useEmailStore.setState({ selectedMailbox: 'inbox' }); + useAuthStore.setState({ client: asClient(other) as never, activeAccountId: 'login-other' }); + }); + await act(async () => finish(message())); + await downloadAttachment(); + expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond'); + expect(other.downloadBlob).not.toHaveBeenCalled(); + }); + + it('keeps an unstamped all-folders search hit on the primary account', async () => { + useEmailStore.setState({ searchQuery: 'Hello', searchMailboxId: '*' }); + render(); + await downloadAttachment(); + expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', undefined); + }); + + it('uses the browsing login for a direct shared folder on another login', async () => { + useEmailStore.setState({ viewingAccountId: 'login-other', accountMailboxes: { 'login-other': [sharedMailbox] } }); + render(); + await downloadAttachment(); + expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond'); + expect(primary.downloadBlob).not.toHaveBeenCalled(); + }); + + it('downloads a list attachment using that row rather than the selected message', async () => { + const row = message({ sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond' }); + useEmailStore.setState({ emails: [row], selectedEmail: message() }); + render(); + fireEvent.click(screen.getByText('List attachment 0')); + expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', 'attached.eml', 'message/rfc822', 'nrichmond'); + expect(primary.downloadBlob).not.toHaveBeenCalled(); + }); + + it('does not route a disconnected stamped login through the primary client', async () => { + useEmailStore.setState({ selectedEmail: message({ sourceClientAccountId: 'missing-login', sourceAccountId: 'nrichmond' }) }); + render(); + await downloadAttachment(); + await exportEmail(); + expect(primary.downloadBlob).not.toHaveBeenCalled(); + expect(other.downloadBlob).not.toHaveBeenCalled(); + }); + + it('fetches an automatically selected owner even when its email id overlaps the previous selection', async () => { + const first = message({ sourceClientAccountId: 'login-primary', sourceAccountId: 'owner-a' }); + const second = message({ bodyValues: undefined, sourceClientAccountId: 'login-other', sourceAccountId: 'owner-b' }); + useEmailStore.setState({ emails: [first, second], selectedEmail: first, isUnifiedView: true }); + render(); + await downloadAttachment(); + act(() => useEmailStore.getState().selectEmail(second)); + await waitFor(() => expect(other.getEmail).toHaveBeenCalledWith('email', 'owner-b')); + await waitFor(() => expect(useEmailStore.getState().selectedEmail?.bodyValues).toBeDefined()); + await downloadAttachment(); + expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'owner-b'); + }); + + it('fetches inline images with the shared owner', async () => { + useEmailStore.setState({ selectedEmail: message({ attachments: [{ partId: 'image', blobId: 'same-blob', name: 'inline.png', cid: 'cid-image', type: 'image/png', size: 50 }] }) }); + render(); + await waitFor(() => expect(primary.fetchBlob).toHaveBeenCalledWith('same-blob', 'inline.png', 'image/png', 'nrichmond')); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c145bb99a..6a3bc4c05 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -31,7 +31,7 @@ import { usePolicyStore } from "@/stores/policy-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { connectedAccountsGrew } from "@/lib/unified-mailbox"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; -import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveUnstampedEmailAccountId, emptyFolderMovesToTrash } from "@/stores/email-store"; +import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveUnstampedEmailAccountId, resolveEmailBlobContext, emptyFolderMovesToTrash } from "@/stores/email-store"; import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; import { toast } from "@/stores/toast-store"; import { formatRejectedRecipients, type JMAPClient } from "@/lib/jmap/client"; @@ -197,7 +197,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const [conversationEmails, setConversationEmails] = useState([]); const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(null); - const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null); + const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; client: NonNullable } | null>(null); // Office attachments open read-only in the configured WOPI editor (#1047). const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null); const wopiStatus = useWopiStatus(true); @@ -1641,17 +1641,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before // calling selectEmail on the stub), to avoid a duplicate request. if (!selectedEmail.bodyValues && !isLoadingEmail) { - const perAccountClient = isUnifiedView && selectedEmail.sourceClientAccountId - ? useAuthStore.getState().getClientForAccount(selectedEmail.sourceClientAccountId) - : undefined; - const fetchClient = perAccountClient ?? client; setLoadingEmail(true); - fetchEmailContent(fetchClient, selectedEmail.id).finally(() => { + fetchEmailContent(client, selectedEmail.id).finally(() => { setLoadingEmail(false); }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedEmail?.id]); + }, [selectedEmail?.id, selectedEmail?.sourceClientAccountId, selectedEmail?.sourceAccountId]); // Opening a message hides the list on tablet, but plenty of paths clear the // reading pane without going through a "back" handler (delete/move/archive, @@ -3154,20 +3150,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }; }, []); - // Blobs are scoped per JMAP account. In the unified/All-Mail view the open - // message may belong to another login (route to its client) or to a delegated - // shared account (same client, but the owner's accountId in the download URL). - // Resolve both from the email's source so attachments on cross-account - // messages can be viewed/downloaded instead of 404ing against the active - // account. + // Resolve from the originating message, including direct shared folders. const resolveBlobSource = useCallback((email: typeof selectedEmail) => { - const clientAccountId = isUnifiedView ? email?.sourceClientAccountId : undefined; - const blobClient = clientAccountId - ? (useAuthStore.getState().getClientForAccount(clientAccountId) ?? client) - : client; - const accountId = isUnifiedView ? email?.sourceAccountId : undefined; + const { client: blobClient, accountId, clientAccountId } = resolveEmailBlobContext(email, client); return { blobClient, accountId, clientAccountId }; - }, [isUnifiedView, client]); + }, [client]); // Opens an attachment the built-in preview cannot render but the office // editor can (docx, odt, xlsx, ...). The launch route runs server-side with @@ -3198,7 +3185,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { try { const { mailAttachmentAction } = useSettingsStore.getState(); if (mailAttachmentAction === 'preview' && isFilePreviewable(name, attachment.type)) { - setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, clientAccountId }); + setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, client: blobClient }); return; } if (mailAttachmentAction === 'preview' && openOfficeAttachment( @@ -3258,7 +3245,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const { mailAttachmentAction } = useSettingsStore.getState(); if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) { - setPreviewAttachment({ blobId, name, type, accountId, clientAccountId }); + setPreviewAttachment({ blobId, name, type, accountId, client: blobClient }); return; } if (!forceDownload && mailAttachmentAction === 'preview' && openOfficeAttachment( @@ -3271,20 +3258,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }; - const previewBlobClient = useCallback(() => { - const id = previewAttachment?.clientAccountId; - return id ? (useAuthStore.getState().getClientForAccount(id) ?? client) : client; - }, [previewAttachment, client]); - const handlePreviewAttachmentDownload = useCallback(async () => { - const c = previewBlobClient(); + const c = previewAttachment?.client; if (!c || !previewAttachment) return; await c.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId); - }, [previewBlobClient, previewAttachment]); + }, [previewAttachment]); const getPreviewAttachmentContent = useCallback(async () => { - const c = previewBlobClient(); + const c = previewAttachment?.client; if (!c || !previewAttachment) { throw new Error('No attachment selected'); } @@ -3295,7 +3277,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { blob, contentType: previewAttachment.type || blob.type || 'application/octet-stream', }; - }, [previewBlobClient, previewAttachment]); + }, [previewAttachment]); const handleQuickReply = async (body: string) => { if (!client || !selectedEmail) return; @@ -3525,40 +3507,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Fetch the full content try { - // Emails fetched through an aggregate view carry their source reference: - // the login they are reachable through (`sourceClientAccountId`) and their - // owning JMAP account (`sourceAccountId`). Honor the stamps whenever they - // are present — not only while `isUnifiedView` is set, since a stamped list - // can outlive that flag — so the fetch goes to the server that actually owns - // the mail. Works uniformly for personal and shared/group sources: for - // personal the owning account equals the client's primary (no-op). (#281) - // - // Unstamped emails belong to the account being browsed: the per-account - // sidebar's `viewingAccountId` when set, otherwise the active login. A - // shared folder on that client still needs its owner accountId. (#847) - const sourceClientId = listEmail?.sourceClientAccountId; - const sourceClient = sourceClientId - ? useAuthStore.getState().getClientForAccount(sourceClientId) - : undefined; - if (sourceClientId && !sourceClient) { - // No silent fallback to the active client: with colliding ids that would - // render another account's mail under this row. - console.warn('[mail-app] No connected client for source account', sourceClientId); + const { client: fetchClient, accountId, clientAccountId } = resolveEmailBlobContext(listEmail ?? null, client); + if (!fetchClient) { + console.warn('[mail-app] No connected client for email source'); return; } - const fetchClient = sourceClient - ?? (viewingAccountId ? useAuthStore.getState().getClientForAccount(viewingAccountId) : undefined) - ?? client; - - // During an unscoped search the hit is the primary account's, not the - // selected shared folder's owner - see resolveUnstampedEmailAccountId. (#923) - const accountId = listEmail?.sourceAccountId - ?? resolveUnstampedEmailAccountId({ - mailboxes: viewMailboxes, - selectedMailbox, - searchActive: !!searchQuery || !isFilterEmpty(searchFilters), - searchMailboxId, - }); const fullEmail = await fetchClient.getEmail(email.id, accountId); if (fullEmail) { @@ -3575,9 +3528,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (listEmail?.sourceClientAccountId) { fullEmail.accountId = listEmail.accountId; fullEmail.accountLabel = listEmail.accountLabel; - fullEmail.sourceClientAccountId = listEmail.sourceClientAccountId; - fullEmail.sourceAccountId = listEmail.sourceAccountId; + fullEmail.sourceFolder = listEmail.sourceFolder; } + // Keep a direct shared folder's owner after navigation as well. + fullEmail.sourceAccountId = listEmail?.sourceAccountId ?? accountId; + fullEmail.sourceClientAccountId = listEmail?.sourceClientAccountId ?? (accountId ? clientAccountId : undefined); selectEmail(fullEmail); // Mark-as-read logic is now handled by useEffect } @@ -4521,6 +4476,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { | null, + passedClient: IJMAPClient | null, +): { client: IJMAPClient | null; accountId?: string; clientAccountId?: string } { + const state = useEmailStore.getState(); + const auth = useAuthStore.getState(); + const clientAccountId = email?.sourceClientAccountId ?? state.viewingAccountId ?? undefined; + // A stamped login must never fall back to another account: blob ids can collide. + const client = clientAccountId + ? auth.getClientForAccount(clientAccountId) ?? null + : passedClient; + const accountId = email?.sourceAccountId ?? resolveUnstampedEmailAccountId({ + mailboxes: mailboxesInView(state), + selectedMailbox: state.selectedMailbox, + searchActive: !!state.searchQuery || !isFilterEmpty(state.searchFilters), + searchMailboxId: state.searchMailboxId, + }); + return { + client, + accountId, + clientAccountId: clientAccountId ?? (client === auth.client ? auth.activeAccountId ?? undefined : undefined), + }; +} + // List requests can finish after navigation. Never apply an old folder/tag // response (or error) to the view the user has since selected. function captureEmailListView(): () => boolean { @@ -2415,21 +2440,22 @@ export const useEmailStore = create((set, get) => ({ // cross-account) the selected mailbox is virtual, so derive the client + // accountId from the email itself (handles shared/group accounts); fall // back to the selected-mailbox shared-folder logic for normal views. - const listEmail = get().emails.find(e => e.id === emailId); - let actionClient: IJMAPClient; - let accountId: string | undefined; - if (listEmail) { - ({ client: actionClient, accountId } = resolveEmailActionContext(listEmail, client)); - } else { - const mailbox = resolveActionMailboxes().find(mb => mb.id === get().selectedMailbox); - actionClient = resolveActionClient(client); - accountId = mailbox?.isShared ? mailbox.accountId : undefined; - } + const selected = get().selectedEmail; + const listEmail = selected?.id === emailId ? selected : get().emails.find(e => e.id === emailId); + const { client: actionClient, accountId, clientAccountId } = resolveEmailBlobContext(listEmail ?? null, client); + if (!actionClient) throw new Error('No connected client for email source'); const email = await actionClient.getEmail(emailId, accountId); if (email) { const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId); + // Email/get does not return client-only source metadata. Keep the source + // captured before the await, including automatic selection after actions. + annotatedEmail.accountId = listEmail?.accountId; + annotatedEmail.accountLabel = listEmail?.accountLabel; + annotatedEmail.sourceFolder = listEmail?.sourceFolder; + annotatedEmail.sourceClientAccountId = listEmail?.sourceClientAccountId ?? (accountId ? clientAccountId : undefined); + annotatedEmail.sourceAccountId = listEmail?.sourceAccountId ?? accountId; set({ selectedEmail: annotatedEmail }); return annotatedEmail; }