diff --git a/components/email/__tests__/viewer-quick-reply-back.test.tsx b/components/email/__tests__/viewer-quick-reply-back.test.tsx index b4717d125..df22850a8 100644 --- a/components/email/__tests__/viewer-quick-reply-back.test.tsx +++ b/components/email/__tests__/viewer-quick-reply-back.test.tsx @@ -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(); + 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'); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index fc292094a..7777a7bb3 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -385,7 +385,10 @@ interface EmailComposerProps { labelHeaders?: Record; /** The id plugins know this composer by, for onAfterEmailSend. */ composerId?: string; - }) => void | false | Promise; + }, + /** 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; /** * 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); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 93be552e5..432749293 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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(`[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 diff --git a/components/mail/__tests__/mail-app-held-reply.test.tsx b/components/mail/__tests__/mail-app-held-reply.test.tsx new file mode 100644 index 000000000..fa1ec6fba --- /dev/null +++ b/components/mail/__tests__/mail-app-held-reply.test.tsx @@ -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>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +type ViewerProps = { mvConversation?: { id: string }[]; onQuickReply?: (body: string) => Promise; onReply?: () => void }; +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: ({ mvConversation, onQuickReply, onReply }: ViewerProps) => ( +
+
{mvConversation ? mvConversation.map((e) => e.id).join(',') : 'no conversation'}
+ + +
+ ), +})); +type ComposerProps = { + mode: string; + initialData?: { draftId?: string | null; body?: string } | null; + onSend?: (data: Record, state?: Record) => Promise; +}; +vi.mock('@/components/email/email-composer', () => ({ + EmailComposer: ({ mode, initialData, onSend }: ComposerProps) => ( +
+ +
+ ), +})); +vi.mock('@/components/layout/sidebar', () => ({ + Sidebar: ({ scheduledTotal, mailboxes }: { scheduledTotal?: number; mailboxes: Mailbox[] }) => ( +
{`scheduled ${scheduledTotal} sent ${mailboxes.find((m) => m.role === 'sent')?.totalEmails}`}
+ ), +})); +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: () => })); +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: () =>
message list
})); + +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 = {}) => ({ + 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 | (() => unknown)>; +let sendEmail: ReturnType; +let markEmailKeyword: ReturnType; +let cancelUndoSend: ReturnType; +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(); + 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(); + 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', '

Works for me

'); + expect(back.closest('[data-reply-dock]')).not.toBeNull(); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 0c5061e4c..d0afb5daf 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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; + 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; labelHeaders?: Record; 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()); + 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): 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, + ): Promise => { + const origin = held?.origin; + if (!origin || !restored) return false; + const openNow = useEmailStore.getState().selectedEmail; + const reopen = async (emailId: string): Promise => { + 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 = {}) { > = {}) => + ({ 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']); + }); +}); diff --git a/lib/held-send.ts b/lib/held-send.ts new file mode 100644 index 000000000..78b66e5fc --- /dev/null +++ b/lib/held-send.ts @@ -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; +} diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 29e0ea8af..ea75a89fb 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -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 { diff --git a/stores/__tests__/email-store-held-send-counts.test.ts b/stores/__tests__/email-store-held-send-counts.test.ts new file mode 100644 index 000000000..6b4f54aa3 --- /dev/null +++ b/stores/__tests__/email-store-held-send-counts.test.ts @@ -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 }); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 73676edc8..429d1dcdc 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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((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((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, }); diff --git a/stores/quick-reply-restore-store.ts b/stores/quick-reply-restore-store.ts new file mode 100644 index 000000000..a903fcc76 --- /dev/null +++ b/stores/quick-reply-restore-store.ts @@ -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()((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; + }, +}));