mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
fix: route shared-account blob downloads by message owner
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user