mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: show a sent reply in the open conversation at once, keep it out of Sent and Scheduled during Undo, and bring an undone reply back where it was written
This commit is contained in:
@@ -63,6 +63,22 @@ describe('quick reply', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// mail-p2-8: Undo send on a quick reply brings its text back into this box.
|
||||
describe('quick reply after Undo send', () => {
|
||||
it('takes the undone text back into the box of the message it answered', async () => {
|
||||
const { useQuickReplyRestoreStore } = await import('@/stores/quick-reply-restore-store');
|
||||
useQuickReplyRestoreStore.setState({ pending: null });
|
||||
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
const box = screen.getByPlaceholderText('quick_reply_to') as HTMLTextAreaElement;
|
||||
act(() => { useQuickReplyRestoreStore.getState().restore('other', 'Not for this one'); });
|
||||
expect(box.value).toBe('');
|
||||
act(() => { useQuickReplyRestoreStore.getState().restore('m1', 'My answer'); });
|
||||
await waitFor(() => expect(box.value).toBe('My answer'));
|
||||
expect(useQuickReplyRestoreStore.getState().pending).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('automatic sign-out while the external recipient question is open', () => {
|
||||
it('answers Back and keeps the typed reply as a draft, without asking again', async () => {
|
||||
const { useExternalRecipientConfirmStore } = await import('@/stores/external-recipient-confirm-store');
|
||||
|
||||
@@ -385,7 +385,10 @@ interface EmailComposerProps {
|
||||
labelHeaders?: Record<string, string>;
|
||||
/** The id plugins know this composer by, for onAfterEmailSend. */
|
||||
composerId?: string;
|
||||
}) => void | false | Promise<void | false>;
|
||||
},
|
||||
/** The composer as it was at Send, to bring it back as it was after Undo send (mail-p2-8). */
|
||||
state?: ComposerDraftData,
|
||||
) => void | false | Promise<void | false>;
|
||||
/**
|
||||
* onSend failed. A host that closes the composer as soon as onSend starts
|
||||
* gets the message back here, to open it again: it must never vanish.
|
||||
@@ -4380,7 +4383,7 @@ export function EmailComposer({
|
||||
labelHeaders: labelled.labelHeaders,
|
||||
delayedUntil: effectiveDelayedUntil,
|
||||
composerId: pluginComposerIdRef.current ?? undefined,
|
||||
});
|
||||
}, handedToHost);
|
||||
// Not when nothing went: no host, or one that sent nothing.
|
||||
if (draftElsewhere && onSend && outcome !== false) await removeDraftVersion(finalDraftId!, draftLogin, draftAccountAtSend);
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import { EmailContextMenu } from "./email-context-menu";
|
||||
import { rememberedTrashOrigin } from "@/lib/trash-origin";
|
||||
import { useMessageActionList, type MessageActionEntry, type MessageActionKey, type MessageActionOptions, type MessageSubmenuKey } from "./message-actions";
|
||||
import { useMessageActionShortcut } from "./message-action-shortcuts";
|
||||
import { useQuickReplyRestoreStore } from "@/stores/quick-reply-restore-store";
|
||||
import { PhishingMarkBanner } from "./phishing-mark-banner";
|
||||
import { turnOffLinks } from "@/lib/phishing-links";
|
||||
import { usePhishingMarked } from "@/hooks/use-phishing-report";
|
||||
@@ -819,7 +820,7 @@ function conversationFullDate(date: Date | string, timeFormat: '12h' | '24h'): s
|
||||
export function conversationOpenPlan(conversation: Email[], openId: string): { expanded: string[]; foldOpen: boolean; newestId: string | null } {
|
||||
const expanded = conversation.filter((e) => e.id !== openId && !e.keywords?.$seen).map((e) => e.id);
|
||||
const open = conversation.find((e) => e.id === openId);
|
||||
const candidates = conversation.filter((e) => !e.keywords?.$draft && !e.isScheduled);
|
||||
const candidates = conversation.filter((e) => !e.keywords?.$draft && !e.isScheduled && !e.sentEcho);
|
||||
const newest = candidates[candidates.length - 1];
|
||||
if (!open || !newest || newest.id === openId) return { expanded, foldOpen: false, newestId: null };
|
||||
if (!expanded.includes(newest.id)) expanded.push(newest.id);
|
||||
@@ -1646,6 +1647,17 @@ export function EmailViewer({
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [mvNewestToShow]);
|
||||
// A reply just sent joins the end of the conversation, folded like the
|
||||
// others: bring it into view so it's seen to be there (mail-p2-1).
|
||||
const lastEchoId = mvConversation?.filter((e) => e.sentEcho).at(-1)?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (!lastEchoId) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
document.querySelector<HTMLElement>(`[data-mv-conversation-card][data-email-id="${CSS.escape(lastEchoId)}"]`)
|
||||
?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [lastEchoId]);
|
||||
const toggleMvExpanded = useCallback((target: Email) => {
|
||||
emitThreadExpand(target.threadId, [target], !mvExpandedIdsRef.current.has(target.id));
|
||||
setMvExpandedIds((prev) => {
|
||||
@@ -2501,6 +2513,23 @@ export function EmailViewer({
|
||||
setEmbeddedEmailAttachments(NO_POSTAL_ATTACHMENTS);
|
||||
}, [email?.id, externalContentPolicy]);
|
||||
|
||||
// Undo send on a quick reply puts its text back here, where it was
|
||||
// written (mail-p2-8). After the reset above, which runs on the same open.
|
||||
const quickReplyRestoreWaiting = useQuickReplyRestoreStore((s) => !!email && s.pending?.emailId === email.id);
|
||||
useEffect(() => {
|
||||
if (!quickReplyRestoreWaiting || !email) return;
|
||||
const text = useQuickReplyRestoreStore.getState().take(email.id);
|
||||
if (text === null) return;
|
||||
setQuickReplyText(text);
|
||||
setIsQuickReplyFocused(true);
|
||||
requestAnimationFrame(() => {
|
||||
const box = quickReplyRef.current;
|
||||
if (!box) return;
|
||||
box.focus();
|
||||
box.setSelectionRange(box.value.length, box.value.length);
|
||||
});
|
||||
}, [quickReplyRestoreWaiting, email]);
|
||||
|
||||
// Crypto-plugin body takeover (S/MIME, PGP, …). A privileged crypto plugin
|
||||
// can fetch the raw message via api.jmap.fetchBlob, decrypt/verify it, and
|
||||
// return a replaced body through the onRenderEmailBody hook. The host stays
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { useQuickReplyRestoreStore } from '@/stores/quick-reply-restore-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { clearAllHooks } from '@/lib/plugin-hooks';
|
||||
import { forgetEmailPrefetches } from '@/lib/email-prefetch';
|
||||
|
||||
// mail-p2-1: a quick reply that went out shows in the open conversation at
|
||||
// once. mail-p2-8: during its Undo window it isn't counted in Sent or
|
||||
// Scheduled and the message isn't marked answered; Undo takes it out of the
|
||||
// conversation and puts the text back into the quick reply box, leaving no
|
||||
// draft behind.
|
||||
|
||||
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => {
|
||||
const Component = lazy(async () => ({ default: await load() }));
|
||||
return function Dynamic(props: Record<string, unknown>) {
|
||||
return <Suspense fallback={null}><Component {...props} /></Suspense>;
|
||||
};
|
||||
} }));
|
||||
type ViewerProps = { mvConversation?: { id: string }[]; onQuickReply?: (body: string) => Promise<boolean | void>; onReply?: () => void };
|
||||
vi.mock('@/components/email/email-viewer', () => ({
|
||||
EmailViewer: ({ mvConversation, onQuickReply, onReply }: ViewerProps) => (
|
||||
<div>
|
||||
<div data-testid="reader">{mvConversation ? mvConversation.map((e) => e.id).join(',') : 'no conversation'}</div>
|
||||
<button type="button" onClick={() => void onQuickReply?.('Thanks, see you there')}>quick send</button>
|
||||
<button type="button" onClick={() => onReply?.()}>reply</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
type ComposerProps = {
|
||||
mode: string;
|
||||
initialData?: { draftId?: string | null; body?: string } | null;
|
||||
onSend?: (data: Record<string, unknown>, state?: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
vi.mock('@/components/email/email-composer', () => ({
|
||||
EmailComposer: ({ mode, initialData, onSend }: ComposerProps) => (
|
||||
<div data-testid="composer" data-mode={mode} data-draft={initialData?.draftId ?? ''} data-body={initialData?.body ?? ''}>
|
||||
<button type="button" onClick={() => void onSend?.(
|
||||
{ to: ['sam@example.org'], cc: [], bcc: [], subject: 'Re: Lunch tomorrow?', body: 'Works for me' },
|
||||
{ mode: 'reply', body: '<p>Works for me</p>', to: 'sam@example.org', cc: '', bcc: '', subject: 'Re: Lunch tomorrow?', draftId: 'd-1', attachments: [] },
|
||||
)}>composer send</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/layout/sidebar', () => ({
|
||||
Sidebar: ({ scheduledTotal, mailboxes }: { scheduledTotal?: number; mailboxes: Mailbox[] }) => (
|
||||
<div data-testid="sidebar-counts">{`scheduled ${scheduledTotal} sent ${mailboxes.find((m) => m.role === 'sent')?.totalEmails}`}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => <input aria-label="stub search field" data-search-input /> }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }), getCachedConfig: () => null }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => true,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
function mailbox(id: string, role: string, totalEmails = 3): Mailbox {
|
||||
return {
|
||||
id, name: role, role, sortOrder: 0, totalEmails, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, isSubscribed: true,
|
||||
} as Mailbox;
|
||||
}
|
||||
const row = (id: string, minute: number, extra: Partial<Email> = {}) => ({
|
||||
id, threadId: 't-m1', subject: 'Lunch tomorrow?', receivedAt: new Date(Date.UTC(2026, 9, 31, 10, minute)).toISOString(),
|
||||
keywords: { $seen: true }, mailboxIds: { inbox: true }, from: [{ name: 'Sam', email: 'sam@example.org' }],
|
||||
to: [{ name: 'Me', email: 'a@example.org' }], ...extra,
|
||||
}) as unknown as Email;
|
||||
|
||||
let client: Record<string, ReturnType<typeof vi.fn> | (() => unknown)>;
|
||||
let sendEmail: ReturnType<typeof vi.fn>;
|
||||
let markEmailKeyword: ReturnType<typeof vi.fn>;
|
||||
let cancelUndoSend: ReturnType<typeof vi.fn>;
|
||||
let folders: Mailbox[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
useToastStore.setState({ toasts: [] });
|
||||
useQuickReplyRestoreStore.setState({ pending: null });
|
||||
clearAllHooks();
|
||||
forgetEmailPrefetches();
|
||||
folders = [mailbox('inbox', 'inbox'), mailbox('sent', 'sent', 51), mailbox('drafts', 'drafts')];
|
||||
const thread = [row('m0', 0, { mailboxIds: { inbox: true } }), row('m1', 30)];
|
||||
client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => true, getSession: () => null,
|
||||
getMailboxes: vi.fn().mockResolvedValue(folders),
|
||||
getThread: vi.fn(async (threadId: string) => ({ id: threadId, emailIds: ['m0', 'm1'] })),
|
||||
getSomeEmails: vi.fn(async (ids: string[]) => thread.filter((e) => ids.includes(e.id))),
|
||||
getEmail: vi.fn(async (id: string) => ({ ...thread.find((e) => e.id === id)!, bodyValues: {} })),
|
||||
deleteEmail: vi.fn().mockResolvedValue(undefined),
|
||||
markAsRead: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
|
||||
getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never,
|
||||
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({
|
||||
enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', disableThreading: false,
|
||||
sendDelaySeconds: 5, sendConfirmation: false, placeholderWarningEnabled: false, attachmentReminderEnabled: false,
|
||||
} as never);
|
||||
sendEmail = vi.fn(async () => {
|
||||
const sendAt = new Date(Date.now() + 5000).toISOString();
|
||||
useEmailStore.setState({
|
||||
pendingUndoSend: {
|
||||
submissionId: 'sub-1', emailId: 'sent-1', sendAt, isSmime: false,
|
||||
countsBefore: { sent: 51, scheduled: 0 },
|
||||
},
|
||||
} as never);
|
||||
// The server files it in Sent and lists its submission at once.
|
||||
useEmailStore.setState({ scheduledTotal: 1, mailboxes: folders.map((m) => m.role === 'sent' ? { ...m, totalEmails: 52 } : m) } as never);
|
||||
return { scheduled: true, emailId: 'sent-1', emailSubmissionId: 'sub-1', sendAt };
|
||||
});
|
||||
markEmailKeyword = vi.fn().mockResolvedValue(undefined);
|
||||
cancelUndoSend = vi.fn(async () => {
|
||||
useEmailStore.setState({ pendingUndoSend: null } as never);
|
||||
return { ...row('sent-1', 40), id: 'sent-1', mailboxIds: { drafts: true }, keywords: { $draft: true } };
|
||||
});
|
||||
const emails = [row('m1', 30)];
|
||||
useEmailStore.setState({
|
||||
emails, selectedEmail: emails[0], selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: folders,
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', scheduledTotal: 0, pendingUndoSend: null,
|
||||
selectedEmailKeys: new Set(), unreadSection: null, listRows: null,
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
fetchEmailContent: vi.fn().mockResolvedValue(undefined),
|
||||
sendEmail, markEmailKeyword, cancelUndoSend,
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('a quick reply in its Undo window', () => {
|
||||
it('shows in the conversation at once, counts nowhere and answers nothing yet; Undo puts it back in the box', async () => {
|
||||
render(<MailApp />);
|
||||
const reader = await screen.findByTestId('reader', {}, { timeout: 5000 });
|
||||
await waitFor(() => expect(reader).toHaveTextContent('m0,m1'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'quick send' }));
|
||||
await waitFor(() => expect(sendEmail).toHaveBeenCalled());
|
||||
await waitFor(() => expect(reader).toHaveTextContent('m0,m1,sent-1'));
|
||||
|
||||
expect(markEmailKeyword).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('sidebar-counts')).toHaveTextContent('scheduled 0 sent 51');
|
||||
|
||||
const undo = await waitFor(() => {
|
||||
const toast = useToastStore.getState().toasts.find((t) => t.action);
|
||||
expect(toast).toBeTruthy();
|
||||
return toast!;
|
||||
});
|
||||
await act(async () => { undo.action!.onClick(); });
|
||||
await waitFor(() => expect(cancelUndoSend).toHaveBeenCalled());
|
||||
await waitFor(() => expect(reader).toHaveTextContent(/^m0,m1$/));
|
||||
await waitFor(() => expect(useQuickReplyRestoreStore.getState().pending).toEqual({ emailId: 'm1', text: 'Thanks, see you there' }));
|
||||
expect(client.deleteEmail).toHaveBeenCalledWith('sent-1', undefined);
|
||||
expect(markEmailKeyword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('brings an undone reply back into the dock, as it was', async () => {
|
||||
render(<MailApp />);
|
||||
await screen.findByTestId('reader', {}, { timeout: 5000 });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reply' }));
|
||||
const composer = await screen.findByTestId('composer');
|
||||
expect(composer.closest('[data-reply-dock]')).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'composer send' }));
|
||||
await waitFor(() => expect(sendEmail).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.queryByTestId('composer')).toBeNull());
|
||||
expect(markEmailKeyword).not.toHaveBeenCalled();
|
||||
|
||||
const undo = await waitFor(() => {
|
||||
const toast = useToastStore.getState().toasts.find((t) => t.action);
|
||||
expect(toast).toBeTruthy();
|
||||
return toast!;
|
||||
});
|
||||
await act(async () => { undo.action!.onClick(); });
|
||||
const back = await screen.findByTestId('composer');
|
||||
expect(back).toHaveAttribute('data-mode', 'reply');
|
||||
expect(back).toHaveAttribute('data-draft', 'sent-1');
|
||||
expect(back).toHaveAttribute('data-body', '<p>Works for me</p>');
|
||||
expect(back.closest('[data-reply-dock]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+156
-10
@@ -11,6 +11,18 @@ import { productName } from "@/lib/product-name";
|
||||
import { MessageListTabs, useMessageListPanelProps } from "@/components/email/message-list-tabs";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { addEcho, isUndoWindow, mergeEchoes, sentEcho, withoutHeldSend } from "@/lib/held-send";
|
||||
import { useQuickReplyRestoreStore } from "@/stores/quick-reply-restore-store";
|
||||
|
||||
/** What waits with a send in its Undo window (mail-p2-8). */
|
||||
type HeldSendExtras = {
|
||||
emailId?: string;
|
||||
/** Marks the answered message; runs once the message really goes. */
|
||||
answer?: () => Promise<void>;
|
||||
origin?:
|
||||
| { kind: 'quick-reply'; emailId: string; text: string }
|
||||
| { kind: 'composer'; state: ComposerDraftData; sourceEmailId?: string };
|
||||
};
|
||||
|
||||
// Neither the viewer (5k+ lines, postal-mime/dompurify) nor the composer (the
|
||||
// entire TipTap/ProseMirror stack) is needed to paint the mail list, and both
|
||||
@@ -2627,7 +2639,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
headers?: Record<string, string>;
|
||||
labelHeaders?: Record<string, string>;
|
||||
composerId?: string;
|
||||
}) => {
|
||||
}, composerState?: ComposerDraftData) => {
|
||||
// Nothing is sent: the composer keeps what only goes once the mail is out.
|
||||
if (!client) return false;
|
||||
|
||||
@@ -2707,13 +2719,29 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// This runs before the scheduled early-return: the undo-send delay is a
|
||||
// HOLDFOR submission, so `scheduled` is true for every delayed send and
|
||||
// the flag was never set. (#985)
|
||||
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) {
|
||||
// In the Undo window the flag waits until the message really goes, and
|
||||
// Undo brings the composer back as it was, where it was (mail-p2-8).
|
||||
const answeredSource = answeredEmailSource;
|
||||
const markAnswered = async () => {
|
||||
if (!originalEmailId || !(effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) return;
|
||||
const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered';
|
||||
try {
|
||||
await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, answeredEmailSource);
|
||||
await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, answeredSource);
|
||||
} catch (e) {
|
||||
debug.error(`Failed to set ${keyword} keyword:`, e);
|
||||
}
|
||||
};
|
||||
const held = holdSend(result, {
|
||||
answer: markAnswered,
|
||||
origin: composerState ? { kind: 'composer', state: composerState, sourceEmailId: originalEmailId } : undefined,
|
||||
});
|
||||
if (!held) await markAnswered();
|
||||
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
|
||||
const replied = composerSourceEmail?.id === originalEmailId ? composerSourceEmail : null;
|
||||
echoSentReply(replied, {
|
||||
emailId: result.emailId, to: data.to, cc: data.cc, subject: data.subject, text: data.body,
|
||||
fromEmail: data.fromEmail, fromName: data.fromName,
|
||||
});
|
||||
}
|
||||
|
||||
const refreshRepliedThreadAfterSend = async (emailId: string | undefined) => {
|
||||
@@ -3033,6 +3061,69 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
);
|
||||
};
|
||||
|
||||
// A send in its Undo window (mail-p2-8): what waits until it really goes
|
||||
// (the answered flag), and where Undo puts it back. Keyed by submission.
|
||||
const heldSendsRef = useRef(new Map<string, HeldSendExtras>());
|
||||
const releaseHeldSend = (submissionId: string) => {
|
||||
const held = heldSendsRef.current.get(submissionId);
|
||||
if (!held) return;
|
||||
heldSendsRef.current.delete(submissionId);
|
||||
void held.answer?.();
|
||||
};
|
||||
/** True when the send waits in its Undo window; `extras` then wait with it. */
|
||||
const holdSend = (result: SendEmailResult, extras: Omit<HeldSendExtras, 'emailId'>): boolean => {
|
||||
if (!result.scheduled || !result.emailSubmissionId || !isUndoWindow(result.sendAt)) return false;
|
||||
const submissionId = result.emailSubmissionId;
|
||||
heldSendsRef.current.set(submissionId, { ...extras, emailId: result.emailId });
|
||||
const remainingMs = Math.max(Date.parse(result.sendAt!) - Date.now(), 0);
|
||||
setTimeout(() => releaseHeldSend(submissionId), remainingMs);
|
||||
return true;
|
||||
};
|
||||
// Undo: the message goes back where it was written, as it was.
|
||||
const restoreUndoneSend = async (
|
||||
held: HeldSendExtras | undefined,
|
||||
restored: Email | null | undefined,
|
||||
pending: { submissionAccountId?: string },
|
||||
undoLogin: string | undefined,
|
||||
undoClient: NonNullable<typeof client>,
|
||||
): Promise<boolean> => {
|
||||
const origin = held?.origin;
|
||||
if (!origin || !restored) return false;
|
||||
const openNow = useEmailStore.getState().selectedEmail;
|
||||
const reopen = async (emailId: string): Promise<boolean> => {
|
||||
if (openNow?.id === emailId) return true;
|
||||
const listed = useEmailStore.getState().emails.find((e) => e.id === emailId);
|
||||
if (!listed) return false;
|
||||
return (await handleEmailSelect(listed)) === 'opened';
|
||||
};
|
||||
if (origin.kind === 'quick-reply') {
|
||||
if (!(await reopen(origin.emailId))) return false;
|
||||
// Back in the quick reply box, so the draft the server made of it goes.
|
||||
try {
|
||||
await undoClient.deleteEmail(restored.id, pending.submissionAccountId);
|
||||
} catch (error) {
|
||||
debug.error('Undid a quick reply, but removing its draft failed:', error);
|
||||
}
|
||||
useQuickReplyRestoreStore.getState().restore(origin.emailId, origin.text);
|
||||
return true;
|
||||
}
|
||||
const state: ComposerDraftData = {
|
||||
...origin.state,
|
||||
draftId: restored.id,
|
||||
draftAccountId: pending.submissionAccountId,
|
||||
draftLocalAccountId: undoLogin,
|
||||
};
|
||||
// A reply docks under its message again; with that message gone from
|
||||
// view it opens in the pane, still as a reply.
|
||||
if (origin.sourceEmailId && state.mode !== 'compose' && !(await reopen(origin.sourceEmailId))) selectEmail(null);
|
||||
startFreshComposerSession();
|
||||
setPendingDraft(state);
|
||||
setComposerMode(state.mode);
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUndoSend || !client) return;
|
||||
if (lastUndoToastSubmissionRef.current === pendingUndoSend.submissionId) return;
|
||||
@@ -3084,6 +3175,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
pending.submissionAccountId,
|
||||
);
|
||||
clearPendingUndoSend();
|
||||
releaseHeldSend(pending.submissionId);
|
||||
// The recipients are checked again for the release.
|
||||
const refused = partlyRefusedWarning(result, tComposer);
|
||||
if (refused) toast.warning(refused);
|
||||
@@ -3121,10 +3213,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
// The send is undone; opening the draft or refreshing the list
|
||||
// failing is no reason to say otherwise.
|
||||
const held = heldSendsRef.current.get(pending.submissionId);
|
||||
heldSendsRef.current.delete(pending.submissionId);
|
||||
dropEcho(pending.emailId);
|
||||
try {
|
||||
// Never left the server: plugins don't hear about it.
|
||||
cancelAnnouncedSend(pending.submissionId);
|
||||
if (restored && !pending.isSmime) {
|
||||
if (!pending.isSmime && await restoreUndoneSend(held, restored, pending, undoLogin, undoClient)) {
|
||||
// Back where it was written.
|
||||
} else if (restored && !pending.isSmime) {
|
||||
// Back in the Drafts of the account it was sent from, through
|
||||
// the login that sent it.
|
||||
await handleEditDraft(restored, { accountId: pending.submissionAccountId, localAccountId: undoLogin });
|
||||
@@ -4644,13 +4741,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// account so the flag lands on shared/group-mailbox messages instead of
|
||||
// being dropped against the reaching account. (#281) Runs before the
|
||||
// scheduled early-return so a delayed (undo-send) reply is flagged too. (#985)
|
||||
{
|
||||
// In the Undo window it waits until the reply really goes (mail-p2-8).
|
||||
const markAnswered = async () => {
|
||||
try {
|
||||
await useEmailStore.getState().markEmailKeyword(client, originalEmailId, '$answered', originalSource);
|
||||
} catch (e) {
|
||||
debug.error('Failed to set $answered keyword:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
const held = holdSend(result, {
|
||||
answer: markAnswered,
|
||||
origin: { kind: 'quick-reply', emailId: originalEmailId, text: body },
|
||||
});
|
||||
if (!held) await markAnswered();
|
||||
echoSentReply(selectedEmail, {
|
||||
emailId: result.emailId, to: recipients, subject: labelled.subject, text: finalBody,
|
||||
fromEmail: headerFromEmail, fromName: headerFromName,
|
||||
});
|
||||
|
||||
const refreshInlineRepliedThread = async () => {
|
||||
const emailState = useEmailStore.getState();
|
||||
@@ -4779,6 +4886,43 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the open message, not on every list refresh
|
||||
}, [mvConversationThreadId, mvConversationRef?.id, mvBlobSource.client, mvBlobSource.accountId, client]);
|
||||
|
||||
// mail-p2-1: a reply that went out shows at the end of the open
|
||||
// conversation at once, then the server's reading of the thread replaces
|
||||
// it (a moment later, and again after a few seconds for a server that
|
||||
// threads new mail late).
|
||||
const confirmConversation = async (ref: Email) => {
|
||||
if (!client || useEmailStore.getState().selectedEmail?.threadId !== ref.threadId) return;
|
||||
mvConversationLoadRef.current = null;
|
||||
const fresh = await loadMvConversation(ref, client);
|
||||
if (!fresh || useEmailStore.getState().selectedEmail?.threadId !== ref.threadId) return;
|
||||
setMvConversationResult((prev) => prev && prev.threadId === ref.threadId
|
||||
? { ...prev, emails: mergeEchoes(fresh, prev.emails) }
|
||||
: prev);
|
||||
};
|
||||
const echoSentReply = (replied: Email | null | undefined, sent: {
|
||||
emailId?: string; to: string[]; cc?: string[]; subject: string; text: string; fromEmail?: string; fromName?: string;
|
||||
}) => {
|
||||
if (!replied || !client || !readerConversationView) return;
|
||||
const open = useEmailStore.getState().selectedEmail;
|
||||
if (!open || open.threadId !== replied.threadId) return;
|
||||
const echo = sentEcho({ ...sent, threadId: replied.threadId });
|
||||
setMvConversationResult((prev) => {
|
||||
if (prev && prev.threadId === replied.threadId) return { ...prev, emails: addEcho(prev.emails, echo) };
|
||||
if (prev) return prev;
|
||||
// A message alone so far: it becomes a conversation of two.
|
||||
return { emails: [open, echo], source: resolveEmailBlobContext(open, client), threadId: replied.threadId };
|
||||
});
|
||||
for (const delay of [2000, 8000]) setTimeout(() => { void confirmConversation(open); }, delay);
|
||||
};
|
||||
const dropEcho = (emailId: string | undefined) => {
|
||||
if (!emailId) return;
|
||||
setMvConversationResult((prev) => {
|
||||
if (!prev || !prev.emails.some((e) => e.id === emailId)) return prev;
|
||||
const emails = prev.emails.filter((e) => e.id !== emailId);
|
||||
return emails.length > 1 ? { ...prev, emails } : null;
|
||||
});
|
||||
};
|
||||
|
||||
const patchMvConversation = (id: string, patch: (e: Email) => Email) =>
|
||||
setMvConversationResult(prev => prev ? {
|
||||
...prev, emails: prev.emails.map(e => e.id === id ? patch(e) : e),
|
||||
@@ -4944,6 +5088,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
|
||||
: "Inbox";
|
||||
})();
|
||||
// A send in its Undo window isn't counted in Sent or Scheduled yet (mail-p2-8).
|
||||
const heldSendView = withoutHeldSend(mailboxes, scheduledTotal, pendingUndoSend);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile;
|
||||
// Under Mountain View on desktop the composer floats over the list in a
|
||||
@@ -5366,8 +5512,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
setPendingDraft(data);
|
||||
}}
|
||||
onSend={async (data) => {
|
||||
const outcome = await handleEmailSend(data);
|
||||
onSend={async (data, state) => {
|
||||
const outcome = await handleEmailSend(data, state);
|
||||
setPendingDraft(null);
|
||||
setComposerPrefill(null);
|
||||
return outcome;
|
||||
@@ -5550,10 +5696,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
>
|
||||
<ErrorBoundary fallback={SidebarErrorFallback}>
|
||||
<Sidebar
|
||||
mailboxes={mailboxes}
|
||||
mailboxes={heldSendView.mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
selectedKeyword={selectedKeyword}
|
||||
scheduledTotal={scheduledTotal}
|
||||
scheduledTotal={heldSendView.scheduledTotal}
|
||||
scheduledTotalByAccount={scheduledTotalByAccount}
|
||||
scheduledAccountScope={scheduledAccountScope}
|
||||
showScheduledMailbox={delayedSendSupported}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { addEcho, isUndoWindow, mergeEchoes, sentEcho, withoutHeldSend } from '../held-send';
|
||||
|
||||
const mb = (role: string, totalEmails: number, extra: Partial<Mailbox> = {}) =>
|
||||
({ id: role, name: role, role, totalEmails, unreadEmails: 0, ...extra }) as Mailbox;
|
||||
const now = Date.parse('2026-11-04T10:00:00Z');
|
||||
const soon = new Date(now + 5000).toISOString();
|
||||
|
||||
describe('withoutHeldSend', () => {
|
||||
it('shows Sent and Scheduled as they were before a send in its Undo window', () => {
|
||||
const view = withoutHeldSend([mb('inbox', 146), mb('sent', 52)], 1, { sendAt: soon, countsBefore: { sent: 51, scheduled: 0 } }, now);
|
||||
expect(view.scheduledTotal).toBe(0);
|
||||
expect(view.mailboxes.find((m) => m.role === 'sent')!.totalEmails).toBe(51);
|
||||
expect(view.mailboxes.find((m) => m.role === 'inbox')!.totalEmails).toBe(146);
|
||||
});
|
||||
|
||||
it('shows a count that fell meanwhile as it is', () => {
|
||||
const view = withoutHeldSend([mb('sent', 49)], 0, { sendAt: soon, countsBefore: { sent: 51, scheduled: 1 } }, now);
|
||||
expect(view.mailboxes[0].totalEmails).toBe(49);
|
||||
expect(view.scheduledTotal).toBe(0);
|
||||
});
|
||||
|
||||
it('counts a mail scheduled for later at once', () => {
|
||||
const tomorrow = new Date(now + 86_400_000).toISOString();
|
||||
const view = withoutHeldSend([mb('sent', 52)], 1, { sendAt: tomorrow, countsBefore: { sent: 51, scheduled: 0 } }, now);
|
||||
expect(view.scheduledTotal).toBe(1);
|
||||
expect(view.mailboxes[0].totalEmails).toBe(52);
|
||||
});
|
||||
|
||||
it('leaves a shared Sent folder alone', () => {
|
||||
const view = withoutHeldSend([mb('sent', 9, { isShared: true })], 0, { sendAt: soon, countsBefore: { sent: 5, scheduled: 0 } }, now);
|
||||
expect(view.mailboxes[0].totalEmails).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUndoWindow', () => {
|
||||
it('is two minutes at most', () => {
|
||||
expect(isUndoWindow(soon, now)).toBe(true);
|
||||
expect(isUndoWindow(new Date(now + 121_000).toISOString(), now)).toBe(false);
|
||||
expect(isUndoWindow(undefined, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('echoes of sent replies', () => {
|
||||
const server = [{ id: 'a' }, { id: 'b' }] as Email[];
|
||||
const echo = sentEcho({ emailId: 'c', threadId: 't', to: ['sam@example.org'], subject: 'Re: x', text: 'Thanks', fromEmail: 'me@example.org' });
|
||||
|
||||
it('is read, carries its text and the server id', () => {
|
||||
expect(echo.id).toBe('c');
|
||||
expect(echo.keywords.$seen).toBe(true);
|
||||
expect(Object.values(echo.bodyValues!)[0].value).toBe('Thanks');
|
||||
expect(echo.sentEcho).toBe(true);
|
||||
});
|
||||
|
||||
it('is added once', () => {
|
||||
expect(addEcho(addEcho(server, echo), echo).map((e) => e.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('gives way to the server copy, and stays while the server lacks it', () => {
|
||||
expect(mergeEchoes([...server, { id: 'c' } as Email], [...server, echo]).map((e) => (e.sentEcho ? 'echo' : e.id))).toEqual(['a', 'b', 'c']);
|
||||
expect(mergeEchoes(server, [...server, echo]).map((e) => (e.sentEcho ? 'echo' : e.id))).toEqual(['a', 'b', 'echo']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
/**
|
||||
* A message in its Undo send window: sent to the server with a short hold, so
|
||||
* the server already files it in Sent and lists its submission. Until the
|
||||
* window is over it isn't sent as far as the user can tell (mail-p2-8):
|
||||
* Sent and Scheduled don't count it, and the message it answers isn't marked
|
||||
* as answered yet.
|
||||
*/
|
||||
export const UNDO_WINDOW_MS = 120_000;
|
||||
|
||||
export interface HeldSendCounts {
|
||||
/** Sent's total before the send, when the send went through the open login. */
|
||||
sent?: number;
|
||||
/** The Scheduled count before the send. */
|
||||
scheduled: number;
|
||||
}
|
||||
|
||||
export function isUndoWindow(sendAt: string | undefined, now = Date.now()): boolean {
|
||||
const at = sendAt ? Date.parse(sendAt) : NaN;
|
||||
return Number.isFinite(at) && at - now <= UNDO_WINDOW_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder list and Scheduled count to show while a send waits in its
|
||||
* Undo window: neither goes above what it was before the send. A count that
|
||||
* fell meanwhile (a mail deleted) shows as it is.
|
||||
*/
|
||||
export function withoutHeldSend(
|
||||
mailboxes: Mailbox[],
|
||||
scheduledTotal: number,
|
||||
held: { sendAt: string; countsBefore?: HeldSendCounts } | null | undefined,
|
||||
now = Date.now(),
|
||||
): { mailboxes: Mailbox[]; scheduledTotal: number } {
|
||||
if (!held?.countsBefore || !isUndoWindow(held.sendAt, now)) return { mailboxes, scheduledTotal };
|
||||
const before = held.countsBefore;
|
||||
const sentBefore = before.sent;
|
||||
const adjusted = sentBefore === undefined ? mailboxes : mailboxes.map((mb) =>
|
||||
mb.role === 'sent' && !mb.isShared && mb.totalEmails > sentBefore ? { ...mb, totalEmails: sentBefore } : mb);
|
||||
return { mailboxes: adjusted, scheduledTotal: Math.min(scheduledTotal, before.scheduled) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The reply just sent, as the conversation shows it until the server's copy
|
||||
* is read back (mail-p2-1). With the server's id when it gave one, so the
|
||||
* real message replaces it in place.
|
||||
*/
|
||||
export function sentEcho(input: {
|
||||
emailId?: string;
|
||||
threadId: string;
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
to: string[];
|
||||
cc?: string[];
|
||||
subject: string;
|
||||
text: string;
|
||||
now?: Date;
|
||||
}): Email {
|
||||
const at = (input.now ?? new Date()).toISOString();
|
||||
const preview = input.text.replace(/\s+/g, ' ').trim().slice(0, 256);
|
||||
return {
|
||||
id: input.emailId ?? `sent-echo-${at}`,
|
||||
blobId: '',
|
||||
threadId: input.threadId,
|
||||
mailboxIds: {},
|
||||
keywords: { $seen: true },
|
||||
size: 0,
|
||||
receivedAt: at,
|
||||
sentAt: at,
|
||||
from: input.fromEmail ? [{ name: input.fromName ?? '', email: input.fromEmail }] : [],
|
||||
to: input.to.map((email) => ({ name: '', email })),
|
||||
cc: (input.cc ?? []).map((email) => ({ name: '', email })),
|
||||
subject: input.subject,
|
||||
preview,
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: 'sent-echo', blobId: '', size: 0, type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
attachments: [],
|
||||
bodyValues: { 'sent-echo': { value: input.text, isEncodingProblem: false, isTruncated: false } },
|
||||
sentEcho: true,
|
||||
} as unknown as Email;
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation with a just-sent reply added at its end (once).
|
||||
*/
|
||||
export function addEcho(conversation: Email[], echo: Email): Email[] {
|
||||
if (conversation.some((e) => e.id === echo.id)) return conversation;
|
||||
return [...conversation, echo];
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's reading of the conversation, with the replies shown before it
|
||||
* had them kept at the end: a server that threads a new message a moment
|
||||
* later (or an offline send that waits in the outbox) doesn't make the reply
|
||||
* vanish again.
|
||||
*/
|
||||
export function mergeEchoes(server: Email[], shown: Email[]): Email[] {
|
||||
const ids = new Set(server.map((e) => e.id));
|
||||
const echoes = shown.filter((e) => e.sentEcho && !ids.has(e.id));
|
||||
return echoes.length ? [...server, ...echoes] : server;
|
||||
}
|
||||
@@ -113,6 +113,11 @@ export interface Email {
|
||||
scheduledAccountId?: string;
|
||||
isScheduled?: boolean;
|
||||
isSmimeScheduled?: boolean;
|
||||
/**
|
||||
* Client-only: a reply just sent, shown in the open conversation before the
|
||||
* server's copy is read back (lib/held-send.ts).
|
||||
*/
|
||||
sentEcho?: boolean;
|
||||
}
|
||||
|
||||
export interface SendEmailResult {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// mail-p2-8: a send in its Undo window keeps what Sent and Scheduled counted
|
||||
// before it, so the sidebar can show those counts until the message goes.
|
||||
|
||||
function mailbox(id: string, role: string, totalEmails: number): Mailbox {
|
||||
return { id, name: role, role, sortOrder: 0, totalEmails, unreadEmails: 0, totalThreads: totalEmails, unreadThreads: 0, isSubscribed: true } as Mailbox;
|
||||
}
|
||||
|
||||
describe('the counts before a held send', () => {
|
||||
let client: IJMAPClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
client = {
|
||||
getAccountId: () => 'acc-1',
|
||||
pruneEmailSubmissions: vi.fn().mockResolvedValue(0),
|
||||
sendEmail: vi.fn().mockResolvedValue({ scheduled: true, emailId: 'sent-1', emailSubmissionId: 'sub-1', sendAt: new Date(Date.now() + 5000).toISOString() }),
|
||||
getAllMailboxesWithState: vi.fn().mockResolvedValue({ mailboxes: [mailbox('mb-sent', 'sent', 52)], states: { 'acc-1': 'm2' } }),
|
||||
} as unknown as IJMAPClient;
|
||||
useAuthStore.setState({ client });
|
||||
useEmailStore.setState({
|
||||
emails: [], mailboxes: [mailbox('mb-sent', 'sent', 51)], mailboxSyncStates: { 'acc-1': 'm1' },
|
||||
selectedMailbox: 'mb-sent', pendingUndoSend: null, scheduledTotal: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('records Sent and Scheduled as they were', async () => {
|
||||
await useEmailStore.getState().sendEmail(client, ['bob@example.net'], 'Subject', 'body');
|
||||
expect(useEmailStore.getState().pendingUndoSend?.countsBefore).toEqual({ sent: 51, scheduled: 2 });
|
||||
});
|
||||
|
||||
it("records no Sent count for another login's send", async () => {
|
||||
const otherLogin = { ...client } as unknown as IJMAPClient;
|
||||
await useEmailStore.getState().sendEmail(otherLogin, ['bob@example.net'], 'Subject', 'body');
|
||||
expect(useEmailStore.getState().pendingUndoSend?.countsBefore).toEqual({ sent: undefined, scheduled: 2 });
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { RescheduleRestoredError } from "@/lib/jmap/reschedule-error";
|
||||
import { create } from "zustand";
|
||||
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types";
|
||||
import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types";
|
||||
import type { HeldSendCounts } from "@/lib/held-send";
|
||||
import type { IJMAPClient, SearchEmailsResult } from "@/lib/jmap/client-interface";
|
||||
import { jmapErrorFailedIds } from "@/lib/jmap/jmap-error";
|
||||
import type { MessagePriority } from "@/lib/message-priority";
|
||||
@@ -70,6 +71,8 @@ type PendingUndoSend = {
|
||||
* was sent from a shared identity (#874). Distinct from localAccountId,
|
||||
* which selects *which login's client* to talk to. */
|
||||
submissionAccountId?: string;
|
||||
/** Sent's and Scheduled's counts before the send, shown during the Undo window (lib/held-send.ts). */
|
||||
countsBefore?: HeldSendCounts;
|
||||
};
|
||||
|
||||
// Nothing ticked stays the same object: a new empty set re-renders all that reads the selection.
|
||||
@@ -4410,6 +4413,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// under "Loading emails…" until the message was out.
|
||||
set({ error: null });
|
||||
try {
|
||||
// What Sent and Scheduled counted before: a send in its Undo window
|
||||
// doesn't count yet (mail-p2-8).
|
||||
const countsBefore: HeldSendCounts = {
|
||||
sent: useAuthStore.getState().client === client ? get().mailboxes.find((mb) => mb.role === 'sent' && !mb.isShared)?.totalEmails : undefined,
|
||||
scheduled: get().scheduledTotal,
|
||||
};
|
||||
// Files attached while offline are uploaded now that the server answers.
|
||||
const sendAttachments = attachments && getOutboxHooks() ? await getOutboxHooks()!.resolveHeldAttachments(client, attachments) : attachments;
|
||||
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, sendAttachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options);
|
||||
@@ -4434,6 +4443,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Cross-account send: undo/send-now must talk to the account that
|
||||
// holds the submission, not the active one (#461).
|
||||
localAccountId: options?.localAccountId,
|
||||
countsBefore,
|
||||
}
|
||||
: get().pendingUndoSend,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* A quick reply whose send was undone, waiting for the reader of the message
|
||||
* it answers to take its text back into the quick reply box (mail-p2-8).
|
||||
*/
|
||||
interface QuickReplyRestoreState {
|
||||
pending: { emailId: string; text: string } | null;
|
||||
restore: (emailId: string, text: string) => void;
|
||||
/** The reader of `emailId` takes the text; null when none waits for it. */
|
||||
take: (emailId: string) => string | null;
|
||||
}
|
||||
|
||||
export const useQuickReplyRestoreStore = create<QuickReplyRestoreState>()((set, get) => ({
|
||||
pending: null,
|
||||
restore: (emailId, text) => set({ pending: { emailId, text } }),
|
||||
take: (emailId) => {
|
||||
const pending = get().pending;
|
||||
if (!pending || pending.emailId !== emailId) return null;
|
||||
set({ pending: null });
|
||||
return pending.text;
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user