diff --git a/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx b/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx index 134a96da6..3844af6d5 100644 --- a/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx +++ b/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx @@ -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); }); diff --git a/components/mail/__tests__/mail-app-compose-handoff.test.tsx b/components/mail/__tests__/mail-app-compose-handoff.test.tsx new file mode 100644 index 000000000..665f2e04a --- /dev/null +++ b/components/mail/__tests__/mail-app-compose-handoff.test.tsx @@ -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; + 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 ( + <> + setSubject(e.target.value)} /> + + + ); + }, + }; +}); +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: () =>
message viewer
})); +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: () =>
message list
})); + +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; + +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(); + 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(); + 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(); + 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); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 50cd06e14..1f4c1b8c6 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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; diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index 288abf27f..f7346bac8 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -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 {