fix: route shared-account blob downloads by message owner

This commit is contained in:
VasilisNtovolos
2026-10-07 15:57:45 +03:00
parent ccf6bf7706
commit bb3ef6d9fc
4 changed files with 433 additions and 89 deletions
+18 -14
View File
@@ -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;
@@ -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<React.ComponentType<Record<string, unknown>>>) => {
const Component = lazy(async () => ({ default: await load() }));
return function Dynamic(props: Record<string, unknown>) {
return <Suspense fallback={null}><Component {...props} /></Suspense>;
};
} }));
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<Email['attachments']>[number]) => void;
}) => <div>{emails.map((email, i) => <div key={i}>
<button onClick={() => onEmailSelect(email)}>Select {i}</button>
<button onClick={() => onOpenAttachment(email, email.attachments![0])}>List attachment {i}</button>
</div>)}</div> }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: ({ onDownload, getFileContent }: {
onDownload: () => void; getFileContent: () => Promise<unknown>;
}) => <div><button onClick={onDownload}>Preview download</button><button onClick={() => void getFileContent()}>Preview fetch</button></div> }));
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> = {}): 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<typeof makeClient>;
let other: ReturnType<typeof makeClient>;
const asClient = (c: ReturnType<typeof makeClient>) => 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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
await waitFor(() => expect(primary.fetchBlob).toHaveBeenCalledWith('same-blob', 'inline.png', 'image/png', 'nrichmond'));
});
});
+21 -65
View File
@@ -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<Email[]>([]);
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(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<typeof client> } | 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 = {}) {
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={selectedEmail}
blobSource={resolveEmailBlobContext(selectedEmail, client)}
isLoading={isLoadingEmail}
onReply={handleReply}
onReplyAll={handleReplyAll}
+36 -10
View File
@@ -799,6 +799,31 @@ function resolveActionMailboxes(): Mailbox[] {
return mailboxesInView(useEmailStore.getState());
}
/** Resolve blobs from the message's source, or the folder being browsed. */
export function resolveEmailBlobContext(
email: Pick<Email, 'sourceClientAccountId' | 'sourceAccountId'> | 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<EmailStore>((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;
}