diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 9c69bd96e..d24d4e61d 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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'); diff --git a/components/pro/__tests__/pro-email-reply-account.test.tsx b/components/pro/__tests__/pro-email-reply-account.test.tsx new file mode 100644 index 000000000..d0d514717 --- /dev/null +++ b/components/pro/__tests__/pro-email-reply-account.test.tsx @@ -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 | null })); + +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: (props: Record) => { + 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(), + useProMultiAccountIdentities: () => ({ enabled: true, groups: [], allIdentities: [] }), +})); + +const { openComposeTab } = vi.hoisted(() => ({ openComposeTab: vi.fn() })); + +function selectorStore(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: '' } }, + 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(); + 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; +} + +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(); + + expect(composeFrom('onReply').accountId).toBe('login-b'); + }); +}); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 024742440..bc8d0fbfc 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -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,