From 0b917d6262cd215acf3a1a12869dceb4ae76bbf4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:03:50 +0100 Subject: [PATCH] fix: mark the replied, forwarded or receipted message as answered in its own account, not another account's message with the same id --- .../__tests__/viewer-send-failures.test.tsx | 16 +++ components/email/email-viewer.tsx | 12 +- .../mail-app-source-routing.test.tsx | 33 +++++- components/mail/mail-app.tsx | 11 +- .../__tests__/pro-compose-tab-source.test.tsx | 105 ++++++++++++++++++ .../__tests__/pro-email-edit-draft.test.tsx | 33 ++++++ components/pro/pro-compose-tab-body.tsx | 6 +- components/pro/pro-email-tab-body.tsx | 21 +++- ...email-store-shared-folder-keywords.test.ts | 20 ++++ stores/email-store.ts | 22 +++- stores/pro-tab-store.ts | 3 + 11 files changed, 261 insertions(+), 21 deletions(-) create mode 100644 components/pro/__tests__/pro-compose-tab-source.test.tsx diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx index 2d0f66e30..fa19d2702 100644 --- a/components/email/__tests__/viewer-send-failures.test.tsx +++ b/components/email/__tests__/viewer-send-failures.test.tsx @@ -5,6 +5,7 @@ import { NoLongerScheduledError, SubmissionQuotaError } from '@/lib/jmap/client' import { toast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useSettingsStore } from '@/stores/settings-store'; +import { useEmailStore } from '@/stores/email-store'; // Sending from the viewer - quick reply, "Send now" / "Reschedule" of a // scheduled message, and read receipts - used to fail silently or as an @@ -210,5 +211,20 @@ describe('viewer send failures', () => { }); await waitFor(() => expect(sendReadReceipt).toHaveBeenCalledTimes(2)); }); + + // A group's message with an id the own account uses too: $MDNSent goes + // to the login and account the message was read in. + it('flags the message in the login and account it was read in', async () => { + sendReadReceipt.mockResolvedValue(undefined); + const markEmailKeyword = vi.fn().mockResolvedValue(undefined); + useEmailStore.setState({ markEmailKeyword } as never); + const client = useAuthStore.getState().client; + const blobSource = { client, clientAccountId: 'login-a', accountId: 'ce' }; + + render(); + + await waitFor(() => expect(markEmailKeyword).toHaveBeenCalled()); + expect(markEmailKeyword).toHaveBeenCalledWith(client, 'e1', '$mdnsent', expect.objectContaining({ clientAccountId: 'login-a', accountId: 'ce' })); + }); }); }); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index dd8abeb78..321321c22 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -111,7 +111,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query"; import { useIsPaneScoped } from "@/hooks/use-pane-context"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; -import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext } from "@/stores/email-store"; +import { useEmailStore, resolveEmailBlobContext, emailWriteSource, type EmailBlobContext } from "@/stores/email-store"; import { useThemeStore } from "@/stores/theme-store"; import { EmailIdentityBadge } from "./email-identity-badge"; import { UnsubscribeBanner } from "./unsubscribe-banner"; @@ -3533,6 +3533,8 @@ export function EmailViewer({ const sendReadReceiptNow = useCallback(async (automatic: boolean) => { if (!client || !email || !readReceiptRequestedBy || !receiptIdentity) return; + // Taken now: another folder may be open once the receipt is out. + const source = blobSource ?? emailWriteSource(email, client); const { rawId } = stripCrossAccountIdentityPrefix(receiptIdentity.id); try { await client.sendReadReceipt({ @@ -3559,21 +3561,21 @@ export function EmailViewer({ // The receipt is out. Failing to flag that must not read as a failed // send, nor let the "always" mode send it again. try { - await useEmailStore.getState().markEmailKeyword(client, email.id, '$mdnsent'); + await useEmailStore.getState().markEmailKeyword(client, email.id, '$mdnsent', source); } catch (err) { console.error('Read receipt sent, but setting $MDNSent failed:', err); setMdnHandledLocally(true); } - }, [client, email, readReceiptRequestedBy, receiptIdentity, t, tComposer]); + }, [client, email, blobSource, readReceiptRequestedBy, receiptIdentity, t, tComposer]); const ignoreReadReceipt = useCallback(async () => { setMdnHandledLocally(true); if (client && email) { // $MDNSent is the RFC 3503 flag every IMAP/JMAP client honours, so the // request is suppressed everywhere - not just locally. - try { await useEmailStore.getState().markEmailKeyword(client, email.id, '$mdnsent'); } catch { /* best effort */ } + try { await useEmailStore.getState().markEmailKeyword(client, email.id, '$mdnsent', blobSource ?? emailWriteSource(email, client)); } catch { /* best effort */ } } - }, [client, email]); + }, [client, email, blobSource]); // "always" mode: auto-send the MDN once when the message is opened. A // failure is not retried while the message stays open: the attempt itself diff --git a/components/mail/__tests__/mail-app-source-routing.test.tsx b/components/mail/__tests__/mail-app-source-routing.test.tsx index 838d07b00..012bdfe56 100644 --- a/components/mail/__tests__/mail-app-source-routing.test.tsx +++ b/components/mail/__tests__/mail-app-source-routing.test.tsx @@ -1,5 +1,5 @@ import React, { Suspense, lazy } from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/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'; @@ -23,13 +23,21 @@ vi.mock('next/dynamic', () => ({ default: (load: () => Promise ({ - EmailViewer: ({ onArchive, onDelete }: { onArchive?: () => void; onDelete?: () => void }) => ( + EmailViewer: ({ onArchive, onDelete, onReply }: { onArchive?: () => void; onDelete?: () => void; onReply?: () => void }) => (
+
), })); +vi.mock('@/components/email/email-composer', () => ({ + EmailComposer: ({ onSend }: { onSend?: (data: unknown) => Promise }) => ( + + ), +})); 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 })); @@ -118,6 +126,27 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe('a reply to a message of the group\'s Inbox', () => { + it('flags the group\'s message as answered after the own Inbox, with its own message of that id, was opened', async () => { + const setKeyword = vi.fn().mockResolvedValue(undefined); + client.setKeyword = setKeyword; + const sendEmail = vi.fn().mockResolvedValue({ scheduled: false }); + const unstamped = { ...groupMessage, sourceClientAccountId: undefined, sourceAccountId: undefined } as Email; + useEmailStore.setState({ selectedMailbox: 'ce:a', selectedKeyword: null, emails: [unstamped], selectedEmail: unstamped, sendEmail } as never); + + render(); + fireEvent.click(await screen.findByText('stub reply', {}, { timeout: 5000 })); + const send = await screen.findByText('stub send', {}, { timeout: 5000 }); + // The own Inbox is opened while the reply is written. + const ownMessage = { ...unstamped, subject: 'A own one', mailboxIds: { a: true } } as Email; + act(() => { useEmailStore.setState({ selectedMailbox: 'a', emails: [ownMessage], selectedEmail: null } as never); }); + fireEvent.click(send); + + await waitFor(() => expect(setKeyword).toHaveBeenCalled()); + expect(setKeyword).toHaveBeenCalledWith('eaaaaab', '$answered', 'ce'); + }); +}); + describe('the open message of a group account in a tag view', () => { it('is archived into the group\'s Archive, not the own one', async () => { render(); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 0b43a8046..ac504fe9a 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, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder } from "@/stores/email-store"; +import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder } 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"; @@ -1268,6 +1268,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { initialData: pendingDraft, prefill: composerPrefill, sourceEmailId: selectedEmail?.id ?? null, + sourceEmailSource: selectedEmail ? emailWriteSource(selectedEmail, client) : null, title, }); @@ -2072,6 +2073,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Mountain View keeps its compose window open while other mail is read // (#1229), so the open message is no longer the one being replied to. const composerSourceEmail = usePinnedPerSession(composerSessionId, selectedEmail); + // 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 handleEmailSend = async (data: { to: string[]; @@ -2160,7 +2164,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); + await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, composerSource); } catch (e) { debug.error(`Failed to set ${keyword} keyword:`, e); } @@ -3935,6 +3939,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { : undefined; const originalEmailId = selectedEmail.id; + const originalSource = emailWriteSource(selectedEmail, client); const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds; let delayedUntil: string | undefined; if (sendDelaySeconds > 0) { @@ -3982,7 +3987,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // scheduled early-return so a delayed (undo-send) reply is flagged too. (#985) { try { - await useEmailStore.getState().markEmailKeyword(client, originalEmailId, '$answered'); + await useEmailStore.getState().markEmailKeyword(client, originalEmailId, '$answered', originalSource); } catch (e) { debug.error('Failed to set $answered keyword:', e); } diff --git a/components/pro/__tests__/pro-compose-tab-source.test.tsx b/components/pro/__tests__/pro-compose-tab-source.test.tsx new file mode 100644 index 000000000..e46f3c581 --- /dev/null +++ b/components/pro/__tests__/pro-compose-tab-source.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; + +// A Pro compose tab acts on the message it answers, and on its draft, in the +// login and account they are in. Email ids repeat across accounts and +// servers, so the active login's client would act on its own namesake. + +const { activeClient, otherClient, markEmailKeyword, toastError } = vi.hoisted(() => { + const makeClient = (label: string) => ({ + label, + getAccountId: () => `jmap-${label}`, + deleteEmail: vi.fn(async () => {}), + getThreadEmails: async () => [], + }); + return { + activeClient: makeClient('account-1'), + otherClient: makeClient('account-2'), + markEmailKeyword: vi.fn(async () => {}), + toastError: vi.fn(), + }; +}); + +vi.mock('@/components/email/email-composer', () => ({ + EmailComposer: ({ onSend, onDiscardDraft }: { + onSend?: (d: Record) => Promise; + onDiscardDraft?: (id: string, draft?: { accountId?: string; localAccountId?: string }) => void; + }) => React.createElement('div', null, + React.createElement('button', { onClick: () => { void onSend?.({ to: ['c@example.org'], cc: [], bcc: [], subject: 'Re', body: 'x' }); } }, 'send'), + React.createElement('button', { onClick: () => onDiscardDraft?.('maaaaad', { accountId: 'jmap-account-2', localAccountId: 'local-2' }) }, 'discard'), + ), +})); +vi.mock('@/components/error', () => ({ + ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), + ComposerErrorFallback: () => null, +})); +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/stores/toast-store', () => ({ toast: { error: toastError, success: () => {}, warning: () => {} } })); + +const auth = vi.hoisted(() => ({ connected: new Set(['local-2']) })); +vi.mock('@/stores/auth-store', () => { + const state = { + client: activeClient, + activeAccountId: 'local-1', + getClientForAccount: (id: string) => (id === 'local-2' && auth.connected.has(id) ? otherClient : undefined), + }; + const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state); + hook.getState = () => state; + return { useAuthStore: hook }; +}); +vi.mock('@/stores/email-store', () => { + const state = { + sendEmail: async () => ({ scheduled: false }), + markEmailKeyword, + refreshCurrentMailbox: async () => {}, + fetchScheduledEmails: async () => {}, + refreshScheduledMetadata: async () => {}, + isScheduledView: false, + emails: [], + expandedThreadIds: new Set(), + threadEmailsCache: new Map(), + }; + const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state); + hook.getState = () => state; + hook.setState = () => {}; + return { useEmailStore: hook }; +}); +vi.mock('@/stores/pro-tab-store', () => { + const state = { closeTab: () => {}, updateTabTitle: () => {}, updateComposeDraft: () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state); + hook.getState = () => state; + return { useProTabStore: hook, registerProTabCloseInterceptor: () => () => {} }; +}); + +import { ProComposeTabBody } from '../pro-compose-tab-body'; + +beforeEach(() => { + markEmailKeyword.mockClear(); + toastError.mockClear(); + activeClient.deleteEmail.mockClear(); + otherClient.deleteEmail.mockClear(); + auth.connected = new Set(['local-2']); +}); + +describe('Pro compose tab', () => { + it('flags the answered message in the login and account the reply started from', async () => { + const source = { clientAccountId: 'local-2', accountId: 'jmap-group' }; + render(); + + fireEvent.click(screen.getByText('send')); + + await waitFor(() => expect(markEmailKeyword).toHaveBeenCalled()); + expect(markEmailKeyword).toHaveBeenCalledWith(activeClient, 'eaaaaab', '$answered', source); + }); + + it('discards the draft through the login that saved it', async () => { + render(); + + fireEvent.click(screen.getByText('discard')); + + await waitFor(() => expect(otherClient.deleteEmail).toHaveBeenCalledWith('maaaaad', 'jmap-account-2')); + expect(activeClient.deleteEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/components/pro/__tests__/pro-email-edit-draft.test.tsx b/components/pro/__tests__/pro-email-edit-draft.test.tsx index 245517f81..c3ebd7e3e 100644 --- a/components/pro/__tests__/pro-email-edit-draft.test.tsx +++ b/components/pro/__tests__/pro-email-edit-draft.test.tsx @@ -182,3 +182,36 @@ describe('Pro email view - editing a draft', () => { expect(openComposeTab.mock.calls[0][0].initialData).toMatchObject({ draftLocalAccountId: 'userb@v10', draftAccountId: undefined }); }); }); + +describe('Pro email view - replying and forwarding', () => { + beforeEach(() => { + viewerProps.current = null; + openComposeTab.mockClear(); + store.fetched = null; + }); + + // The answered message is flagged once the reply is sent, by then perhaps + // with another folder open that has a message with the same id. + it.each(['onReply', 'onReplyAll', 'onForward'])('names the login and account of the message (%s)', async (action) => { + const client = { getEmail: vi.fn().mockResolvedValue(DRAFT), getUsername: () => 'userb@example.org', getAccountId: () => 'e' }; + render(); + await waitFor(() => expect(viewerProps.current?.email).toBeTruthy()); + + (viewerProps.current![action] as () => void)(); + + expect(openComposeTab.mock.calls[0][0]).toMatchObject({ + sourceEmailId: 'eaaaaab', + sourceEmailSource: { clientAccountId: 'userb@v10', accountId: 'c' }, + }); + }); + + it('names the active login for a message read through it', async () => { + auth.state.client = { getEmail: vi.fn(), getUsername: () => 'userb@example.org', getAccountId: () => 'e' }; + render(); + await waitFor(() => expect(viewerProps.current?.email).toBeTruthy()); + + (viewerProps.current!.onReply as () => void)(); + + expect(openComposeTab.mock.calls[0][0].sourceEmailSource).toEqual({ clientAccountId: 'userb@v016', accountId: undefined }); + }); +}); diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index 8e89dd73e..ca7d40247 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -109,13 +109,13 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { // viewer and list reflect the action (same behaviour as inline compose). if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) { try { - await useEmailStore.getState().markEmailKeyword(client, data.sourceEmailId, '$answered'); + await useEmailStore.getState().markEmailKeyword(client, data.sourceEmailId, '$answered', data.sourceEmailSource ?? undefined); } catch (e) { debug.error('Failed to set $answered keyword:', e); } } else if (data.sourceEmailId && data.mode === 'forward') { try { - await useEmailStore.getState().markEmailKeyword(client, data.sourceEmailId, '$forwarded'); + await useEmailStore.getState().markEmailKeyword(client, data.sourceEmailId, '$forwarded', data.sourceEmailSource ?? undefined); } catch (e) { debug.error('Failed to set $forwarded keyword:', e); } @@ -148,7 +148,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { if (!submitted) throw error; toast.error(t('notifications.error_sending')); } - }, [client, sendEmail, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated, refreshCurrentMailbox]); + }, [client, sendEmail, closeTab, data.sourceEmailId, data.sourceEmailSource, data.mode, t, handleScheduledSendCreated, refreshCurrentMailbox]); const handleClose = useCallback(() => { closeTab(tabIdRef.current); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 49e83b3e2..255f9fa39 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { EmailViewer } from "@/components/email/email-viewer"; import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error"; import { useAuthStore } from "@/stores/auth-store"; -import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext } from "@/stores/email-store"; +import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { findDraftIdentityIdForLogin } from "@/lib/draft-source"; @@ -112,6 +112,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, const [email, setEmail] = useState(null); const [blobSource, setBlobSource] = useState({ client: null }); + // The login and account the message was read in, for its $answered / + // $forwarded flag once a reply or forward from here is sent. + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const writeSource = useMemo(() => ({ + clientAccountId: blobSource.clientAccountId ?? (clientOverride ? localAccountId : activeAccountId ?? undefined), + accountId: blobSource.accountId, + }), [blobSource, clientOverride, localAccountId, activeAccountId]); // A reader owns its load, including an in-flight one across account switches. // Only a different message or explicit tab context starts a new load. const loadRef = useRef<{ @@ -214,10 +221,11 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, mode: 'reply', replyTo: buildReplyContext(email), sourceEmailId: email.id, + sourceEmailSource: writeSource, initialDraftText: draftText, title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')), }, { source: email, body: draftText ?? '' }); - }, [email, openComposeTab, t]); + }, [email, openComposeTab, t, writeSource]); const handleReplyAll = useCallback(() => { if (!email) return; @@ -225,9 +233,10 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, mode: 'replyAll', replyTo: buildReplyContext(email), sourceEmailId: email.id, + sourceEmailSource: writeSource, title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')), }, { source: email }); - }, [email, openComposeTab, t]); + }, [email, openComposeTab, t, writeSource]); const handleForward = useCallback(() => { if (!email) return; @@ -235,9 +244,10 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, mode: 'forward', replyTo: buildReplyContext(email), sourceEmailId: email.id, + sourceEmailSource: writeSource, title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')), }, { source: email }); - }, [email, openComposeTab, t]); + }, [email, openComposeTab, t, writeSource]); // Mirrors handleForward, but attaches the original as a message/rfc822 // file instead of quoting it inline - see lib/forward-as-attachment.ts. @@ -271,6 +281,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, attachments: [payload.attachment], }, sourceEmailId: email.id, + sourceEmailSource: writeSource, // payload.subject is intentionally blank for a subject-less email (to // match normal Forward's *composer* subject behavior - see // buildForwardAsAttachmentPayload). The Pro tab *title* is a separate @@ -279,7 +290,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, // empty title instead of e.g. "Fwd: New message". title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')), }, { subject: payload.subject }); - }, [email, openComposeTab, t]); + }, [email, openComposeTab, t, writeSource]); const handleDelete = useCallback(async () => { if (!client || !email) return; diff --git a/stores/__tests__/email-store-shared-folder-keywords.test.ts b/stores/__tests__/email-store-shared-folder-keywords.test.ts index 9e2c6b7d7..db52d3b65 100644 --- a/stores/__tests__/email-store-shared-folder-keywords.test.ts +++ b/stores/__tests__/email-store-shared-folder-keywords.test.ts @@ -168,6 +168,26 @@ describe('non-unified shared-folder keyword routing', () => { expect(activeClient.setKeyword).toHaveBeenCalledWith('e1', '$answered', 'owner-x'); }); + it('markEmailKeyword writes to the login and account the reply started from, not a namesake in the open folder', async () => { + // The reply was written to the group's e1; by the time it is sent the + // own Inbox is open, and the own account has an e1 too. + useEmailStore.setState({ + selectedMailbox: 'a-inbox', + emails: [makeEmail({ id: 'e1', mailboxIds: { 'a-inbox': true } })], + } as never); + + await useEmailStore.getState().markEmailKeyword(activeClient, 'e1', '$answered', { clientAccountId: 'account-a', accountId: 'owner-x' }); + + expect(activeClient.setKeyword).toHaveBeenCalledWith('e1', '$answered', 'owner-x'); + }); + + it('markEmailKeyword writes nothing when the login the reply started from is gone', async () => { + await expect(useEmailStore.getState().markEmailKeyword(activeClient, 'e1', '$answered', { clientAccountId: 'account-b', accountId: 'owner-x' })) + .rejects.toThrow('Not connected'); + + expect(activeClient.setKeyword).not.toHaveBeenCalled(); + }); + it('routes a keyword write for an email that is only the selected email', async () => { // The read-receipt ($mdnsent) path acts on the open message, which is not // necessarily in the current `emails` page. diff --git a/stores/email-store.ts b/stores/email-store.ts index c012cfcbb..0aafd8ab4 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -426,7 +426,12 @@ interface EmailStore { * is left as the server has it. */ patchEmailKeywords: (client: IJMAPClient, emailId: string, changes: Record, emailKey?: string) => Promise; - markEmailKeyword: (client: IJMAPClient, emailId: string, keyword: string) => Promise; + /** + * `source`: the login and account the message was read in, taken where the + * reply, forward or receipt started. By the time it is sent another folder + * may be open, with another account's message under the same id. + */ + markEmailKeyword: (client: IJMAPClient, emailId: string, keyword: string, source?: EmailWriteSource) => Promise; /** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */ setEmailKeywordsLocal: (emailId: string, keywords: Record, emailKey?: string) => void; @@ -1195,6 +1200,9 @@ export interface EmailBlobContext { clientAccountId?: string; } +/** The login and JMAP account (undefined: the login's own) a message is in. */ +export type EmailWriteSource = Pick; + export function resolveEmailBlobContext( email: Pick | null, passedClient: IJMAPClient | null, @@ -1219,6 +1227,12 @@ export function resolveEmailBlobContext( }; } +/** The login and account of a row in the view on screen now (resolveEmailBlobContext). */ +export function emailWriteSource(email: Pick, passedClient: IJMAPClient | null): EmailWriteSource { + const { clientAccountId, accountId } = resolveEmailBlobContext(email, passedClient); + return { clientAccountId, accountId }; +} + // List requests can finish after navigation. Never apply an old folder/tag // response (or error) to the view the user has since selected. /** @@ -5001,7 +5015,7 @@ export const useEmailStore = create((set, get) => ({ return true; }, - markEmailKeyword: async (client, emailId, keyword) => { + markEmailKeyword: async (client, emailId, keyword, source) => { // Single-flag counterpart of setEmailKeywords ($answered / $forwarded / // $mdnsent). Same routing, same reason: an unrouted write against the // reaching client's primary account is a silent no-op on a shared/group @@ -5010,7 +5024,9 @@ export const useEmailStore = create((set, get) => ({ && !(await emailHooks.onBeforeEmailKeywordChange.intercept({ emailIds: [emailId], added: [keyword], removed: [] }))) { return; } - const { client: actionClient, accountId } = resolveKeywordActionContext(emailId, client); + const { client: actionClient, accountId } = source?.clientAccountId + ? { client: stampedClient(source.clientAccountId), accountId: source.accountId } + : resolveKeywordActionContext(emailId, client); await actionClient.setKeyword(emailId, keyword, accountId); void emailHooks.onEmailKeywordChange.emit({ emailIds: [emailId], added: [keyword], removed: [] }); }, diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index 3228b3f96..1cde7fcea 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { ComposerDraftData } from '@/components/email/email-composer'; import type { ComposePrefill } from '@/lib/plugin-compose-gate'; +import type { EmailWriteSource } from '@/stores/email-store'; import type { SearchScope } from '@/lib/global-search/query-parser'; export type ProTabKind = @@ -60,6 +61,8 @@ export interface ProComposeTabData { /** Fields a plugin filled in through onBeforeCompose; dropped on the first state save. */ prefill?: ComposePrefill | null; sourceEmailId?: string | null; + /** The login and account of that message, for its $answered / $forwarded flag. */ + sourceEmailSource?: EmailWriteSource | null; title: string; }