mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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: [] });
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user