fix: flag the mail a reply answers when it crosses between a Pro tab and the standard composer

This commit is contained in:
Linus Rath
2026-11-03 14:57:35 +01:00
parent fcfae58a70
commit cc9aa64eb7
4 changed files with 200 additions and 12 deletions
@@ -140,7 +140,7 @@ describe('Pro page gate', () => {
await act(async () => {});
expect(routerReplace).toHaveBeenCalledWith('/');
expect(replace).not.toHaveBeenCalled();
expect(takeHandedOffComposeDraft()?.subject).toBe('Typed');
expect(takeHandedOffComposeDraft()?.draft.subject).toBe('Typed');
expect(useProTabStore.getState().tabs.some((t) => t.id === 'c1')).toBe(false);
});
@@ -0,0 +1,174 @@
import React 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 { 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 { handOffComposeTab, useProTabStore } from '@/stores/pro-tab-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// A reply crossing between a Pro compose tab and the standard composer answers
// the message it was started from. The message selected in the mail list can be
// another one, and it must not get the $answered flag.
const seen = vi.hoisted(() => ({ sourceEmailId: undefined as string | undefined }));
vi.mock('@/components/email/email-composer', async () => {
const { useEffect, useRef, useState } = await import('react');
return {
EmailComposer: ({ initialData, onSaveState, onSend, sourceEmailId }: {
initialData?: { subject?: string; mode?: string } | null;
onSaveState: (data: { subject: string; mode?: string }) => void;
onSend: (data: { to: string[]; cc: string[]; bcc: string[]; subject: string; body: string }) => Promise<unknown>;
sourceEmailId?: string;
}) => {
seen.sourceEmailId = sourceEmailId;
const [subject, setSubject] = useState(initialData?.subject ?? '');
const initial = useRef(subject);
const latest = useRef(subject);
latest.current = subject;
useEffect(() => () => {
if (latest.current !== initial.current) onSaveState({ subject: latest.current, mode: initialData?.mode });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<input aria-label="stub subject" value={subject} onChange={(e) => setSubject(e.target.value)} />
<button onClick={() => void onSend({ to: ['a@example.org'], cc: [], bcc: [], subject, body: 'x' })}>stub send</button>
</>
);
},
};
});
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null }));
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>message viewer</div> }));
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/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), 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: true, isDesktop: false }),
useMediaQuery: () => false,
useIsDesktop: () => false,
atLeast: (px: number) => `(width >= ${px}px)`,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 1, unreadEmails: 0 } as Mailbox;
const messageB = { id: 'b', threadId: 't-b', subject: 'B', receivedAt: '2026-10-31T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email;
const SOURCE_OF_A = { clientAccountId: 'login-a', accountId: 'shared' };
let markEmailKeyword: ReturnType<typeof vi.fn>;
function handOffReply(composeData: { sourceEmailId?: string | null; sourceEmailSource?: typeof SOURCE_OF_A | null }) {
useProTabStore.setState({
tabs: [
{ id: 'home-mail', kind: 'mail', labelKey: 'mail', closeable: false, paneId: 'main' },
{
id: 'c1', kind: 'compose', labelKey: '', closeable: true, paneId: 'main',
composeData: { sessionId: 1, mode: 'reply', title: 'Re: A', initialData: { subject: 'Re: A', mode: 'reply' } as never, ...composeData },
},
],
activeTabId: 'c1',
} as never);
handOffComposeTab('c1');
}
beforeEach(() => {
seen.sourceEmailId = undefined;
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} })));
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
const client = {
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
getClientForAccount: () => client as never,
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', proInterface: false } as never);
markEmailKeyword = vi.fn().mockResolvedValue(undefined);
useEmailStore.setState({
emails: [messageB], selectedEmail: messageB, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
selectedEmailKeys: new Set(), unreadSection: null,
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),
fetchEmailContent: vi.fn().mockResolvedValue(undefined),
sendEmail: vi.fn().mockResolvedValue({ scheduled: false }),
refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined),
markEmailKeyword,
} as never);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('a reply handed from a Pro compose tab to the standard composer', () => {
it('flags the message it answers, not the one selected in the list', async () => {
handOffReply({ sourceEmailId: 'a', sourceEmailSource: SOURCE_OF_A });
render(<MailApp />);
await screen.findByLabelText('stub subject');
expect(seen.sourceEmailId).toBe('a');
fireEvent.click(screen.getByText('stub send'));
await waitFor(() => expect(markEmailKeyword).toHaveBeenCalled());
expect(markEmailKeyword).toHaveBeenCalledTimes(1);
expect(markEmailKeyword).toHaveBeenCalledWith(expect.anything(), 'a', '$answered', SOURCE_OF_A);
});
it('flags nothing when the tab did not know its message', async () => {
handOffReply({});
render(<MailApp />);
await screen.findByLabelText('stub subject');
expect(seen.sourceEmailId).toBeUndefined();
fireEvent.click(screen.getByText('stub send'));
await waitFor(() => expect(useEmailStore.getState().sendEmail).toHaveBeenCalled());
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
expect(markEmailKeyword).not.toHaveBeenCalled();
});
it('takes its message along when the window widens back into Pro', async () => {
handOffReply({ sourceEmailId: 'a', sourceEmailSource: SOURCE_OF_A });
const view = render(<MailApp />);
fireEvent.change(await screen.findByLabelText('stub subject'), { target: { value: 'Re: A, typed' } });
useSettingsStore.setState({ proInterface: true } as never);
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} })));
view.unmount();
const tab = useProTabStore.getState().tabs.find((t) => t.kind === 'compose');
expect(tab?.composeData?.initialData?.subject).toBe('Re: A, typed');
expect(tab?.composeData?.sourceEmailId).toBe('a');
expect(tab?.composeData?.sourceEmailSource).toEqual(SOURCE_OF_A);
});
});
+16 -6
View File
@@ -37,7 +37,7 @@ import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
import { messagePriorityFromHeaders, type MessagePriority } from "@/lib/message-priority";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore, annotateScheduledEmails, annotateScheduledEmail, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder, restoreBatchesFor, restoreBatchesForSelection, TrashMailboxNotFoundError, type EmailRestoreBatch , type CrossAccountMoveReceipt } from "@/stores/email-store";
import { useEmailStore, annotateScheduledEmails, annotateScheduledEmail, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, type EmailWriteSource, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder, restoreBatchesFor, restoreBatchesForSelection, TrashMailboxNotFoundError, type EmailRestoreBatch , type CrossAccountMoveReceipt } from "@/stores/email-store";
import { findDraftIdentityIdForLogin, listRowSource, resolveDraftSource, type DraftSource } from "@/lib/draft-source";
import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
@@ -265,6 +265,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// its edits over as it unmounts, too late for the one already mounting there,
// so that one is mounted once more with them.
const [composerRemount, setComposerRemount] = useState(0);
// A draft carried over from a Pro compose tab answers the message that tab was
// opened from, whatever is selected here. Holds for the session it arrived in.
const [handedOffSource, setHandedOffSource] = useState<{ session: number; emailId?: string; source?: EmailWriteSource } | null>(null);
const composerSessionRef = useRef(composerSessionId);
composerSessionRef.current = composerSessionId;
const composerHostRef = useRef('pane');
@@ -1441,9 +1444,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// with its draft here. And the way back further down, in onSaveState.
useEffect(() => {
if (isEmbedded) return;
const draft = takeHandedOffComposeDraft();
if (!draft) return;
const handedOff = takeHandedOffComposeDraft();
if (!handedOff) return;
const { draft, sourceEmailId, sourceEmailSource } = handedOff;
setComposerSessionId((id) => id + 1);
setHandedOffSource({ session: composerSessionId + 1, emailId: sourceEmailId, source: sourceEmailSource });
setPendingDraft(draft);
setComposerMode(draft.mode);
setShowComposer(true);
@@ -2228,6 +2233,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Its login and account, pinned too: the folder open at send time may hold
// another account's message under the same id.
const composerSource = usePinnedPerSession(composerSessionId, selectedEmail ? emailWriteSource(selectedEmail, client) : undefined);
const handedOffToSession = handedOffSource?.session === composerSessionId ? handedOffSource : null;
const answeredEmailId = handedOffToSession ? handedOffToSession.emailId : composerSourceEmail?.id;
const answeredEmailSource = handedOffToSession ? handedOffToSession.source : composerSource;
const handleEmailSend = async (data: {
to: string[];
@@ -2278,7 +2286,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
try {
const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = composerSourceEmail?.id;
const originalEmailId = answeredEmailId;
const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, composerSendOptions(data));
submitted = true;
@@ -2319,7 +2327,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) {
const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered';
try {
await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, composerSource);
await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, answeredEmailSource);
} catch (e) {
debug.error(`Failed to set ${keyword} keyword:`, e);
}
@@ -4925,7 +4933,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
initialData={pendingDraft}
prefill={composerPrefill}
sessionKey={`inline-${composerSessionId}`}
sourceEmailId={composerSourceEmail?.id}
sourceEmailId={answeredEmailId}
onSaveState={(data) => {
setComposerPrefill(null);
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
@@ -4940,6 +4948,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
mode: data.mode,
replyTo: data.replyTo,
initialData: data,
sourceEmailId: answeredEmailId ?? null,
sourceEmailSource: answeredEmailSource ?? null,
title: data.subject?.trim() || t('email_composer.new_message'),
});
return;
+9 -5
View File
@@ -248,15 +248,19 @@ export function handOffComposeTab(id: string): void {
handedOffComposeTabId = id;
}
/** The handed-off tab's draft, closing the tab. Null when there is none, or it was never edited. */
export function takeHandedOffComposeDraft(): ComposerDraftData | null {
/**
* The handed-off tab's draft and the message it answers, closing the tab. Null
* when there is none, or it was never edited.
*/
export function takeHandedOffComposeDraft(): { draft: ComposerDraftData; sourceEmailId?: string; sourceEmailSource?: EmailWriteSource } | null {
const id = handedOffComposeTabId;
handedOffComposeTabId = null;
if (!id) return null;
const { tabs, closeTab } = useProTabStore.getState();
const draft = tabs.find((tab) => tab.id === id)?.composeData?.initialData ?? null;
if (draft) closeTab(id);
return draft;
const data = tabs.find((tab) => tab.id === id)?.composeData;
if (!data?.initialData) return null;
closeTab(id);
return { draft: data.initialData, sourceEmailId: data.sourceEmailId ?? undefined, sourceEmailSource: data.sourceEmailSource ?? undefined };
}
function makeId(): string {