fix: reply and forward from the account holding the message in Pro tabs #1511

This commit is contained in:
Linus Rath
2026-11-03 22:06:21 +01:00
parent b8cb12174c
commit 7040a6a049
3 changed files with 155 additions and 5 deletions
+2
View File
@@ -3172,6 +3172,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
attachments: [payload.attachment],
headers: labelSourceHeaders(email.headers),
originalFrom: email.from,
// The login holding the message, whose blob this attachment is.
accountId: email.sourceClientAccountId ?? viewingAccountId ?? undefined,
},
});
setComposerMode('forward');
@@ -0,0 +1,137 @@
import { render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
/**
* A reply, forward or forward-as-attachment from a Pro message view names the
* login holding the message. The composer defaults its From to that account and
* copies the original's parts from it; without the login it takes the message
* for the active account's, so the reply goes out from the wrong account, or
* fails on a blobId that account doesn't have (#1511).
*/
const viewerProps = vi.hoisted(() => ({ current: null as Record<string, unknown> | null }));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: (props: Record<string, unknown>) => {
viewerProps.current = props;
return null;
},
}));
vi.mock('@/components/error', () => ({
ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
EmailViewerErrorFallback: () => null,
}));
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } }));
vi.mock('@/hooks/use-pro-multi-account-identities', async (importOriginal) => ({
...await importOriginal<typeof import('@/hooks/use-pro-multi-account-identities')>(),
useProMultiAccountIdentities: () => ({ enabled: true, groups: [], allIdentities: [] }),
}));
const { openComposeTab } = vi.hoisted(() => ({ openComposeTab: vi.fn() }));
function selectorStore<T extends object>(state: T) {
const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state);
hook.getState = () => state;
return hook;
}
const auth = vi.hoisted(() => ({
state: { client: null as unknown, activeAccountId: 'login-a', getClientForAccount: () => undefined },
}));
vi.mock('@/stores/auth-store', () => ({ useAuthStore: selectorStore(auth.state) }));
const MESSAGE = vi.hoisted(() => ({
id: 'eaaaaab',
blobId: 'blob-of-message',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
from: [{ email: 'sender@example.com', name: 'Sender' }],
to: [{ email: 'b@example.org' }],
subject: 'Hello',
receivedAt: '2026-01-01T00:00:00Z',
bodyValues: { '1': { value: '<img src="cid:logo">' } },
textBody: [],
htmlBody: [{ partId: '1', type: 'text/html' }],
attachments: [{ blobId: 'blob-of-logo', name: 'logo.png', type: 'image/png', size: 10, cid: 'logo', disposition: 'inline' }],
}));
vi.mock('@/stores/email-store', () => ({
resolveEmailBlobContext: (_email: unknown, client: unknown) => ({ client, clientAccountId: auth.state.activeAccountId }),
useEmailStore: selectorStore({
fetchEmailContent: async () => MESSAGE,
deleteEmail: async () => {},
markAsRead: async () => {},
toggleStar: async () => {},
moveToMailbox: async () => {},
setEmailKeywordsLocal: () => {},
mailboxes: [],
emails: [],
selectedEmail: null,
}),
}));
vi.mock('@/stores/identity-store', () => ({ useIdentityStore: selectorStore({ identities: [] }) }));
vi.mock('@/stores/settings-store', () => ({ useSettingsStore: selectorStore({ emailDownloadTemplate: '' }) }));
vi.mock('@/stores/pro-tab-store', () => ({
useProTabStore: selectorStore({ openComposeTab, closeTab: () => {}, updateTabTitle: () => {} }),
}));
import { ProEmailView } from '../pro-email-tab-body';
const ACTIONS = ['onReply', 'onReplyAll', 'onForward', 'onForwardAsAttachment'] as const;
async function open(props: { clientAccountId?: string; accountId?: string; override?: boolean }) {
const client = { getEmail: vi.fn().mockResolvedValue(MESSAGE), getUsername: () => 'b@example.org', getAccountId: () => 'jmap-b' };
const { override = true, ...rest } = props;
if (!override) auth.state.client = client;
const view = render(<ProEmailView emailId="eaaaaab" client={override ? client as never : undefined} {...rest} onClose={vi.fn()} />);
await waitFor(() => expect(viewerProps.current?.email).toBeTruthy());
return { ...view, client };
}
function composeFrom(action: typeof ACTIONS[number]) {
openComposeTab.mockClear();
(viewerProps.current![action] as () => void)();
expect(openComposeTab).toHaveBeenCalledTimes(1);
return openComposeTab.mock.calls[0][0].replyTo as Record<string, unknown>;
}
describe('compose actions of a Pro message view', () => {
beforeEach(() => {
viewerProps.current = null;
openComposeTab.mockClear();
auth.state.activeAccountId = 'login-a';
auth.state.client = null;
});
it.each(ACTIONS)('%s of another login names that login', async (action) => {
await open({ clientAccountId: 'login-b' });
expect(composeFrom(action).accountId).toBe('login-b');
});
it.each(ACTIONS)('%s of a shared folder of another login names the login, not the owner', async (action) => {
await open({ clientAccountId: 'login-b', accountId: 'jmap-team' });
expect(composeFrom(action).accountId).toBe('login-b');
});
it.each(ACTIONS)('%s of the active login names the active login', async (action) => {
await open({ override: false });
expect(composeFrom(action).accountId).toBe('login-a');
});
it('keeps naming the login the message was read in after the active account changes', async () => {
// A message of b isn't foreign while b is active, so it carries no stamp.
auth.state.activeAccountId = 'login-b';
const { rerender, client } = await open({ clientAccountId: 'login-b' });
auth.state.activeAccountId = 'login-a';
rerender(<ProEmailView emailId="eaaaaab" client={client as never} clientAccountId="login-b" onClose={vi.fn()} />);
expect(composeFrom('onReply').accountId).toBe('login-b');
});
});
+16 -5
View File
@@ -111,7 +111,17 @@ export function stampProSource(email: Email, source: EmailBlobContext, loginId:
return { ...email, sourceClientAccountId: login, sourceAccountId: account };
}
function buildReplyContext(email: Email): ProReplyContext {
/**
* The login holding the message, for ProReplyContext.accountId: the composer
* defaults its From to that account and copies the original's parts from it
* (#1104, #1511). email.accountId can't say: it is for display, and a message
* read through client.getEmail() has none.
*/
function replyLogin(email: Email, source: EmailWriteSource): string | undefined {
return email.sourceClientAccountId ?? source.clientAccountId;
}
function buildReplyContext(email: Email, source: EmailWriteSource): ProReplyContext {
return {
from: email.from,
replyToAddresses: email.replyTo,
@@ -122,7 +132,7 @@ function buildReplyContext(email: Email): ProReplyContext {
subject: email.subject,
...getQuoteBodies(email),
receivedAt: email.receivedAt,
accountId: email.accountId,
accountId: replyLogin(email, source),
attachments: email.attachments,
messageId: email.messageId,
inReplyTo: email.inReplyTo,
@@ -285,7 +295,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
if (!email) return;
openComposeTab({
mode: 'reply',
replyTo: buildReplyContext(email),
replyTo: buildReplyContext(email, writeSource),
sourceEmailId: email.id,
sourceEmailSource: writeSource,
initialDraftText: draftText,
@@ -305,7 +315,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
if (!email) return;
openComposeTab({
mode: 'replyAll',
replyTo: buildReplyContext(email),
replyTo: buildReplyContext(email, writeSource),
sourceEmailId: email.id,
sourceEmailSource: writeSource,
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
@@ -316,7 +326,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
if (!email) return;
openComposeTab({
mode: 'forward',
replyTo: buildReplyContext(email),
replyTo: buildReplyContext(email, writeSource),
sourceEmailId: email.id,
sourceEmailSource: writeSource,
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
@@ -355,6 +365,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
attachments: [payload.attachment],
headers: labelSourceHeaders(email.headers),
originalFrom: email.from,
accountId: replyLogin(email, writeSource),
},
sourceEmailId: email.id,
sourceEmailSource: writeSource,