fix: mark the replied, forwarded or receipted message as answered in its own account, not another account's message with the same id

This commit is contained in:
Linus Rath
2026-10-26 16:11:52 +01:00
parent ea6d414e5b
commit 0b917d6262
11 changed files with 261 additions and 21 deletions
@@ -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(<EmailViewer email={makeEmail({ headers: MDN_HEADERS })} blobSource={blobSource as never} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
await waitFor(() => expect(markEmailKeyword).toHaveBeenCalled());
expect(markEmailKeyword).toHaveBeenCalledWith(client, 'e1', '$mdnsent', expect.objectContaining({ clientAccountId: 'login-a', accountId: 'ce' }));
});
});
});
+7 -5
View File
@@ -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
@@ -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<React.ComponentTy
};
} }));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: ({ onArchive, onDelete }: { onArchive?: () => void; onDelete?: () => void }) => (
EmailViewer: ({ onArchive, onDelete, onReply }: { onArchive?: () => void; onDelete?: () => void; onReply?: () => void }) => (
<div>
<button onClick={() => onArchive?.()}>stub archive</button>
<button onClick={() => onDelete?.()}>stub delete</button>
<button onClick={() => onReply?.()}>stub reply</button>
</div>
),
}));
vi.mock('@/components/email/email-composer', () => ({
EmailComposer: ({ onSend }: { onSend?: (data: unknown) => Promise<void> }) => (
<button onClick={() => { void onSend?.({ to: ['c@example.org'], cc: [], bcc: [], subject: 'Re: GRP one', body: 'Hi' }); }}>
stub send
</button>
),
}));
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(<MailApp />);
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(<MailApp />);
+8 -3
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, 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);
}
@@ -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<string, unknown>) => Promise<unknown>;
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<string>(),
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(<ProComposeTabBody tabId="t" data={{ sessionId: 1, mode: 'reply', title: 'Re', sourceEmailId: 'eaaaaab', sourceEmailSource: source }} />);
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(<ProComposeTabBody tabId="t" data={{ sessionId: 1, mode: 'compose', title: 'New' }} />);
fireEvent.click(screen.getByText('discard'));
await waitFor(() => expect(otherClient.deleteEmail).toHaveBeenCalledWith('maaaaad', 'jmap-account-2'));
expect(activeClient.deleteEmail).not.toHaveBeenCalled();
});
});
@@ -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(<ProEmailView emailId="eaaaaab" client={client as never} clientAccountId="userb@v10" accountId="c" onClose={vi.fn()} />);
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(<ProEmailView emailId="eaaaaab" onClose={vi.fn()} />);
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 });
});
});
+3 -3
View File
@@ -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);
+16 -5
View File
@@ -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<Email | null>(null);
const [blobSource, setBlobSource] = useState<EmailBlobContext>({ 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<EmailWriteSource>(() => ({
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;
@@ -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.
+19 -3
View File
@@ -426,7 +426,12 @@ interface EmailStore {
* is left as the server has it.
*/
patchEmailKeywords: (client: IJMAPClient, emailId: string, changes: Record<string, boolean>, emailKey?: string) => Promise<boolean>;
markEmailKeyword: (client: IJMAPClient, emailId: string, keyword: string) => Promise<void>;
/**
* `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<void>;
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
setEmailKeywordsLocal: (emailId: string, keywords: Record<string, boolean>, 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<EmailBlobContext, 'clientAccountId' | 'accountId'>;
export function resolveEmailBlobContext(
email: Pick<Email, 'sourceClientAccountId' | 'sourceAccountId'> | 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<Email, 'sourceClientAccountId' | 'sourceAccountId'>, 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<EmailStore>((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<EmailStore>((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: [] });
},
+3
View File
@@ -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;
}