({
- 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;
}