From 3616fd7386fb9d1d2c821378bfd94beb89340985 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 22:58:20 +0000 Subject: [PATCH 01/75] test(email-compose): cover local saves after a sender switch moves a draft Why: switching From moves the draft to a new server id, but the composer keeps the old working copy's revision. The next local write then targets the new id with that stale revision, so IndexedDB rejects it with "This draft changed while saving" and the server autosave behind it never runs. What: a behavior test over real IndexedDB working copies for both the standalone and reply composers, Outlook to Gmail and Gmail to Outlook. It fails today and should pass once the transfer resets local identity. Co-authored-by: teo --- .../draft-transfer-identity.test.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts diff --git a/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts b/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts new file mode 100644 index 00000000000..18bdc9f9b28 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts @@ -0,0 +1,150 @@ +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { message } from '../../email-message/tests/messages'; +import type { + EmailInbox, + SaveEmailDraft, +} from '../context/compose-capabilities'; +import { createComposeContext } from '../tests/capabilities'; +import { mountEmailComposer } from '../tests/composer'; +import { mountReplyComposer } from '../tests/reply'; + +vi.mock('@app/lib/analytics', () => ({ + analytics: { track: vi.fn(), posthog: { captureException: vi.fn() } }, +})); +vi.mock('@queries/client', () => ({ + queryClient: { + getQueryData: () => ({ authenticated: true, id: 'owner' }), + }, +})); + +let localDrafts: typeof import('@queries/email/local-drafts'); + +beforeEach(async () => { + vi.resetModules(); + vi.stubGlobal('indexedDB', new IDBFactory()); + localStorage.clear(); + localDrafts = await import('@queries/email/local-drafts'); +}); +afterEach(async () => { + await localDrafts.localDraftStore.close(); + vi.unstubAllGlobals(); +}); + +const outlook: EmailInbox = { + id: 'inbox', + email_address: 'teo@outlook.example', + provider: 'OUTLOOK', + settings: {}, +}; +const gmail: EmailInbox = { + id: 'inbox', + email_address: 'teo@gmail.example', + provider: 'GMAIL', + settings: {}, +}; + +/** Production keeps working copies in IndexedDB; only the network is faked. */ +function createDeviceContext(source: EmailInbox, destination: EmailInbox) { + const context = createComposeContext(); + const inboxes = [source, { ...destination, id: 'other' }]; + context.accounts.inboxes = () => inboxes; + context.drafts.saveLocalDraft = (input) => + localDrafts.saveLocalDraft({ + ...input, + senderEmail: inboxes.find((inbox) => inbox.id === input.inboxId) + ?.email_address, + }); + const moved = Promise.withResolvers< + { saved: SaveEmailDraft } | { failed: string; reason?: string } + >(); + context.drafts.saveDraft = vi.fn(async (input) => { + if (input.inboxId === 'other') moved.resolve({ saved: input }); + return { + draftId: input.draft.db_id ?? 'draft', + threadId: input.draft.thread_db_id ?? 'thread', + inboxId: input.inboxId ?? 'inbox', + }; + }); + context.notices.feedback.failure = vi.fn((failed: string, options) => { + moved.resolve({ failed, reason: options?.subtext }); + }); + return { context, moved: moved.promise }; +} + +const directions = [ + ['Outlook', 'Gmail', outlook, gmail], + ['Gmail', 'Outlook', gmail, outlook], +] as const; + +it.each(directions)( + 'keeps saving a draft locally after its sender moves it from %s to %s', + async (_from, _to, source, destination) => { + const { context, moved } = createDeviceContext(source, destination); + const root = mountEmailComposer(context, undefined, { + draft: message('draft', { + is_draft: true, + link_id: 'inbox', + from: { email: source.email_address }, + }), + }); + try { + root.edit('Before the move'); + await root.state.flushLocal(); + root.state.context.onSelectInbox?.('other'); + expect(await moved).toEqual({ + saved: expect.objectContaining({ + inboxId: 'other', + draft: expect.objectContaining({ db_id: 'moved-draft' }), + }), + }); + root.edit('After the move'); + await root.state.flushLocal(); + expect(root.state.localSaveState()).toBe('saved'); + expect(await localDrafts.readLocalDraft('moved-draft')).toMatchObject({ + inboxId: 'other', + content: { body_text: 'After the move' }, + }); + expect(await localDrafts.readLocalDraft('draft')).toBeUndefined(); + } finally { + root.dispose(); + } + } +); + +it.each(directions)( + 'keeps saving a reply locally after its sender moves it from %s to %s', + async (_from, _to, source, destination) => { + const { context, moved } = createDeviceContext(source, destination); + const reply = mountReplyComposer(context, undefined, { + draft: message('draft', { + is_draft: true, + link_id: 'inbox', + replying_to_id: 'parent', + from: { email: source.email_address }, + }), + }); + try { + reply.edit('Before the move'); + await reply.flushLocal(); + reply.persistDraftOnSenderSwitch('other'); + expect(await moved).toEqual({ + saved: expect.objectContaining({ + inboxId: 'other', + draft: expect.objectContaining({ db_id: 'moved-draft' }), + }), + }); + reply.edit('After the move'); + await reply.flushLocal(); + expect(reply.localSaveState()).toBe('saved'); + expect(await localDrafts.readLocalDraft('moved-draft')).toMatchObject({ + inboxId: 'other', + content: { body_text: 'After the move' }, + }); + expect(await localDrafts.readLocalDraft('draft')).toBeUndefined(); + } finally { + reply.dispose(); + } + } +); From 742d6ce8c4a85216bc1d4fb685204915b247e4b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 23:10:38 +0000 Subject: [PATCH 02/75] fix(email-compose): follow a draft to its new id after a From switch Why: switching From on a saved draft moves it to a new server draft and thread. The composer adopted the new id but kept the old working copy's revision, so the next local write was rejected with "This draft changed while saving" and the save carrying the new sender never ran. A thread route kept pointing at the deleted source thread, which showed the 404 page, and the source's working copy stayed behind. What: - Once the server commits the move, the transfer resets the local revision fence and forgets the source working copy, for both composers. - The standalone composer captures its post-move save before the host shows the moved draft, so the new sender still saves if that unmounts the composer. - The thread host replaces the route with the moved draft's thread. Replies stay on their conversation. - The identity test now checks the post-move write itself; edits made while the switch is still running are ignored by design. Co-authored-by: teo --- .../features/email-compose/compose-adapter.ts | 2 + .../email-compose/compose-host-adapter.ts | 8 ++- .../context/compose-capabilities.ts | 4 ++ .../primitives/draft-persistence.ts | 2 + .../draft-transfer-identity.test.ts | 68 ++++++++++++++++--- .../primitives/draft-transfer.ts | 17 ++++- .../primitives/email-composer.ts | 14 ++-- .../primitives/reply-composer.ts | 1 + 8 files changed, 98 insertions(+), 18 deletions(-) diff --git a/apps/web/src/features/email-compose/compose-adapter.ts b/apps/web/src/features/email-compose/compose-adapter.ts index 624934f8664..6f3c4fd7719 100644 --- a/apps/web/src/features/email-compose/compose-adapter.ts +++ b/apps/web/src/features/email-compose/compose-adapter.ts @@ -65,6 +65,7 @@ import { import { confirmLocalDraftSave } from '@queries/email/local-draft-confirmation'; import { clearLocalAttachmentReceipt, + forgetLocalDraft, readLocalDraft, recordLocalAttachment, reviveLocalDraft, @@ -357,6 +358,7 @@ export function createEmailComposeContext( get retryDraft() { return queueActive() ? retryEmailDraftSynchronization : undefined; }, + forgetLocalDraft: (draftId) => forgetLocalDraft(draftId), readDraft: readEmailDraft, watchDrafts: watchEmailDrafts, async saveDraft({ diff --git a/apps/web/src/features/email-compose/compose-host-adapter.ts b/apps/web/src/features/email-compose/compose-host-adapter.ts index e26249de100..d0a6550e3cc 100644 --- a/apps/web/src/features/email-compose/compose-host-adapter.ts +++ b/apps/web/src/features/email-compose/compose-host-adapter.ts @@ -6,10 +6,11 @@ import { createPanelFocusSibling } from './editor-adapter'; export function createEmailComposeHost(): EmailComposeHost { const { replaceSplit } = useSplitLayout(); const panel = useSplitPanel(); + const showThread = (id: string) => { + replaceSplit({ content: { type: 'email', id }, mergeHistory: true }); + }; return { - showThread: (id: string) => { - replaceSplit({ content: { type: 'email', id }, mergeHistory: true }); - }, + showThread, showDraft: (id: string) => { replaceSplit({ content: { @@ -20,6 +21,7 @@ export function createEmailComposeHost(): EmailComposeHost { }, }); }, + showMovedDraft: ({ threadId }) => showThread(threadId), goBack: () => panel?.handle.goBack(), focusSibling: createPanelFocusSibling(), registerBack: (handler: () => boolean) => { diff --git a/apps/web/src/features/email-compose/context/compose-capabilities.ts b/apps/web/src/features/email-compose/context/compose-capabilities.ts index 404a4f93942..5f926d24392 100644 --- a/apps/web/src/features/email-compose/context/compose-capabilities.ts +++ b/apps/web/src/features/email-compose/context/compose-capabilities.ts @@ -144,6 +144,8 @@ export interface EmailDraftStorage { } ): Promise; retryDraft?(draftId: string): Promise; + /** Drop the working copy of a draft whose server row no longer exists. */ + forgetLocalDraft?(draftId: string): Promise; /** Resolve durable local drafts before mounting an editor. */ readDraft?( draftId: string, @@ -421,6 +423,8 @@ export interface EmailComposeHost { focusSibling?: (direction: 'next' | 'prev') => boolean | void; showThread?: (id: string) => void; showDraft?: (id: string) => void; + /** A From switch moved the draft to a new id and thread; the old ones are gone. */ + showMovedDraft?: (moved: { draftId: string; threadId: string }) => void; goBack?: () => void; registerBack?: (handler: () => boolean) => void; } diff --git a/apps/web/src/features/email-compose/primitives/draft-persistence.ts b/apps/web/src/features/email-compose/primitives/draft-persistence.ts index 4f35b8ca2fd..89bc1b421e9 100644 --- a/apps/web/src/features/email-compose/primitives/draft-persistence.ts +++ b/apps/web/src/features/email-compose/primitives/draft-persistence.ts @@ -11,6 +11,8 @@ import type { LocalDraft } from '../core/local-draft'; import type { DraftSession } from './draft-session'; import type { DraftFormAttachment } from './email-form-state'; +export type DraftPersistence = ReturnType; + /** Sent from another device: the server outcome supersedes the local draft. */ export const isAlreadySentRejection = (error: unknown) => error instanceof DraftPersistRejected && error.code === 'DRAFT_ALREADY_SENT'; diff --git a/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts b/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts index 18bdc9f9b28..c26034080d4 100644 --- a/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts +++ b/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts @@ -56,6 +56,11 @@ function createDeviceContext(source: EmailInbox, destination: EmailInbox) { senderEmail: inboxes.find((inbox) => inbox.id === input.inboxId) ?.email_address, }); + const forgotten = Promise.withResolvers(); + context.drafts.forgetLocalDraft = async (draftId) => { + await localDrafts.forgetLocalDraft(draftId); + forgotten.resolve(draftId); + }; const moved = Promise.withResolvers< { saved: SaveEmailDraft } | { failed: string; reason?: string } >(); @@ -70,7 +75,7 @@ function createDeviceContext(source: EmailInbox, destination: EmailInbox) { context.notices.feedback.failure = vi.fn((failed: string, options) => { moved.resolve({ failed, reason: options?.subtext }); }); - return { context, moved: moved.promise }; + return { context, moved: moved.promise, forgotten: forgotten.promise }; } const directions = [ @@ -81,7 +86,10 @@ const directions = [ it.each(directions)( 'keeps saving a draft locally after its sender moves it from %s to %s', async (_from, _to, source, destination) => { - const { context, moved } = createDeviceContext(source, destination); + const { context, moved, forgotten } = createDeviceContext( + source, + destination + ); const root = mountEmailComposer(context, undefined, { draft: message('draft', { is_draft: true, @@ -99,13 +107,12 @@ it.each(directions)( draft: expect.objectContaining({ db_id: 'moved-draft' }), }), }); - root.edit('After the move'); - await root.state.flushLocal(); expect(root.state.localSaveState()).toBe('saved'); expect(await localDrafts.readLocalDraft('moved-draft')).toMatchObject({ inboxId: 'other', - content: { body_text: 'After the move' }, + content: { sender_email: destination.email_address }, }); + expect(await forgotten).toBe('draft'); expect(await localDrafts.readLocalDraft('draft')).toBeUndefined(); } finally { root.dispose(); @@ -114,9 +121,53 @@ it.each(directions)( ); it.each(directions)( - 'keeps saving a reply locally after its sender moves it from %s to %s', + 'shows the moved draft from %s to %s and still saves its new sender', async (_from, _to, source, destination) => { const { context, moved } = createDeviceContext(source, destination); + const showMovedDraft = vi.fn(); + // A thread route shows the moved draft by unmounting this composer. + const root = mountEmailComposer( + context, + { + showMovedDraft: (draft) => { + showMovedDraft(draft); + root.dispose(); + }, + }, + { + draft: message('draft', { + is_draft: true, + link_id: 'inbox', + from: { email: source.email_address }, + }), + } + ); + root.edit('Moving draft'); + await root.state.flushLocal(); + root.state.context.onSelectInbox?.('other'); + expect(await moved).toEqual({ + saved: expect.objectContaining({ + inboxId: 'other', + draft: expect.objectContaining({ + db_id: 'moved-draft', + sender_email: destination.email_address, + }), + }), + }); + expect(showMovedDraft).toHaveBeenCalledExactlyOnceWith({ + draftId: 'moved-draft', + threadId: 'moved-thread', + }); + } +); + +it.each(directions)( + 'keeps saving a reply locally after its sender moves it from %s to %s', + async (_from, _to, source, destination) => { + const { context, moved, forgotten } = createDeviceContext( + source, + destination + ); const reply = mountReplyComposer(context, undefined, { draft: message('draft', { is_draft: true, @@ -135,13 +186,12 @@ it.each(directions)( draft: expect.objectContaining({ db_id: 'moved-draft' }), }), }); - reply.edit('After the move'); - await reply.flushLocal(); expect(reply.localSaveState()).toBe('saved'); expect(await localDrafts.readLocalDraft('moved-draft')).toMatchObject({ inboxId: 'other', - content: { body_text: 'After the move' }, + content: { sender_email: destination.email_address }, }); + expect(await forgotten).toBe('draft'); expect(await localDrafts.readLocalDraft('draft')).toBeUndefined(); } finally { reply.dispose(); diff --git a/apps/web/src/features/email-compose/primitives/draft-transfer.ts b/apps/web/src/features/email-compose/primitives/draft-transfer.ts index 41e2c67c35d..122cc87b596 100644 --- a/apps/web/src/features/email-compose/primitives/draft-transfer.ts +++ b/apps/web/src/features/email-compose/primitives/draft-transfer.ts @@ -1,4 +1,5 @@ import { getUploadFileSize } from '@core/mobile/nativeStagedUpload'; +import { fireAndForget } from '@core/util/fireAndForget'; import { createSignal } from 'solid-js'; import { v7 as uuidv7 } from 'uuid'; import { @@ -7,15 +8,17 @@ import { type EmailInbox, } from '../context/compose-capabilities'; import { attachmentLimitBytes } from '../core/constants'; +import type { DraftPersistence } from './draft-persistence'; import type { DraftSession } from './draft-session'; import type { DraftFormAttachment } from './email-form-state'; /** Preserve the operation ID across a lost response and adopt a distinct identity. */ export function createDraftTransfer(options: { session: DraftSession; + persistence: Pick; draftGeneration?: () => number; onTransferred?: () => void; - drafts: Pick; + drafts: Pick; attachments: { list(): DraftFormAttachment[]; replace(files: DraftFormAttachment[]): void; @@ -27,7 +30,11 @@ export function createDraftTransfer(options: { createSignal[0]>(); return { pending, - async move(destination: EmailInbox, sourceInboxId: string) { + /** Resolves with the moved draft's ids once the server commits the move. */ + async move( + destination: EmailInbox, + sourceInboxId: string + ): Promise<{ draftId: string; threadId: string } | undefined> { const pendingMove = pending(); if (pendingMove && pendingMove.destinationInboxId !== destination.id) { throw new Error( @@ -73,6 +80,11 @@ export function createDraftTransfer(options: { } options.invalidateOldSaves(); options.onTransferred?.(); + // The moved draft has no working copy yet, and the source's copy would + // linger as a draft whose server row is gone. + options.persistence.restored(undefined); + const forget = options.drafts.forgetLocalDraft?.(request.draftId); + if (forget) fireAndForget(forget, 'email-compose'); options.session.dispatch({ type: 'seeded', draftId: result.draftId, @@ -91,6 +103,7 @@ export function createDraftTransfer(options: { })) ); setPending(undefined); + return { draftId: result.draftId, threadId: result.threadId }; }, }; } diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts index 8baa7e35f0b..cbdba577bcf 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -1279,6 +1279,7 @@ export function createEmailComposer(props: EmailComposerOptions) { draftGeneration, onTransferred: () => setDraftGeneration(0), session, + persistence, drafts: props.drafts, attachments: form.attachments, save: () => autosave.save(), @@ -1313,13 +1314,18 @@ export function createEmailComposer(props: EmailComposerOptions) { ); } autosave.cancel(); - if (sourceInboxId && sourceInboxId !== inboxId) { - await draftTransfer?.move(destination, sourceInboxId); - } + const moved = + sourceInboxId && sourceInboxId !== inboxId + ? await draftTransfer?.move(destination, sourceInboxId) + : undefined; form.setSelectedInbox(inboxId, senderEmail); editVersion += 1; setDraftDirty(true); - await autosave.save(); + // The save captures the new sender before the host can unmount this + // composer to show the moved draft. + const saved = autosave.save(); + if (moved) props.host?.showMovedDraft?.(moved); + await saved; } catch (error) { props.notices.reportError(error); props.notices.feedback.failure( diff --git a/apps/web/src/features/email-compose/primitives/reply-composer.ts b/apps/web/src/features/email-compose/primitives/reply-composer.ts index fdf38de6869..370d1a3a352 100644 --- a/apps/web/src/features/email-compose/primitives/reply-composer.ts +++ b/apps/web/src/features/email-compose/primitives/reply-composer.ts @@ -842,6 +842,7 @@ export function createReplyComposer( draftGeneration, onTransferred: () => setDraftGeneration(0), session, + persistence, drafts: props.drafts, attachments: form.attachments, save: () => executeSaveDraft(), From 28b1b79c43a6906352770547b4fd436db34aef39 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 23:11:26 +0000 Subject: [PATCH 03/75] fix(email): show a moved draft before evicting its source thread Why: after a committed From switch, the transfer awaited the cache refresh that evicts the source thread before returning. A thread route still showing that thread swapped in the not-found page, and unmounted the composer, before the caller could show the moved draft. What: the post-commit cache refresh runs in the background and still reports failures to telemetry, so the caller adopts and shows the moved draft first. Co-authored-by: teo --- apps/web/src/lib/queries/email/draft.ts | 38 ++++++++++++++----------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/apps/web/src/lib/queries/email/draft.ts b/apps/web/src/lib/queries/email/draft.ts index b410b4d410c..d36075c3738 100644 --- a/apps/web/src/lib/queries/email/draft.ts +++ b/apps/web/src/lib/queries/email/draft.ts @@ -1,4 +1,5 @@ import { DraftTransferAborted } from '@app/features/email-compose/context/compose-capabilities'; +import { fireAndForget } from '@core/util/fireAndForget'; import { throwOnErr } from '@core/util/result'; import { Telemetry } from '@macro-inc/observability'; import { invalidateAllSoup, refetchSoupEntity } from '@queries/soup/cache'; @@ -6,6 +7,7 @@ import { emailClient } from '@service-email/client'; import type { ApiDraftInput, CreateDraftResponse, + TransferDraftResponse, } from '@service-email/generated/schemas'; import { getGraphqlCacheHost } from '@service-storage/graphql-soup'; import { useMutation } from '@tanstack/solid-query'; @@ -165,22 +167,24 @@ export async function transferEmailDraft(input: { result = recovery.committed; } // Cache refresh errors must not turn a committed move into a failed write. - try { - await getGraphqlCacheHost()?.deleteRecords([ - `GraphqlSoupEmailMessage:${result.source_id}`, - ]); - await Promise.all([ - refreshEmailThreadCache(result.source_thread_id), - refreshEmailThreadCache(result.thread_id), - ]); - for (const threadId of [result.source_thread_id, result.thread_id]) { - await queryClient.invalidateQueries({ - queryKey: emailKeys.threadMessages(threadId).queryKey, - }); - } - invalidateAllSoup(); - } catch (error) { - Telemetry.error(error); - } + // The caller shows the moved draft first: evicting the source thread + // replaces a route still showing it with the not-found page. + fireAndForget(refreshTransferredThreads(result), 'email-draft-transfer'); return result; } + +async function refreshTransferredThreads(result: TransferDraftResponse) { + await getGraphqlCacheHost()?.deleteRecords([ + `GraphqlSoupEmailMessage:${result.source_id}`, + ]); + await Promise.all([ + refreshEmailThreadCache(result.source_thread_id), + refreshEmailThreadCache(result.thread_id), + ]); + for (const threadId of [result.source_thread_id, result.thread_id]) { + await queryClient.invalidateQueries({ + queryKey: emailKeys.threadMessages(threadId).queryKey, + }); + } + invalidateAllSoup(); +} From d64d878329c67f263102c1d3e71b246a964cd471 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 23:16:34 +0000 Subject: [PATCH 04/75] test(email-compose): type the failure notice fake in the identity test Why: the fake returned nothing, which the feedback capability's type rejects, so the web type check failed. What: the fake takes the notice options type and returns undefined. Co-authored-by: teo --- .../primitives/draft-transfer-identity.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts b/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts index c26034080d4..fb11fe7a8b7 100644 --- a/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts +++ b/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts @@ -3,6 +3,7 @@ import { IDBFactory } from 'fake-indexeddb'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { message } from '../../email-message/tests/messages'; import type { + ComposeNoticeOptions, EmailInbox, SaveEmailDraft, } from '../context/compose-capabilities'; @@ -72,9 +73,12 @@ function createDeviceContext(source: EmailInbox, destination: EmailInbox) { inboxId: input.inboxId ?? 'inbox', }; }); - context.notices.feedback.failure = vi.fn((failed: string, options) => { - moved.resolve({ failed, reason: options?.subtext }); - }); + context.notices.feedback.failure = vi.fn( + (failed: string, options?: ComposeNoticeOptions) => { + moved.resolve({ failed, reason: options?.subtext }); + return undefined; + } + ); return { context, moved: moved.promise, forgotten: forgotten.promise }; } From b7a5325dafdb03d0e790ae854491d4567b35fd9d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 23:16:34 +0000 Subject: [PATCH 05/75] fix(email-compose): reopen a compose's draft and From after a reload Why: the compose route kept its draft only in in-memory params, so reloading a new compose opened a blank composer with the primary inbox as From, even though the content had autosaved. A From switch inside the compose route had the same gap for the moved draft's new id. What: - The standalone composer reports its server-confirmed draft id to the host. A handle the server has not confirmed would not resolve after a reload, so the last confirmed id stays until a newer one is confirmed. - The compose route records that id in an `email-compose` search param, replacing the current history entry so the composer does not remount, and reopens it ahead of the route's original params. - The compose route host keeps a moved draft in place instead of following it to its thread. Co-authored-by: teo --- .../email-compose/compose-host-adapter.ts | 21 +- .../compose-route-draft.test.tsx | 215 ++++++++++++++++++ .../email-compose/compose-route-draft.ts | 20 ++ .../context/compose-capabilities.ts | 2 + .../features/email-compose/email-compose.tsx | 18 +- .../primitives/email-composer.ts | 10 + 6 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/features/email-compose/compose-route-draft.test.tsx create mode 100644 apps/web/src/features/email-compose/compose-route-draft.ts diff --git a/apps/web/src/features/email-compose/compose-host-adapter.ts b/apps/web/src/features/email-compose/compose-host-adapter.ts index d0a6550e3cc..c878489d49d 100644 --- a/apps/web/src/features/email-compose/compose-host-adapter.ts +++ b/apps/web/src/features/email-compose/compose-host-adapter.ts @@ -1,9 +1,11 @@ import { useSplitBackInterceptor } from '@components/app/split-layout/back-interceptor'; import { useSplitLayout } from '@components/app/split-layout/layout'; import { useSplitPanel } from '@components/app/split-layout/layoutUtils'; +import type { createComposeRouteDraft } from './compose-route-draft'; import type { EmailComposeHost } from './context/compose-capabilities'; import { createPanelFocusSibling } from './editor-adapter'; -export function createEmailComposeHost(): EmailComposeHost { + +function createSplitComposeHost() { const { replaceSplit } = useSplitLayout(); const panel = useSplitPanel(); const showThread = (id: string) => { @@ -21,7 +23,6 @@ export function createEmailComposeHost(): EmailComposeHost { }, }); }, - showMovedDraft: ({ threadId }) => showThread(threadId), goBack: () => panel?.handle.goBack(), focusSibling: createPanelFocusSibling(), registerBack: (handler: () => boolean) => { @@ -29,3 +30,19 @@ export function createEmailComposeHost(): EmailComposeHost { }, }; } + +/** A thread route names the draft's thread, so it follows a moved draft there. */ +export function createEmailComposeHost(): EmailComposeHost { + const host = createSplitComposeHost(); + return { + ...host, + showMovedDraft: ({ threadId }) => host.showThread(threadId), + }; +} + +/** The compose route keeps a moved draft in place and records its new id instead. */ +export function createComposeRouteHost( + route: Pick, 'record'> +): EmailComposeHost { + return { ...createSplitComposeHost(), recordDraft: route.record }; +} diff --git a/apps/web/src/features/email-compose/compose-route-draft.test.tsx b/apps/web/src/features/email-compose/compose-route-draft.test.tsx new file mode 100644 index 00000000000..da54196afa2 --- /dev/null +++ b/apps/web/src/features/email-compose/compose-route-draft.test.tsx @@ -0,0 +1,215 @@ +import { + createMemoryHistory, + createMemoryPaneStore, + createSplitRouter, + defineRoute, + type MemoryHistory, + type SplitPanePolicy, + SplitRouter, + type SplitRoutes, +} from '@app/lib/split-router'; +import { cleanup, render } from '@solidjs/testing-library'; +import { createEffect, createRoot, For, onCleanup, onMount } from 'solid-js'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { message } from '../email-message/tests/messages'; +import { + composeDraftSearch, + createComposeRouteDraft, +} from './compose-route-draft'; +import type { + EmailInbox, + PersistedEmailIdentity, + SaveEmailDraft, +} from './context/compose-capabilities'; +import { createComposeContext } from './tests/capabilities'; +import { mountEmailComposer } from './tests/composer'; + +let route: ReturnType | undefined; +let mounts = 0; + +function ComposeRoute() { + route = createComposeRouteDraft(); + onMount(() => { + mounts += 1; + }); + return null; +} + +/** The app serves compose from the legacy `component/:id` route, which accepts any search. */ +const routes: SplitRoutes = { + definitions: [ + defineRoute({ + id: 'app', + children: [ + defineRoute({ + id: 'email-compose', + path: 'component/email-compose', + search: [composeDraftSearch.namespace], + component: ComposeRoute, + }), + ], + }), + ], + defaultRoute: () => ({ + matches: [ + { id: 'app', params: {} }, + { id: 'email-compose', params: {} }, + ], + }), +}; + +const policy: SplitPanePolicy = { + placeNewPane: ({ panes }) => ({ insertAt: panes.length }), + closeAction: () => ({ type: 'keep' }), + activate: () => {}, +}; + +function renderComposeRoute(url: string): MemoryHistory { + const history = createMemoryHistory(url); + render(() => { + const router = createSplitRouter({ + routes, + history, + paneStore: createMemoryPaneStore(), + policy, + }); + onCleanup(() => router.dispose()); + return ( + + + {(pane) => } + + + ); + }); + return history; +} + +function routeShows(compose: ReturnType) { + return (draftId: string) => { + const shown = Promise.withResolvers(); + createRoot((dispose) => + createEffect(() => { + if (compose.draftId() !== draftId) return; + dispose(); + shown.resolve(); + }) + ); + return shown.promise; + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + route = undefined; + mounts = 0; +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +const outlook: EmailInbox = { + id: 'outlook', + email_address: 'teo@outlook.example', + provider: 'OUTLOOK', + settings: {}, +}; +const gmail: EmailInbox = { + id: 'gmail', + email_address: 'teo@gmail.example', + provider: 'GMAIL', + settings: {}, +}; + +it.each([ + ['Outlook', outlook, gmail], + ['Gmail', gmail, outlook], +] as const)( + 'reopens a new %s compose with its From after a reload, once the server confirms it', + async (_provider, from, primary) => { + const history = renderComposeRoute('/component/email-compose'); + const compose = route!; + const context = createComposeContext(); + context.accounts.inboxes = () => [primary, from]; + context.accounts.primaryId = () => primary.id; + const saving = Promise.withResolvers(); + const confirmation = Promise.withResolvers(); + context.drafts.saveDraft = vi.fn((input) => { + saving.resolve(input); + return confirmation.promise; + }); + const composer = mountEmailComposer( + context, + { recordDraft: compose.record }, + { initialInboxId: from.id } + ); + composer.edit('Keep this draft'); + await vi.advanceTimersByTimeAsync(600); + const saved = await saving.promise; + expect(compose.draftId()).toBeUndefined(); + + confirmation.resolve({ + draftId: 'draft', + threadId: 'thread', + inboxId: saved.inboxId ?? '', + }); + await routeShows(compose)('draft'); + expect(history.entries()).toHaveLength(1); + expect(mounts).toBe(1); + composer.dispose(); + + const { path, search } = history.entries()[history.index()]!; + cleanup(); + renderComposeRoute(`${path}${search}`); + const reopened = route!.draftId(); + expect(reopened).toBe('draft'); + const restored = mountEmailComposer(context, undefined, { + draftId: reopened, + draft: message('draft', { + is_draft: true, + link_id: saved.inboxId, + from: { email: saved.draft.sender_email ?? '' }, + }), + }); + expect(restored.state.context.fromAddress?.()).toBe(from.email_address); + restored.dispose(); + } +); + +it.each([ + ['Outlook', 'Gmail', outlook, gmail], + ['Gmail', 'Outlook', gmail, outlook], +] as const)( + 'keeps a compose open on its route when From moves it from %s to %s', + async (_from, _to, source, destination) => { + const history = renderComposeRoute('/component/email-compose'); + const compose = route!; + const context = createComposeContext(); + context.accounts.inboxes = () => [source, destination]; + context.accounts.primaryId = () => source.id; + context.drafts.saveDraft = vi.fn(async (input) => ({ + draftId: input.draft.db_id ?? 'draft', + threadId: input.draft.thread_db_id ?? 'thread', + inboxId: input.inboxId ?? source.id, + })); + const composer = mountEmailComposer( + context, + { recordDraft: compose.record }, + { + draft: message('draft', { + is_draft: true, + link_id: source.id, + from: { email: source.email_address }, + }), + } + ); + await routeShows(compose)('draft'); + + composer.state.context.onSelectInbox?.(destination.id); + await routeShows(compose)('moved-draft'); + expect(history.entries()).toHaveLength(1); + expect(mounts).toBe(1); + composer.dispose(); + } +); diff --git a/apps/web/src/features/email-compose/compose-route-draft.ts b/apps/web/src/features/email-compose/compose-route-draft.ts new file mode 100644 index 00000000000..9b20eb90fc1 --- /dev/null +++ b/apps/web/src/features/email-compose/compose-route-draft.ts @@ -0,0 +1,20 @@ +import { createSearchParams } from '@app/lib/split-router'; +import { z } from 'zod'; + +export const composeDraftSearch = { + namespace: 'email-compose', + schema: z.object({ draft: z.string() }), + defaults: { draft: '' }, +}; + +/** The compose route's draft, kept in its URL so a reload reopens it. */ +export function createComposeRouteDraft() { + const [search, setSearch] = createSearchParams(composeDraftSearch); + return { + draftId: () => search.draft || undefined, + record(draftId: string | undefined) { + if ((draftId ?? '') === search.draft) return; + setSearch({ draft: draftId }, { history: 'replace' }); + }, + }; +} diff --git a/apps/web/src/features/email-compose/context/compose-capabilities.ts b/apps/web/src/features/email-compose/context/compose-capabilities.ts index 5f926d24392..65b89cc7df2 100644 --- a/apps/web/src/features/email-compose/context/compose-capabilities.ts +++ b/apps/web/src/features/email-compose/context/compose-capabilities.ts @@ -425,6 +425,8 @@ export interface EmailComposeHost { showDraft?: (id: string) => void; /** A From switch moved the draft to a new id and thread; the old ones are gone. */ showMovedDraft?: (moved: { draftId: string; threadId: string }) => void; + /** The server-confirmed draft a reload should reopen, or undefined when there is none. */ + recordDraft?: (draftId: string | undefined) => void; goBack?: () => void; registerBack?: (handler: () => boolean) => void; } diff --git a/apps/web/src/features/email-compose/email-compose.tsx b/apps/web/src/features/email-compose/email-compose.tsx index da37f232072..99984ca673b 100644 --- a/apps/web/src/features/email-compose/email-compose.tsx +++ b/apps/web/src/features/email-compose/email-compose.tsx @@ -1,11 +1,23 @@ import type { ComponentProps } from 'solid-js'; import { createEmailComposeContext } from './compose-adapter'; -import { createEmailComposeHost } from './compose-host-adapter'; +import { createComposeRouteHost } from './compose-host-adapter'; +import { createComposeRouteDraft } from './compose-route-draft'; import { EmailComposeView } from './views/email-compose'; export function EmailCompose( props: Omit, 'context' | 'host'> ) { const composeContext = createEmailComposeContext(); - const host = createEmailComposeHost(); - return ; + const route = createComposeRouteDraft(); + const host = createComposeRouteHost(route); + // History keeps the params this route opened with; a moved draft lives on + // only in the URL. + const draftId = route.draftId() ?? props.draftId; + return ( + + ); } diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts index cbdba577bcf..6d0f3bae299 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -246,6 +246,16 @@ export function createEmailComposer(props: EmailComposerOptions) { const currentDraftId = session.draftId; const [sentDraftId, setSentDraftId] = createSignal(); const currentThreadId = session.threadId; + // A reload can reopen only a draft the server confirmed; until a newer + // identity is confirmed, the host keeps the last one. + const confirmedDraftId = createMemo((previous) => { + const identity = session.identity(); + if (identity.kind === 'none') return undefined; + return session.serverConfirmed() ? identity.draftId : previous; + }, undefined); + createEffect( + on(confirmedDraftId, (draftId) => props.host?.recordDraft?.(draftId)) + ); const sendLocked = () => props.delivery.sendLocked?.(currentDraftId() ?? sentDraftId()) ?? false; observeDraftIdentity( From ec6fb7107b07315527a680632ed9d2031ea5441f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 23:17:16 +0000 Subject: [PATCH 06/75] docs(email): describe draft identity after a From switch or reload Why: the guides said the compose route only reads its in-memory `draftID` param and did not say where a moved draft is shown. What: the architecture guide covers the `email-compose.draft` pane search and which host follows a moved draft. The agent guide says what to check after a From switch and a compose reload. Co-authored-by: teo --- docs/agent-guide/email.md | 6 ++++++ docs/architecture/email.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/docs/agent-guide/email.md b/docs/agent-guide/email.md index c24f24691c8..df81784df46 100644 --- a/docs/agent-guide/email.md +++ b/docs/agent-guide/email.md @@ -84,6 +84,12 @@ provider copy before choosing an explicit resend; a resend in flight shows as the send button's pending state. Switching the sender inbox copies the draft and attachments into the destination; wait for completion before sending. If the copy is interrupted, use retry or keep the original. +A draft opened from Drafts then shows its thread in the destination inbox, +never the not-found page; a draft in compose stays open there. Keep editing +after the switch and check that no "This draft changed while saving" notice +appears. Once a new compose's first save is confirmed, the URL carries its +draft (`s0.email-compose.draft` for the first pane): reloading reopens it with +its From, including after a From switch. Opening an attachment opens it in a split from its Drive copy, except a calendar file: it opens its event (see the calendar invitations section of the diff --git a/docs/architecture/email.md b/docs/architecture/email.md index 30d87a627c6..8f900bb9e30 100644 --- a/docs/architecture/email.md +++ b/docs/architecture/email.md @@ -287,6 +287,12 @@ inbox ID is not a precomputed header: primary-inbox omission belongs to the adap Existing message/thread model fields retain their established snake_case spelling; this cleanup does not rename generated schemas or persisted URLs. In particular, the host still reads/writes the existing `draftID` compose route parameter. +Those parameters live only in memory, so the compose route also records the +composer's server-confirmed draft in its `email-compose.draft` pane search, +replacing the current history entry, and reopens that draft ahead of +`draftID` (`compose-route-draft.ts`). A From switch that moves a draft keeps +the compose route in place, while a thread route follows the moved draft to +its new thread (`compose-host-adapter.ts`). `EmailThreadStateProvider` and `useEmailThreadState` name mounted thread state. `ThreadReplyInput` names the thread-owned reply entry point, while From e6fd3eb4e8d0fc91bf54d9bbd449c4e2aa1d621a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 23:32:19 +0000 Subject: [PATCH 07/75] docs(email): list the compose host's moved-draft and route capabilities Why: The capability table in the email architecture doc still described EmailComposeHost and EmailDraftStorage without the moved-draft destination, the route's draft, and the working-copy drop. What: Name those capabilities in the two table rows. Co-authored-by: teo --- docs/architecture/email.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/email.md b/docs/architecture/email.md index 8f900bb9e30..8acfeed7672 100644 --- a/docs/architecture/email.md +++ b/docs/architecture/email.md @@ -100,7 +100,7 @@ test exercises this ordering. | `EmailRenderingContextValue` | Theme values, explicit image policy, link preparation, and image resolution with an abortable resource lifetime | | `EmailFormContextInputs` | Viewer address and available inbox identities for recipient selection | | `EmailReplySession` | Thread identity, recipient options, personal-reply classification, a targeted reply request, and host intents for leaving the composer or removing its draft | -| `EmailDraftStorage` | Save/delete and restore an undone draft; inputs use domain inbox IDs and completion intent | +| `EmailDraftStorage` | Save/delete and restore an undone draft, and drop a moved draft's working copy; inputs use domain inbox IDs and completion intent | | `EmailAttachmentStorage` | Upload, forward, and remove draft attachments | | `EmailDelivery` | Send, undo, schedule, unschedule, and archive operations | | `EmailComposeFeedback` | User notices and error reporting | @@ -109,7 +109,7 @@ test exercises this ordering. | `EmailEditorFiles` | View-owned editor file upload and sharing integration | | `EmailComposeContext` | Compose capabilities supplied to views, which pass the narrow inputs each controller needs | | `PersistedEmailIdentity` | Successful save/send result: draft, thread, and inbox identity without a transport envelope | -| `EmailComposeHost` | Optional navigation, back handling, and focus movement supplied by the host | +| `EmailComposeHost` | Optional navigation, a moved draft's destination, the route's draft, back handling, and focus movement supplied by the host | Core types are owned by the features. Generated email service schemas and concrete query results stop at adapters. `email-thread/queries/thread-source.ts` uses From aa2f854c66a86a8fbe82d272aa18970ebab40671 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 23:33:26 +0000 Subject: [PATCH 08/75] style(email): split the draft transfer refresh comment into sentences Why: The comment on the deferred cache refresh joined two sentences with a colon. What: Split it into two sentences. Co-authored-by: teo --- apps/web/src/lib/queries/email/draft.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/queries/email/draft.ts b/apps/web/src/lib/queries/email/draft.ts index d36075c3738..2c99f32cdad 100644 --- a/apps/web/src/lib/queries/email/draft.ts +++ b/apps/web/src/lib/queries/email/draft.ts @@ -167,8 +167,8 @@ export async function transferEmailDraft(input: { result = recovery.committed; } // Cache refresh errors must not turn a committed move into a failed write. - // The caller shows the moved draft first: evicting the source thread - // replaces a route still showing it with the not-found page. + // The caller shows the moved draft first. Evicting the source thread + // replaces a route that still shows it with the not-found page. fireAndForget(refreshTransferredThreads(result), 'email-draft-transfer'); return result; } From 9d13328b2e8bf0b8d859f250b88d04221051cd3c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 23:59:37 +0000 Subject: [PATCH 09/75] test(email-compose): cover switching a reply composer between reply and forward Why: A reply sent after Forward on the same message shipped the forwarded-message block. A saved forward reopened from the thread or restored from its local copy came back as a reply, so Reply kept the forward and Forward cleared its recipients. What: Behavior tests for Gmail and Outlook inboxes that send through the reply composer after Forward then Reply, after showing the quote then Forward, and after reopening or restoring a saved forward. Paired tests keep a plain forward and an undone forward sending their forwarded message. The shared reply harness now connects its editor and registers the quote command, as the view does. Co-authored-by: teo --- .../primitives/reply-forward-kind.test.ts | 332 ++++++++++++++++++ .../src/features/email-compose/tests/reply.ts | 5 +- 2 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/features/email-compose/primitives/reply-forward-kind.test.ts diff --git a/apps/web/src/features/email-compose/primitives/reply-forward-kind.test.ts b/apps/web/src/features/email-compose/primitives/reply-forward-kind.test.ts new file mode 100644 index 00000000000..e265365c153 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/reply-forward-kind.test.ts @@ -0,0 +1,332 @@ +import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical'; +import type { LexicalEditor } from 'lexical'; +import { createSignal } from 'solid-js'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { message } from '../../email-message/tests/messages'; +import type { + EmailComposeContext, + EmailDraftRestoration, + EmailInbox, +} from '../context/compose-capabilities'; +import { decodeBase64Utf8 } from '../core/decode-base64'; +import type { EmailRecipient } from '../core/email-recipient'; +import type { ReplyType } from '../core/reply-type'; +import { createComposeContext } from '../tests/capabilities'; +import { createEmailEditor } from '../tests/editor'; +import { mountReplyComposer } from '../tests/reply'; +import { + prepareEmailBody, + registerToggleAppendedThread, + TOGGLE_APPEND_EMAIL_THREAD_COMMAND, +} from './prepare-email-body'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +const parent = message('parent', { + from: { name: 'Ada Lovelace', email: 'ada@example.com' }, + to: [{ email: 'me@example.com' }], + subject: 'Numbers', + body_html_sanitized: '

Original message text

', +}); +const teammate: EmailRecipient = { + kind: 'custom', + id: 'teammate@example.com', + data: { id: 'teammate@example.com', email: 'teammate@example.com', invalid: false }, +}; +const providers = ['GMAIL', 'OUTLOOK'] as const; + +function inboxContext(provider: EmailInbox['provider']) { + const context = createComposeContext(); + context.accounts.inboxes = () => [ + { id: 'inbox', email_address: 'me@example.com', provider, settings: {} }, + ]; + // Undo restores by the sent draft's identity. + context.delivery.sendMessage = vi.fn(async () => ({ + draftId: 'draft', + threadId: 'thread', + inboxId: 'inbox', + })); + return context; +} + +/** The message card's Reply and Forward buttons. */ +function cardActions(initial?: ReplyType) { + const [replyType, setReplyType] = createSignal(initial); + return { + replyType, + clear: () => setReplyType(undefined), + click: async (next: ReplyType) => { + setReplyType(next); + // The composer appends quoted content once the editor registers it. + await vi.advanceTimersByTimeAsync(0); + }, + }; +} + +/** A forward saved from a real editor, as the thread's draft slot returns it. */ +function savedForward() { + const editor = createEmailEditor('FYI, see below'); + registerToggleAppendedThread(editor); + editor.dispatchCommand(TOGGLE_APPEND_EMAIL_THREAD_COMMAND, { + replyingTo: parent, + replyType: 'forward', + visible: true, + }); + return message('draft', { + is_draft: true, + replying_to_id: 'parent', + from: { email: 'me@example.com' }, + to: [{ email: 'teammate@example.com' }], + subject: 'Fwd: Numbers', + body_html_sanitized: prepareEmailBody(editor)!.bodyHtml, + }); +} + +function typeAboveQuote( + reply: ReturnType, + text: string +) { + const editor: LexicalEditor = reply.editor; + editor.update( + () => { + const paragraph = $createParagraphNode().append($createTextNode(text)); + const first = $getRoot().getFirstChild(); + if (first) first.insertBefore(paragraph); + else $getRoot().append(paragraph); + }, + { discrete: true } + ); + reply.onContentChange(text); +} + +function delivered(context: EmailComposeContext, call = 0) { + const sent = vi.mocked(context.delivery.sendMessage).mock.calls[call][0]; + return { + body: decodeBase64Utf8(sent.message.body_html ?? ''), + to: sent.message.to?.map((contact) => contact.email), + subject: sent.message.subject, + }; +} + +it.each(providers)( + 'sends a %s reply without the forward the same composer failed to send', + async (provider) => { + const context = inboxContext(provider); + vi.mocked(context.delivery.sendMessage).mockRejectedValueOnce( + new Error('provider rejected the forward') + ); + const actions = cardActions(); + const reply = mountReplyComposer(context, () => parent, {}, { + replyRequest: actions, + }); + try { + await actions.click('forward'); + reply.form.setRecipients('to', [teammate]); + await reply.sendEmail(); + expect(context.notices.feedback.failure).toHaveBeenCalledWith( + 'Failed to send email' + ); + + await actions.click('reply'); + typeAboveQuote(reply, 'Reply test body'); + await reply.sendEmail(); + + expect(context.delivery.sendMessage).toHaveBeenCalledTimes(2); + const sent = delivered(context, 1); + expect(sent.to).toEqual(['ada@example.com']); + expect(sent.body).toContain('Reply test body'); + expect(sent.body).toContain('wrote:'); + expect(sent.body).not.toContain('Forwarded message'); + } finally { + reply.dispose(); + } + } +); + +it.each(providers)( + 'sends a %s forward with the forwarded message to its recipient', + async (provider) => { + const context = inboxContext(provider); + const actions = cardActions(); + const reply = mountReplyComposer(context, () => parent, {}, { + replyRequest: actions, + }); + try { + await actions.click('forward'); + reply.form.setRecipients('to', [teammate]); + await reply.sendEmail(); + + expect(context.delivery.sendMessage).toHaveBeenCalledOnce(); + const sent = delivered(context); + expect(sent.to).toEqual(['teammate@example.com']); + expect(sent.subject).toBe('Fwd: Numbers'); + expect(sent.body).toContain('Forwarded message'); + expect(sent.body).toContain('Original message text'); + } finally { + reply.dispose(); + } + } +); + +it.each(providers)( + 'forwards a %s reply that was showing its quote with the forwarded message', + async (provider) => { + const context = inboxContext(provider); + const actions = cardActions(); + const reply = mountReplyComposer(context, () => parent, {}, { + replyRequest: actions, + }); + try { + reply.toggleQuotedText(); + await actions.click('forward'); + reply.form.setRecipients('to', [teammate]); + await reply.sendEmail(); + + expect(context.delivery.sendMessage).toHaveBeenCalledOnce(); + const sent = delivered(context); + expect(sent.body).toContain('Forwarded message'); + expect(sent.body).not.toContain('wrote:'); + } finally { + reply.dispose(); + } + } +); + +it.each(providers)( + 'turns a saved %s forward into a reply when Reply reopens its slot', + async (provider) => { + const context = inboxContext(provider); + const draft = savedForward(); + const reply = mountReplyComposer( + context, + () => parent, + { + draft, + preloadedHtml: decodeBase64Utf8(draft.body_html_sanitized ?? ''), + }, + { replyRequest: cardActions('reply') } + ); + try { + await vi.advanceTimersByTimeAsync(0); + await reply.sendEmail(); + + expect(context.delivery.sendMessage).toHaveBeenCalledOnce(); + const sent = delivered(context); + expect(sent.to).toEqual(['ada@example.com']); + expect(sent.subject).toBe('Re: Numbers'); + expect(sent.body).toContain('FYI, see below'); + expect(sent.body).toContain('wrote:'); + expect(sent.body).not.toContain('Forwarded message'); + } finally { + reply.dispose(); + } + } +); + +it.each(providers)( + 'keeps a saved %s forward and its recipients when Forward reopens its slot', + async (provider) => { + const context = inboxContext(provider); + const draft = savedForward(); + const reply = mountReplyComposer( + context, + () => parent, + { + draft, + preloadedHtml: decodeBase64Utf8(draft.body_html_sanitized ?? ''), + }, + { replyRequest: cardActions('forward') } + ); + try { + await vi.advanceTimersByTimeAsync(0); + await reply.sendEmail(); + + expect(context.notices.feedback.failure).not.toHaveBeenCalled(); + expect(context.delivery.sendMessage).toHaveBeenCalledOnce(); + const sent = delivered(context); + expect(sent.to).toEqual(['teammate@example.com']); + expect(sent.subject).toBe('Fwd: Numbers'); + expect(sent.body).toContain('FYI, see below'); + expect(sent.body.match(/Forwarded message/g)).toHaveLength(1); + } finally { + reply.dispose(); + } + } +); + +it.each(providers)( + 'keeps a %s forward restored from its local copy a forward', + async (provider) => { + const context = inboxContext(provider); + let restore: ((change: EmailDraftRestoration) => void) | undefined; + context.drafts.watchRestorations = (listener) => { + restore = listener; + return () => {}; + }; + context.drafts.readDraft = vi.fn(async () => ({ + draft: savedForward(), + persistence: 'committed' as const, + })); + const actions = cardActions(); + const reply = mountReplyComposer(context, () => parent, {}, { + replyRequest: actions, + }); + try { + restore?.({ + draftId: 'draft', + originalDraftId: 'draft', + threadId: 'thread', + inboxId: 'inbox', + replyingToId: 'parent', + }); + await vi.waitFor(() => expect(reply.savedDraftId()).toBe('draft')); + + await actions.click('forward'); + await reply.sendEmail(); + + expect(context.notices.feedback.failure).not.toHaveBeenCalled(); + expect(context.delivery.sendMessage).toHaveBeenCalledOnce(); + const sent = delivered(context); + expect(sent.to).toEqual(['teammate@example.com']); + expect(sent.body.match(/Forwarded message/g)).toHaveLength(1); + } finally { + reply.dispose(); + } + } +); + +it.each(providers)( + 'reopens an undone %s forward as a forward', + async (provider) => { + const context = inboxContext(provider); + context.delivery.undoSend = vi.fn(async (input) => { + await input.onUndone(); + }); + const actions = cardActions(); + const reply = mountReplyComposer(context, () => parent, {}, { + replyRequest: actions, + }); + try { + await actions.click('forward'); + reply.form.setRecipients('to', [teammate]); + await reply.sendEmail(); + const undo = vi + .mocked(context.notices.feedback.success) + .mock.calls.flatMap(([, options]) => options?.actions ?? []) + .find((action) => action.label === 'Undo'); + undo?.onClick(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(0); + + await reply.sendEmail(); + + expect(context.delivery.sendMessage).toHaveBeenCalledTimes(2); + const sent = delivered(context, 1); + expect(sent.to).toEqual(['teammate@example.com']); + expect(sent.body.match(/Forwarded message/g)).toHaveLength(1); + } finally { + reply.dispose(); + } + } +); diff --git a/apps/web/src/features/email-compose/tests/reply.ts b/apps/web/src/features/email-compose/tests/reply.ts index f9e10663383..d7cf69b7015 100644 --- a/apps/web/src/features/email-compose/tests/reply.ts +++ b/apps/web/src/features/email-compose/tests/reply.ts @@ -3,13 +3,14 @@ import { message } from '../../email-message/tests/messages'; import type { EmailComposeContext } from '../context/compose-capabilities'; import type { EmailReplySession } from '../context/email-form-inputs'; import { createEmailFormState } from '../primitives/email-form-state'; +import { registerToggleAppendedThread } from '../primitives/prepare-email-body'; import { createReplyComposer, type ReplyComposerOptions, } from '../primitives/reply-composer'; import { createEmailEditor, setEmailEditorText } from './editor'; -/** A reply composer on a real editor, addressed to a thread of one message. */ +/** A reply composer on a real, connected editor, addressed to a thread of one message. */ export function mountReplyComposer( composeContext: EmailComposeContext, replyingTo = () => message('parent'), @@ -28,6 +29,7 @@ export function mountReplyComposer( ) { return createRoot((dispose) => { const editor = createEmailEditor('Ready to send'); + registerToggleAppendedThread(editor); const parent = replyingTo(); const form = createEmailFormState( { @@ -67,6 +69,7 @@ export function mountReplyComposer( { container: () => undefined, footer: () => undefined }, () => form ); + state.handleEditorConnect(); state.onContentChange('Ready to send'); return { ...state, From 8804859ce18414b88b3f68d0c0e6274fff8175da Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 00:11:28 +0000 Subject: [PATCH 10/75] fix(email-compose): reopen a saved forward as a forward Why: No draft field records whether a draft is a forward, and the reply composer always seeded reply or reply all. A forward reopened from the thread, restored from its local copy, or brought back by Undo said "Replying to", and Forward then cleared its recipients. What: Read the kind from the forwarded-message header outside any blockquote, so a reply quoting a forwarded message stays a reply. The composer takes the kind from the body its editor loads, a restored draft, or an undo snapshot, keeping reply versus reply all otherwise. A test keeps a reopened reply that quotes a forwarded message a reply. Co-authored-by: teo --- .../features/email-compose/core/reply-type.ts | 14 ++ .../primitives/email-form-state.ts | 32 +++-- .../primitives/prepare-email-body.ts | 4 +- .../primitives/reply-composer.ts | 4 + .../primitives/reply-forward-kind.test.ts | 135 ++++++++++++++---- 5 files changed, 155 insertions(+), 34 deletions(-) diff --git a/apps/web/src/features/email-compose/core/reply-type.ts b/apps/web/src/features/email-compose/core/reply-type.ts index 24fbe7b8f6b..6dd249c25b7 100644 --- a/apps/web/src/features/email-compose/core/reply-type.ts +++ b/apps/web/src/features/email-compose/core/reply-type.ts @@ -1 +1,15 @@ export type ReplyType = 'reply' | 'reply-all' | 'forward'; + +export const FORWARDED_MESSAGE_HEADER = + '---------- Forwarded message ----------'; + +/** + * Whether an email body forwards a message. Provider sync rewrites markup, so + * this reads the header's text; a header inside a quote belongs to the quoted + * message. + */ +export function forwardsMessage(html: string) { + const { body } = new DOMParser().parseFromString(html, 'text/html'); + for (const quote of body.querySelectorAll('blockquote')) quote.remove(); + return body.textContent?.includes(FORWARDED_MESSAGE_HEADER) ?? false; +} diff --git a/apps/web/src/features/email-compose/primitives/email-form-state.ts b/apps/web/src/features/email-compose/primitives/email-form-state.ts index 10241399b18..294b16b5768 100644 --- a/apps/web/src/features/email-compose/primitives/email-form-state.ts +++ b/apps/web/src/features/email-compose/primitives/email-form-state.ts @@ -16,7 +16,7 @@ import { getReplyAllRecipients, getReplyRecipientsFromParent, } from '../core/recipient-conversion'; -import type { ReplyType } from '../core/reply-type'; +import { forwardsMessage, type ReplyType } from '../core/reply-type'; import { getSubjectText } from '../core/subject-text'; export type DraftFormAttachment = @@ -157,21 +157,26 @@ export function createEmailFormState( ].filter((address): address is string => !!address); const [recipientsEdited, setRecipientsEdited] = createSignal(false); - const draftContainsAppendedReply = (message = draft) => { + const decodedBody = (message?: EmailMessage) => { const encoded = message?.body_html_sanitized; - if (!encoded) return false; - const decodedHtml = decodeBase64Utf8(encoded); + return encoded ? decodeBase64Utf8(encoded) : undefined; + }; + + const draftContainsAppendedReply = (message = draft) => { + const decodedHtml = decodedBody(message); if (!decodedHtml) return false; const parsed = new DOMParser().parseFromString(decodedHtml, 'text/html'); return parsed.body.querySelector('div.macro_quote') !== null; }; + const defaultReplyType = (): ReplyType => + (replyingTo?.to.length ?? 0) + (replyingTo?.cc.length ?? 0) > 1 + ? 'reply-all' + : 'reply'; + const getInitialState = () => { - const replyType = - (replyingTo?.to.length ?? 0) + (replyingTo?.cc.length ?? 0) > 1 - ? 'reply-all' - : 'reply'; + const replyType = defaultReplyType(); let initialSubject = draft?.subject; @@ -346,6 +351,12 @@ export function createEmailFormState( setEditRevision((revision) => revision + 1); }; + // No draft field records whether it is a forward; only its body does. + const restoredReplyType = (bodyHtml: string | undefined): ReplyType => { + if (bodyHtml && forwardsMessage(bodyHtml)) return 'forward'; + return state.replyType === 'forward' ? defaultReplyType() : state.replyType; + }; + const reset = (next: EmailFormState) => { setState(reconcile(next)); const recipients = state.recipients; @@ -364,6 +375,7 @@ export function createEmailFormState( setSenderChoice({ email: message.from?.email }); setRecipientsEdited(true); setState({ + replyType: restoredReplyType(decodedBody(message)), subject: message.subject ?? '', recipients: { to: message.to.map(convertContactInfoToEmailRecipient), @@ -381,6 +393,10 @@ export function createEmailFormState( ]) ); }, + /** Match the kind to a body loaded into the editor, keeping recipients. */ + restoreReplyType(bodyHtml: string) { + setState('replyType', restoredReplyType(bodyHtml)); + }, replyAppended: () => state.withQuotedText, setReplyAppended: (next: boolean) => setState('withQuotedText', next), recipients: () => { diff --git a/apps/web/src/features/email-compose/primitives/prepare-email-body.ts b/apps/web/src/features/email-compose/primitives/prepare-email-body.ts index 8c7f6f5f55c..a7f2776b9b7 100644 --- a/apps/web/src/features/email-compose/primitives/prepare-email-body.ts +++ b/apps/web/src/features/email-compose/primitives/prepare-email-body.ts @@ -31,7 +31,7 @@ import { type LexicalEditor, type LexicalNode, } from 'lexical'; -import type { ReplyType } from '../core/reply-type'; +import { FORWARDED_MESSAGE_HEADER, type ReplyType } from '../core/reply-type'; import { TOGGLE_APPEND_EMAIL_THREAD_COMMAND } from './email-editor-commands'; import { flattenConsecutiveParagraphs } from './flatten-consecutive-paragraphs'; @@ -63,7 +63,7 @@ function buildHeaderDescriptor( if (replyType === 'forward') { const lines: string[] = []; - lines.push('---------- Forwarded message ----------'); + lines.push(FORWARDED_MESSAGE_HEADER); const fromName = replyingTo.from?.name ?? replyingTo.from?.email ?? ''; const fromEmail = replyingTo.from?.email ?? ''; lines.push(`From: ${fromName} <${fromEmail}>`); diff --git a/apps/web/src/features/email-compose/primitives/reply-composer.ts b/apps/web/src/features/email-compose/primitives/reply-composer.ts index 370d1a3a352..713968cfb17 100644 --- a/apps/web/src/features/email-compose/primitives/reply-composer.ts +++ b/apps/web/src/features/email-compose/primitives/reply-composer.ts @@ -435,6 +435,7 @@ export function createReplyComposer( }); restoreEnvelope(snapshot); setRestoredBodyHtml(snapshot.bodyHtml ?? plainTextToHtml('')); + if (snapshot.bodyHtml) form.restoreReplyType(snapshot.bodyHtml); const currentEditor = editor(); if (currentEditor && snapshot.bodyHtml) { setEditorStateFromHtml(currentEditor, snapshot.bodyHtml); @@ -454,6 +455,9 @@ export function createReplyComposer( const initialHtml = () => restoredBodyHtml() ?? restoredSnapshot?.bodyHtml ?? props.preloadedHtml; + // The editor may load a local copy that is newer than the thread's draft. + const loadedHtml = untrack(initialHtml); + if (loadedHtml) form.restoreReplyType(loadedHtml); const [editorConnected, setEditorConnected] = createSignal(false); const handleEditorConnect = () => { const currentEditor = editor(); diff --git a/apps/web/src/features/email-compose/primitives/reply-forward-kind.test.ts b/apps/web/src/features/email-compose/primitives/reply-forward-kind.test.ts index e265365c153..3c178390163 100644 --- a/apps/web/src/features/email-compose/primitives/reply-forward-kind.test.ts +++ b/apps/web/src/features/email-compose/primitives/reply-forward-kind.test.ts @@ -1,5 +1,5 @@ -import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical'; import type { LexicalEditor } from 'lexical'; +import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical'; import { createSignal } from 'solid-js'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { message } from '../../email-message/tests/messages'; @@ -32,7 +32,11 @@ const parent = message('parent', { const teammate: EmailRecipient = { kind: 'custom', id: 'teammate@example.com', - data: { id: 'teammate@example.com', email: 'teammate@example.com', invalid: false }, + data: { + id: 'teammate@example.com', + email: 'teammate@example.com', + invalid: false, + }, }; const providers = ['GMAIL', 'OUTLOOK'] as const; @@ -52,7 +56,9 @@ function inboxContext(provider: EmailInbox['provider']) { /** The message card's Reply and Forward buttons. */ function cardActions(initial?: ReplyType) { - const [replyType, setReplyType] = createSignal(initial); + const [replyType, setReplyType] = createSignal( + initial + ); return { replyType, clear: () => setReplyType(undefined), @@ -64,22 +70,33 @@ function cardActions(initial?: ReplyType) { }; } -/** A forward saved from a real editor, as the thread's draft slot returns it. */ -function savedForward() { - const editor = createEmailEditor('FYI, see below'); +/** A draft saved from a real editor, as the thread's draft slot returns it. */ +function savedDraft( + replyType: ReplyType, + text: string, + quoted = parent, + overrides: Parameters[1] = {} +) { + const editor = createEmailEditor(text); registerToggleAppendedThread(editor); editor.dispatchCommand(TOGGLE_APPEND_EMAIL_THREAD_COMMAND, { - replyingTo: parent, - replyType: 'forward', + replyingTo: quoted, + replyType, visible: true, }); return message('draft', { is_draft: true, replying_to_id: 'parent', from: { email: 'me@example.com' }, + body_html_sanitized: prepareEmailBody(editor)!.bodyHtml, + ...overrides, + }); +} + +function savedForward() { + return savedDraft('forward', 'FYI, see below', parent, { to: [{ email: 'teammate@example.com' }], subject: 'Fwd: Numbers', - body_html_sanitized: prepareEmailBody(editor)!.bodyHtml, }); } @@ -117,9 +134,14 @@ it.each(providers)( new Error('provider rejected the forward') ); const actions = cardActions(); - const reply = mountReplyComposer(context, () => parent, {}, { - replyRequest: actions, - }); + const reply = mountReplyComposer( + context, + () => parent, + {}, + { + replyRequest: actions, + } + ); try { await actions.click('forward'); reply.form.setRecipients('to', [teammate]); @@ -149,9 +171,14 @@ it.each(providers)( async (provider) => { const context = inboxContext(provider); const actions = cardActions(); - const reply = mountReplyComposer(context, () => parent, {}, { - replyRequest: actions, - }); + const reply = mountReplyComposer( + context, + () => parent, + {}, + { + replyRequest: actions, + } + ); try { await actions.click('forward'); reply.form.setRecipients('to', [teammate]); @@ -174,9 +201,14 @@ it.each(providers)( async (provider) => { const context = inboxContext(provider); const actions = cardActions(); - const reply = mountReplyComposer(context, () => parent, {}, { - replyRequest: actions, - }); + const reply = mountReplyComposer( + context, + () => parent, + {}, + { + replyRequest: actions, + } + ); try { reply.toggleQuotedText(); await actions.click('forward'); @@ -269,9 +301,14 @@ it.each(providers)( persistence: 'committed' as const, })); const actions = cardActions(); - const reply = mountReplyComposer(context, () => parent, {}, { - replyRequest: actions, - }); + const reply = mountReplyComposer( + context, + () => parent, + {}, + { + replyRequest: actions, + } + ); try { restore?.({ draftId: 'draft', @@ -304,9 +341,14 @@ it.each(providers)( await input.onUndone(); }); const actions = cardActions(); - const reply = mountReplyComposer(context, () => parent, {}, { - replyRequest: actions, - }); + const reply = mountReplyComposer( + context, + () => parent, + {}, + { + replyRequest: actions, + } + ); try { await actions.click('forward'); reply.form.setRecipients('to', [teammate]); @@ -319,8 +361,10 @@ it.each(providers)( await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0); + await actions.click('forward'); await reply.sendEmail(); + expect(context.notices.feedback.failure).not.toHaveBeenCalled(); expect(context.delivery.sendMessage).toHaveBeenCalledTimes(2); const sent = delivered(context, 1); expect(sent.to).toEqual(['teammate@example.com']); @@ -330,3 +374,46 @@ it.each(providers)( } } ); + +it.each(providers)( + 'keeps a reopened %s reply that quotes a forwarded message a reply', + async (provider) => { + const context = inboxContext(provider); + const forwardedToMe = message('parent', { + ...parent, + body_html_sanitized: + '

Passing this along

---------- Forwarded message ----------
From: Bob

Bob wrote this

', + }); + const draft = savedDraft('reply', 'Thanks, noted', forwardedToMe, { + to: [{ email: 'ada@example.com' }], + cc: [{ email: 'teammate@example.com' }], + subject: 'Re: Numbers', + }); + const reply = mountReplyComposer( + context, + () => forwardedToMe, + { + draft, + preloadedHtml: decodeBase64Utf8(draft.body_html_sanitized ?? ''), + }, + { replyRequest: cardActions('reply') } + ); + try { + await vi.advanceTimersByTimeAsync(0); + await reply.sendEmail(); + + expect(context.delivery.sendMessage).toHaveBeenCalledOnce(); + const sent = delivered(context); + const { message: sentMessage } = vi.mocked(context.delivery.sendMessage) + .mock.calls[0][0]; + expect(sent.to).toEqual(['ada@example.com']); + expect(sentMessage.cc?.map((contact) => contact.email)).toEqual([ + 'teammate@example.com', + ]); + expect(sent.body).toContain('wrote:'); + expect(sent.body).toContain('Bob wrote this'); + } finally { + reply.dispose(); + } + } +); From 074e2e9492147b6656c425bd23f297844a77e0a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 00:11:43 +0000 Subject: [PATCH 11/75] fix(email-compose): swap the quoted thread when switching reply and forward Why: Changing between Reply and Forward only rewrote the envelope. The forwarded message stayed in the editor and was sent with the reply, and a reply showing its quote kept that quote when it became a forward. What: When the kind changes, the composer reads the appended thread's header and removes it if it belongs to the other kind. A new reply appends its quote again at send, and a forward appends the forwarded message, collapsing it only when newly added. A body the editor loads is left as it is. Co-authored-by: teo --- .../primitives/prepare-email-body.ts | 21 +++++- .../primitives/reply-composer.ts | 67 +++++++++++++------ .../primitives/reply-forward-kind.test.ts | 36 ++++++++++ 3 files changed, 103 insertions(+), 21 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/prepare-email-body.ts b/apps/web/src/features/email-compose/primitives/prepare-email-body.ts index a7f2776b9b7..7dafd47dfcc 100644 --- a/apps/web/src/features/email-compose/primitives/prepare-email-body.ts +++ b/apps/web/src/features/email-compose/primitives/prepare-email-body.ts @@ -8,7 +8,7 @@ import { convertDocumentMentionsToLinks } from '@core/component/LexicalMarkdown/ import { staticFileReferenceEndpoint } from '@core/constant/servers'; import { formatEmailDate } from '@core/util/date'; import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'; -import { $createQuoteNode } from '@lexical/rich-text'; +import { $createQuoteNode, $isQuoteNode } from '@lexical/rich-text'; import { $dfsIterator } from '@lexical/utils'; import { sanitizeEmailHtml } from '@macro-inc/email-renderer'; import type { DocumentMentionInfo } from '@macro-inc/lexical-core'; @@ -262,6 +262,25 @@ function removeAppendedThread( ); } +/** Whether the appended thread forwards or quotes, read from its header. */ +export function appendedThreadKind( + editor: LexicalEditor, + replyingToId: string +): 'forward' | 'reply' | undefined { + return editor.read(() => { + for (const node of $findPreviousEmailNode(replyingToId)) { + if (!node) continue; + const header = node + .getChildren() + .filter((child) => !$isQuoteNode(child)) + .map((child) => child.getTextContent()) + .join(''); + return header.includes(FORWARDED_MESSAGE_HEADER) ? 'forward' : 'reply'; + } + return undefined; + }); +} + export function registerToggleAppendedThread(editor: LexicalEditor) { return editor.registerCommand( TOGGLE_APPEND_EMAIL_THREAD_COMMAND, diff --git a/apps/web/src/features/email-compose/primitives/reply-composer.ts b/apps/web/src/features/email-compose/primitives/reply-composer.ts index 713968cfb17..484522e3899 100644 --- a/apps/web/src/features/email-compose/primitives/reply-composer.ts +++ b/apps/web/src/features/email-compose/primitives/reply-composer.ts @@ -50,6 +50,7 @@ import { convertContactInfoToEmailRecipient, convertEmailRecipientToContactInfo, } from '../core/recipient-conversion'; +import type { ReplyType } from '../core/reply-type'; import { partitionByAttachmentLimit } from './attachment-limit'; import { createAttachmentLinkOffer } from './attachment-links'; import { @@ -71,6 +72,7 @@ import type { EmailFormContextValue, FormAccessKey } from './email-form-types'; import { createEmailSendSchedule } from './email-send-schedule'; import { addUserMentionToCc } from './mention-to-cc'; import { + appendedThreadKind, clearEmailBody, hasDraftContent, prepareEmailBody, @@ -525,40 +527,65 @@ export function createReplyComposer( on(form.editRevision, () => scheduleDraftSave(), { defer: true }) ); + // A forward carries the forwarded message and a reply never does, so a + // kind change swaps whichever thread the other kind appended. + const matchAppendedThread = ( + currentEditor: LexicalEditor, + message: EmailMessage, + rt: ReplyType, + kindChanged: boolean + ) => { + const forwarding = rt === 'forward'; + const appended = kindChanged + ? appendedThreadKind(currentEditor, message.db_id) + : undefined; + if (appended && (appended === 'forward') !== forwarding) { + currentEditor.dispatchCommand(TOGGLE_APPEND_EMAIL_THREAD_COMMAND, { + replyingTo: message, + replyType: rt, + visible: false, + }); + if (!forwarding) form.setReplyAppended(false); + } + if (!forwarding || !form.replyAppended()) return; + if (appendedThreadKind(currentEditor, message.db_id)) return; + setQuoteCollapsed(true); + currentEditor.dispatchCommand(TOGGLE_APPEND_EMAIL_THREAD_COMMAND, { + replyingTo: message, + replyType: rt, + visible: true, + isPersonal: ctx.isPersonalReply(), + }); + }; + // The mounted composer owns focus, editor commands, and their cleanup. + let shownReplyType: ReplyType | undefined; createEffect(() => { const rt = form.replyType(); if (!editorConnected()) return; + // A loaded body already matches its kind; only later changes swap threads. + const kindChanged = shownReplyType !== undefined && shownReplyType !== rt; + shownReplyType = rt; // Later reply-type changes are the user's, and always take focus. const takeFocus = mountFocus; mountFocus = true; untrack(() => { setComposerExpanded(false); if (rt === 'forward') { - setQuoteCollapsed(true); if (takeFocus) focus.forward(); - const message = replyTarget; - const currentEditor = editor(); - if (message && currentEditor && form.replyAppended()) { - // The editor's lazy command registration completes after this batch. - const timer = setTimeout( - () => - currentEditor.dispatchCommand( - TOGGLE_APPEND_EMAIL_THREAD_COMMAND, - { - replyingTo: message, - replyType: rt, - visible: true, - isPersonal: ctx.isPersonalReply(), - } - ), - 0 - ); - onCleanup(() => clearTimeout(timer)); - } } else if (takeFocus) { focus.reply(); } + const message = replyTarget; + const currentEditor = editor(); + if (!message || !currentEditor) return; + if (!kindChanged && (rt !== 'forward' || !form.replyAppended())) return; + // The editor's lazy command registration completes after this batch. + const timer = setTimeout( + () => matchAppendedThread(currentEditor, message, rt, kindChanged), + 0 + ); + onCleanup(() => clearTimeout(timer)); }); }); diff --git a/apps/web/src/features/email-compose/primitives/reply-forward-kind.test.ts b/apps/web/src/features/email-compose/primitives/reply-forward-kind.test.ts index 3c178390163..8461f852d6f 100644 --- a/apps/web/src/features/email-compose/primitives/reply-forward-kind.test.ts +++ b/apps/web/src/features/email-compose/primitives/reply-forward-kind.test.ts @@ -417,3 +417,39 @@ it.each(providers)( } } ); + +it.each(providers)( + 'drops a %s forward newer than the thread draft when Reply is clicked', + async (provider) => { + const context = inboxContext(provider); + // The thread still holds the saved reply; the editor loads the newer + // local copy, which became a forward. + const draft = savedDraft('reply', 'FYI, see below', parent, { + to: [{ email: 'ada@example.com' }], + subject: 'Re: Numbers', + }); + const localCopy = decodeBase64Utf8( + savedForward().body_html_sanitized ?? '' + ); + const actions = cardActions(); + const reply = mountReplyComposer( + context, + () => parent, + { draft, preloadedHtml: localCopy }, + { replyRequest: actions } + ); + try { + await vi.advanceTimersByTimeAsync(0); + await actions.click('reply'); + await reply.sendEmail(); + + expect(context.delivery.sendMessage).toHaveBeenCalledOnce(); + const sent = delivered(context); + expect(sent.to).toEqual(['ada@example.com']); + expect(sent.body).toContain('FYI, see below'); + expect(sent.body).not.toContain('Forwarded message'); + } finally { + reply.dispose(); + } + } +); From 0da920dbcb5697d6275292b1b963cb09b21c84fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 00:11:50 +0000 Subject: [PATCH 12/75] fix(email-compose): name the quoted content a forward or reply carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: A collapsed forward showed only an unlabeled "⋯" button, so it looked empty, and its placeholder still started with "Reply". What: The collapsed button reads "Show forwarded message" or "Show quoted text", the show and hide toggle gets the same accessible name, and the placeholder starts with "Forward" on a forward. Co-authored-by: teo --- .../email-compose/views/reply-input.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/web/src/features/email-compose/views/reply-input.tsx b/apps/web/src/features/email-compose/views/reply-input.tsx index a6fd38844b7..3d64a800376 100644 --- a/apps/web/src/features/email-compose/views/reply-input.tsx +++ b/apps/web/src/features/email-compose/views/reply-input.tsx @@ -212,6 +212,10 @@ function LoadedReplyInputView(props: ReplyInputViewProps) { composeContext.presentation.isTouch() && !state.hasBodyText() && state.replyType() !== 'forward'; + const quotedContent = () => + state.replyType() === 'forward' ? 'forwarded message' : 'quoted text'; + const quoteToggleLabel = () => + `${form.replyAppended() ? 'Hide' : 'Show'} ${quotedContent()}`; const signatureHtml = () => composeContext.presentation.signaturesEnabled() ? state.signatureHtml() @@ -672,7 +676,7 @@ function LoadedReplyInputView(props: ReplyInputViewProps) { placeholder={ isMobileCompose() ? 'Use `@` to reference files' - : 'Reply — @mention to share or cc people' + : `${state.replyType() === 'forward' ? 'Forward' : 'Reply'} — @mention to share or cc people` } portalScope={isMobileCompose() ? 'local' : 'split'} refFn={(el) => props.markdownDomRef?.(el)} @@ -718,9 +722,8 @@ function LoadedReplyInputView(props: ReplyInputViewProps) {
@@ -743,13 +747,10 @@ function LoadedReplyInputView(props: ReplyInputViewProps) { data-corvu-no-drag="" onClick={(e) => e.stopPropagation()} > - + Date: Sun, 25 Oct 2026 00:13:16 +0000 Subject: [PATCH 13/75] test(email-compose): keep a provider-rejected draft movable to another inbox Why: Changing the From inbox is how a user recovers a draft the provider rejected. The From switch reuses the send gate, so blocking delivery of rejected content would also block moving it. What: A regression test for compose and reply drafts. A FAILED draft with invalid content moves to another inbox, and a draft with an unfinished move or a draft conflict stays in its inbox. Co-authored-by: teo --- .../rejected-draft-inbox-switch.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 apps/web/src/features/email-compose/primitives/rejected-draft-inbox-switch.test.ts diff --git a/apps/web/src/features/email-compose/primitives/rejected-draft-inbox-switch.test.ts b/apps/web/src/features/email-compose/primitives/rejected-draft-inbox-switch.test.ts new file mode 100644 index 00000000000..ceee2e66b61 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/rejected-draft-inbox-switch.test.ts @@ -0,0 +1,103 @@ +import { createSignal } from 'solid-js'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { MessageOperation } from '../../email-message/core/message-operation'; +import { message } from '../../email-message/tests/messages'; +import { createComposeContext } from '../tests/capabilities'; +import { mountEmailComposer } from '../tests/composer'; +import { mountReplyComposer } from '../tests/reply'; + +beforeEach(() => vi.useFakeTimers({ now: new Date('2026-09-01T12:00:00Z') })); +afterEach(() => vi.useRealTimers()); + +function mountDraft(kind: 'compose' | 'reply', seeded: MessageOperation) { + const context = createComposeContext(); + context.accounts.inboxes = () => [ + { id: 'inbox', email_address: 'me@outlook.example', settings: {} }, + { id: 'other', email_address: 'me@gmail.example', settings: {} }, + ]; + const [operation] = createSignal(seeded); + context.operations = () => ({ operation, resolve: vi.fn() }); + context.setDraftLifecycle({ + type: 'editing', + draftId: 'draft', + threadId: 'thread', + inboxId: 'inbox', + observedAt: Date.now(), + }); + const draft = message('draft', { + is_draft: true, + replying_to_id: kind === 'reply' ? 'parent' : null, + operation: seeded, + }); + const compose = + kind === 'compose' + ? mountEmailComposer(context, undefined, { draft }) + : undefined; + const reply = + kind === 'reply' + ? mountReplyComposer(context, undefined, { draft }) + : undefined; + return { + context, + async switchInbox() { + const switched = compose + ? compose.state.context.onSelectInbox?.('other') + : reply?.persistDraftOnSenderSwitch('other'); + await vi.advanceTimersByTimeAsync(0); + await switched; + }, + dispose() { + compose?.dispose(); + reply?.dispose(); + }, + }; +} + +it.each(['compose', 'reply'] as const)( + 'moves a %s draft the provider rejected to another inbox', + async (kind) => { + const draft = mountDraft(kind, { + state: 'FAILED', + issue: 'INVALID_CONTENT', + revision: '3', + }); + try { + await draft.switchInbox(); + expect(draft.context.drafts.transferDraft).toHaveBeenCalledWith( + expect.objectContaining({ + draftId: 'draft', + sourceInboxId: 'inbox', + destinationInboxId: 'other', + }) + ); + } finally { + draft.dispose(); + } + } +); + +it.each([ + [ + 'compose', + 'an unfinished move', + { state: 'PENDING', issue: 'MOVE_PENDING' }, + ], + ['reply', 'an unfinished move', { state: 'PENDING', issue: 'MOVE_PENDING' }], + [ + 'compose', + 'a draft conflict', + { state: 'CONFLICT', issue: 'DRAFT_CONFLICT' }, + ], + ['reply', 'a draft conflict', { state: 'CONFLICT', issue: 'DRAFT_CONFLICT' }], +] as const)( + 'keeps a %s draft with %s in its inbox', + async (kind, _label, seeded) => { + const draft = mountDraft(kind, { ...seeded, revision: '3' }); + try { + await draft.switchInbox(); + expect(draft.context.drafts.transferDraft).not.toHaveBeenCalled(); + } finally { + draft.dispose(); + } + } +); From 2ef4e8e022e001a8da9e54f81d30de089ec34d01 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 00:14:49 +0000 Subject: [PATCH 14/75] fix(email-compose): gate the From switch on its own operation predicate Why: The compose and reply From switches reused the send gate. Once provider-rejected content blocks delivery, that gate would also block moving the draft, which is how the user recovers it. What: operationBlocksInboxSwitch blocks edit-blocking states and unfinished moves and allows a FAILED draft with invalid content. Both From switches use it instead of the send gate. Co-authored-by: teo --- .../features/email-compose/primitives/email-composer.ts | 6 +++++- .../features/email-compose/primitives/reply-composer.ts | 6 +++++- .../src/features/email-message/core/message-operation.ts | 9 +++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts index 6d0f3bae299..9fb6a400176 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -26,6 +26,7 @@ import type { } from '../../email-message/core/email-message'; import { operationBlocksEditing, + operationBlocksInboxSwitch, operationBlocksSending, operationSending, } from '../../email-message/core/message-operation'; @@ -493,6 +494,9 @@ export function createEmailComposer(props: EmailComposerOptions) { const deliveryBlocked = () => operationSource?.needsReload?.() || operationBlocksSending(currentOperation()); + const inboxSwitchBlocked = () => + operationSource?.needsReload?.() || + operationBlocksInboxSwitch(currentOperation()); const operationBlocked = () => operationSource?.needsReload?.() || operationBlocksEditing(currentOperation()); @@ -1302,7 +1306,7 @@ export function createEmailComposer(props: EmailComposerOptions) { if ( sendLocked() || restoring() || - (!draftTransfer?.pending() && deliveryBlocked()) || + (!draftTransfer?.pending() && inboxSwitchBlocked()) || submitting() || discarding() || movingInbox() || diff --git a/apps/web/src/features/email-compose/primitives/reply-composer.ts b/apps/web/src/features/email-compose/primitives/reply-composer.ts index 484522e3899..4723c02c506 100644 --- a/apps/web/src/features/email-compose/primitives/reply-composer.ts +++ b/apps/web/src/features/email-compose/primitives/reply-composer.ts @@ -27,6 +27,7 @@ import { import type { MessageOperationSourceFactory } from '../../email-message/context/message-operation-source'; import { operationBlocksEditing, + operationBlocksInboxSwitch, operationBlocksSending, operationSending, } from '../../email-message/core/message-operation'; @@ -312,6 +313,9 @@ export function createReplyComposer( const deliveryBlocked = () => operationSource?.needsReload?.() || operationBlocksSending(currentOperation()); + const inboxSwitchBlocked = () => + operationSource?.needsReload?.() || + operationBlocksInboxSwitch(currentOperation()); function operationBlocked() { return ( operationSource?.needsReload?.() || @@ -885,7 +889,7 @@ export function createReplyComposer( const saveSelectedInbox = async (inboxId: string, senderEmail?: string) => { if ( restoring() || - (!draftTransfer?.pending() && deliveryBlocked()) || + (!draftTransfer?.pending() && inboxSwitchBlocked()) || submitting() || pendingDeletion() || movingInbox() || diff --git a/apps/web/src/features/email-message/core/message-operation.ts b/apps/web/src/features/email-message/core/message-operation.ts index 6b96118267e..f4b4c3ab5bb 100644 --- a/apps/web/src/features/email-message/core/message-operation.ts +++ b/apps/web/src/features/email-message/core/message-operation.ts @@ -51,6 +51,15 @@ export function operationBlocksEditing( ); } +/** Moving a draft is how rejected content recovers, but not mid-move. */ +export function operationBlocksInboxSwitch( + operation?: MessageOperation | null +): boolean { + return ( + operationBlocksEditing(operation) || !!operation?.issue?.startsWith('MOVE_') + ); +} + export function operationActions( operation: MessageOperation ): MessageResolutionAction[] { From fd403a627147bd118062cbe184619eb428eec652 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 00:19:06 +0000 Subject: [PATCH 15/75] docs(email): describe reply and forward switching and rejected-draft moves Why: The agent guide did not say how a composer behaves when it switches between Reply and Forward, how a reopened forward should look, or that a draft the provider rejected can still change its sender inbox. What: Add checks for switching one message's composer between Reply and Forward, for forwards reopened from the thread, a local copy, or Undo, and for the named collapsed-content control. Note which drafts can switch From. Co-authored-by: teo --- docs/agent-guide/email.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/agent-guide/email.md b/docs/agent-guide/email.md index df81784df46..24adc98681e 100644 --- a/docs/agent-guide/email.md +++ b/docs/agent-guide/email.md @@ -84,6 +84,9 @@ provider copy before choosing an explicit resend; a resend in flight shows as the send button's pending state. Switching the sender inbox copies the draft and attachments into the destination; wait for completion before sending. If the copy is interrupted, use retry or keep the original. +A draft the provider could not save can still switch From, which moves it to an +inbox that may accept it; a draft with an unfinished move or a conflict stays in +its inbox until that is resolved. A draft opened from Drafts then shows its thread in the destination inbox, never the not-found page; a draft in compose stays open there. Keep editing after the switch and check that no "This draft changed while saving" notice @@ -487,6 +490,15 @@ inbox, saving, and reopening the draft. Send must fetch the original files from the receiving inbox while using the selected From inbox for delivery. A scheduled cross-inbox forward must not download a source whose inbox access was revoked after saving. +Switch one message's composer between Reply and Forward on Gmail and Outlook. +A reply sends without the forwarded message, including after a forward from that +composer failed to send. A reply showing its quoted text sends the forwarded +message once switched to Forward. A saved forward reopened from its thread, its +local copy, or Undo still reads **Forwarding** with a `Forward — @mention…` +placeholder: Forward keeps its recipients, and Reply turns it into a reply +without the forwarded message. A reply quoting a message that was itself +forwarded stays a reply. Collapsed content is named: **Show forwarded message** +on a forward, **Show quoted text** on a reply. When checking draft autosave, edit the body of a draft with uploaded or forwarded attachments, wait for the save, and reopen it; the attachments should remain visible. In a long thread, scroll away from the bottom while a draft save is pending. From 9b52720d593dd0fad4525206bb518a573d3fa172 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 00:55:16 +0000 Subject: [PATCH 16/75] test(recipients): rank the typed address and name matches first Why: pressing Enter after typing a full address committed whichever suggestion recency put first, so teo@macro.com became teo+123456@macro.com. A contact whose name starts with the query sank below recent substring matches, and the same-domain boost followed the Macro login rather than the inbox the mail goes out from. What: drive the real combobox for a Gmail and an Outlook sending inbox and assert what Enter commits, where a name-prefix contact lands for "gab", and which colleague opens first on ArrowDown. Co-authored-by: teo --- .../RecipientSelector.ranking.test.tsx | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 apps/web/src/lib/core/component/RecipientSelector.ranking.test.tsx diff --git a/apps/web/src/lib/core/component/RecipientSelector.ranking.test.tsx b/apps/web/src/lib/core/component/RecipientSelector.ranking.test.tsx new file mode 100644 index 00000000000..20e7ab62895 --- /dev/null +++ b/apps/web/src/lib/core/component/RecipientSelector.ranking.test.tsx @@ -0,0 +1,180 @@ +import type { CombinedRecipientItem, WithCustomUserInput } from '@core/user'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@solidjs/testing-library'; +import { createSignal, type JSX } from 'solid-js'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { RecipientSelector } from './RecipientSelector'; + +vi.mock('@core/context/user', () => ({ + useEmail: () => () => 'me@macro.com', + useUserId: () => () => 'macro|me@macro.com', +})); +vi.mock('@core/user/dmActivity', () => ({ + useAugmentUserWithDmActivity: () => (user: unknown) => user, +})); +vi.mock('@core/component/HoverCard', () => ({ + HoverCard: (props: { trigger: JSX.Element }) => <>{props.trigger}, +})); +vi.mock('@core/component/UserIcon', () => ({ UserIcon: () => null })); +// jsdom has no layout, so the virtualized list would render no options. +vi.mock('virtua/solid', async () => { + const { For } = await import('solid-js'); + return { + VList: (props: { + data: unknown[]; + children: (item: unknown) => JSX.Element; + }) => {props.children}, + }; +}); + +beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +// Sending inboxes on domains other than the Macro login's. +const inboxes = [ + { provider: 'Gmail', address: 'me@northwind.dev' }, + { provider: 'Outlook', address: 'me@macro420.onmicrosoft.com' }, +] as const; + +const recent = new Date(Date.now() - 60 * 60 * 1000); + +const contact = (email: string, name: string): CombinedRecipientItem => ({ + kind: 'contact', + id: `macro|${email}`, + data: { id: `macro|${email}`, email, name, type: 'extracted' }, +}); + +const user = ( + email: string, + name: string, + lastInteraction?: Date +): CombinedRecipientItem => ({ + kind: 'user', + id: `macro|${email}`, + data: { id: `macro|${email}`, email, name, lastInteraction }, +}); + +function renderSelector(options: CombinedRecipientItem[], selfEmail: string) { + const [selected, setSelected] = createSignal([]); + render(() => ( + options} + selfEmail={selfEmail} + selectedOptions={selected()} + setSelectedOptions={setSelected} + openOnFocus={false} + /> + )); + const input = screen.getByRole('combobox'); + return { + input, + selectedEmails: () => + selected().flatMap((option) => + 'email' in option.data ? [option.data.email] : [] + ), + async suggestions() { + await screen.findByRole('listbox'); + return screen.getAllByRole('option').map((o) => o.textContent ?? ''); + }, + }; +} + +it.each(inboxes)( + 'commits the exact known address typed in a $provider inbox on Enter', + async ({ address }) => { + const selector = renderSelector( + [ + user('teo+123456@macro.com', 'Teo QA', recent), + contact('teo@macro.com', 'Teo Snyder'), + ], + address + ); + fireEvent.input(selector.input, { target: { value: 'teo@macro.com' } }); + await selector.suggestions(); + fireEvent.keyDown(selector.input, { key: 'Enter' }); + + await waitFor(() => + expect(selector.selectedEmails()).toEqual(['teo@macro.com']) + ); + } +); + +it.each(inboxes)( + 'commits a typed address no one is saved under in a $provider inbox on Enter', + async ({ address }) => { + const selector = renderSelector( + [user('teo+123456@macro.com', 'Teo QA', recent)], + address + ); + fireEvent.input(selector.input, { target: { value: 'teo@macro.com' } }); + await selector.suggestions(); + fireEvent.keyDown(selector.input, { key: 'Enter' }); + + await waitFor(() => + expect(selector.selectedEmails()).toEqual(['teo@macro.com']) + ); + } +); + +it.each(inboxes)( + 'suggests a contact whose name starts with the query near the top in a $provider inbox', + async ({ address }) => { + const selector = renderSelector( + [ + user('eric.ngabo@macro.com', 'Eric Ngabo', recent), + user('ligaba.t@macro.com', 'Ligaba Tesfaye', recent), + user('gabriel@macro.com', 'Gabriel Ortiz', recent), + contact('tickets@megabus.com', 'Megabus'), + contact('orders@vagabond.coffee', 'Vagabond Coffee'), + contact('gab@macro420.onmicrosoft.com', 'Gab Birman'), + ], + address + ); + fireEvent.input(selector.input, { target: { value: 'gab' } }); + + const position = (await selector.suggestions()).findIndex((text) => + text.includes('gab@macro420.onmicrosoft.com') + ); + expect(position).toBeGreaterThanOrEqual(0); + expect(position).toBeLessThan(3); + } +); + +it.each(inboxes)( + 'lists colleagues on the $provider sending domain first', + async ({ address }) => { + const domain = address.split('@')[1]; + const selector = renderSelector( + [ + user('sam@macro.com', 'Sam Lee'), + contact(`riley@${domain}`, 'Riley Park'), + ], + address + ); + fireEvent.keyDown(selector.input, { key: 'ArrowDown' }); + + const [first] = await selector.suggestions(); + expect(first).toContain(`riley@${domain}`); + } +); From 4cdb832371b792b5d8522c979f375ff6b0978543 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 00:56:08 +0000 Subject: [PATCH 17/75] test(email-compose): reopen a queued draft from its route after two reloads Why: Reloading a compose route whose draft save is still queued strips the draft from the route, so a second reload opens a blank compose. What: Add a route test for Gmail and Outlook inboxes that uses the real split router and this device's draft store. It opens a draft, queues an edit, reloads twice before the server confirms it, and expects the draft and its From to reopen. The composer test helper can now seed a draft's persistence and local copy. Co-authored-by: teo --- .../compose-route-draft.test.tsx | 96 +++++++++++++++++++ .../features/email-compose/tests/composer.ts | 2 + 2 files changed, 98 insertions(+) diff --git a/apps/web/src/features/email-compose/compose-route-draft.test.tsx b/apps/web/src/features/email-compose/compose-route-draft.test.tsx index da54196afa2..ef662bfa313 100644 --- a/apps/web/src/features/email-compose/compose-route-draft.test.tsx +++ b/apps/web/src/features/email-compose/compose-route-draft.test.tsx @@ -1,3 +1,4 @@ +import 'fake-indexeddb/auto'; import { createMemoryHistory, createMemoryPaneStore, @@ -8,6 +9,8 @@ import { SplitRouter, type SplitRoutes, } from '@app/lib/split-router'; +import { readEmailDraft } from '@queries/email/draft-queue'; +import { saveLocalDraft } from '@queries/email/local-drafts'; import { cleanup, render } from '@solidjs/testing-library'; import { createEffect, createRoot, For, onCleanup, onMount } from 'solid-js'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; @@ -17,6 +20,7 @@ import { createComposeRouteDraft, } from './compose-route-draft'; import type { + EmailComposeContext, EmailInbox, PersistedEmailIdentity, SaveEmailDraft, @@ -24,6 +28,15 @@ import type { import { createComposeContext } from './tests/capabilities'; import { mountEmailComposer } from './tests/composer'; +vi.mock('@app/lib/analytics', () => ({ + analytics: { track: vi.fn(), posthog: { captureException: vi.fn() } }, +})); +vi.mock('@queries/client', () => ({ + queryClient: { + getQueryData: () => ({ authenticated: true, id: 'owner' }), + }, +})); + let route: ReturnType | undefined; let mounts = 0; @@ -213,3 +226,86 @@ it.each([ composer.dispose(); } ); + +function reloadComposeRoute(history: MemoryHistory): MemoryHistory { + const { path, search } = history.entries()[history.index()]!; + cleanup(); + return renderComposeRoute(`${path}${search}`); +} + +/** Reads the route's draft from this device the way the compose view does. */ +async function reopenFromRoute(context: EmailComposeContext) { + const compose = route!; + const draftId = compose.draftId(); + const saved = draftId ? await readEmailDraft(draftId) : undefined; + return { + persistence: saved?.persistence, + composer: mountEmailComposer( + context, + { recordDraft: compose.record }, + { + draftId, + draft: saved?.draft, + draftPersistence: saved?.persistence, + localDraft: saved?.local, + } + ), + }; +} + +it.each([ + ['Outlook', outlook, gmail], + ['Gmail', gmail, outlook], +] as const)( + 'reopens a queued %s draft from its route after two reloads, before the server confirms it', + async (_provider, from, primary) => { + vi.useRealTimers(); + const draftId = `${from.id}-draft`; + const context = createComposeContext(); + context.accounts.inboxes = () => [primary, from]; + context.accounts.primaryId = () => primary.id; + context.drafts.saveLocalDraft = (input) => + saveLocalDraft({ ...input, senderEmail: from.email_address }); + const queued = Promise.withResolvers(); + context.drafts.saveDraft = vi.fn(async (input) => { + queued.resolve(); + return { + draftId: input.draft.db_id ?? draftId, + threadId: input.draft.thread_db_id ?? 'thread', + inboxId: input.inboxId ?? from.id, + persistence: 'queued' as const, + }; + }); + let history = renderComposeRoute('/component/email-compose'); + const opened = mountEmailComposer( + context, + { recordDraft: route!.record }, + { + draft: message(draftId, { + is_draft: true, + link_id: from.id, + from: { email: from.email_address }, + }), + } + ); + await routeShows(route!)(draftId); + opened.edit('Edited offline'); + await queued.promise; + await opened.state.flushLocal(); + opened.dispose(); + + context.drafts.saveDraft = vi.fn(() => new Promise(() => {})); + history = reloadComposeRoute(history); + const firstReload = await reopenFromRoute(context); + expect(firstReload.persistence).toBe('queued'); + firstReload.composer.dispose(); + + reloadComposeRoute(history); + const secondReload = await reopenFromRoute(context); + expect(route!.draftId()).toBe(draftId); + expect(secondReload.composer.state.context.fromAddress?.()).toBe( + from.email_address + ); + secondReload.composer.dispose(); + } +); diff --git a/apps/web/src/features/email-compose/tests/composer.ts b/apps/web/src/features/email-compose/tests/composer.ts index f8dac480f1b..3c51039678f 100644 --- a/apps/web/src/features/email-compose/tests/composer.ts +++ b/apps/web/src/features/email-compose/tests/composer.ts @@ -17,6 +17,8 @@ export function mountEmailComposer( EmailComposerOptions, | 'draft' | 'draftId' + | 'draftPersistence' + | 'localDraft' | 'initialInboxId' | 'initialSubject' | 'initialBodyText' From 94135937223f9c391ed5537b77f19766666b4edc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 00:57:47 +0000 Subject: [PATCH 18/75] fix(recipients): commit the typed address and rank name matches first Why: fuzzy search gives every hit the same score, so recency and length decided the order and Enter committed the first suggestion even when another one, or the typed text itself, was exactly the address. The typed address was appended after every match. The same-domain boost read the Macro login, so a sender on another domain saw the login's colleagues first. What: order search results by match strength (exact address, an address that continues the query, every query word starting a word, then the rest) with the search order inside each, and rank the typed address with them. The same-domain boost now uses selfEmail, which the composers set to the sending inbox; other callers keep the login. Co-authored-by: teo --- .../lib/core/component/RecipientSelector.tsx | 66 +++++++++++++++---- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/apps/web/src/lib/core/component/RecipientSelector.tsx b/apps/web/src/lib/core/component/RecipientSelector.tsx index 5edc9569831..0fca3c04093 100644 --- a/apps/web/src/lib/core/component/RecipientSelector.tsx +++ b/apps/web/src/lib/core/component/RecipientSelector.tsx @@ -193,6 +193,53 @@ function getRecipientOptionTextValue(option: CombinedRecipientItem) { } } +const RECIPIENT_MATCH_ORDER = [ + 'address', + 'address-start', + 'word-start', + 'other', +] as const; +type RecipientMatch = (typeof RECIPIENT_MATCH_ORDER)[number]; + +const WORD_BREAK = /[^\p{L}\p{N}]+/u; + +function recipientMatch( + option: CombinedRecipientItem, + query: string, + queryWords: string[] +): RecipientMatch { + const email = getRecipientOptionEmail(option)?.toLowerCase(); + if (email === query) return 'address'; + if (email && query.includes('@') && email.startsWith(query)) + return 'address-start'; + const words = getRecipientOptionTextValue(option) + .toLowerCase() + .split(WORD_BREAK); + if (queryWords.every((part) => words.some((word) => word.startsWith(part)))) + return 'word-start'; + return 'other'; +} + +// Fuzzy search scores every hit alike, leaving recency and length to decide +// what Enter commits. Stronger matches go first; the search order breaks ties. +function rankRecipientMatches( + found: T[], + query: string +): T[] { + const normalized = query.trim().toLowerCase(); + if (!normalized) return found; + const queryWords = normalized.split(WORD_BREAK).filter(Boolean); + return found + .map((option) => ({ + option, + rank: RECIPIENT_MATCH_ORDER.indexOf( + recipientMatch(option, normalized, queryWords) + ), + })) + .sort((a, b) => a.rank - b.rank) + .map(({ option }) => option); +} + type RecipientComboboxItemProps = CollectionNode & { inviteExternalEmails?: boolean; }; @@ -484,15 +531,11 @@ export function RecipientSelector( (props.triedToSubmit?.() ?? false) && props.selectedOptions.length === 0 ); - const currentUserEmail = useEmail(); - const currentUserDomain = createMemo(() => { - const email = currentUserEmail(); - return email ? email.split('@')[1] : undefined; - }); + const selfDomain = createMemo(() => selfEmail()?.split('@')[1]); const baseRecipientSearchConfig = FreshSearchPresets.baseUserSearch( - currentUserDomain, + selfDomain, getRecipientOptionEmail ); @@ -598,8 +641,7 @@ export function RecipientSelector( const searched = searchTerm ? recipientSearch(raw, searchTerm).map((item) => item.item) : sorted; - - const allOptions = [...searched, ...customUsers()]; + const typed: CombinedRecipientItem[] = []; // Only add custom input if it doesn't match an existing email if ( @@ -613,11 +655,13 @@ export function RecipientSelector( invalid: !EmailValidator.validate(currentUserInput), }; - const customEntity = recipientEntityMapper('custom')(customUserInput); - allOptions.push(customEntity); + typed.push(recipientEntityMapper('custom')(customUserInput)); } - return allOptions as CombinedRecipientItem[]; + return [ + ...rankRecipientMatches([...searched, ...typed], searchTerm), + ...customUsers(), + ] as CombinedRecipientItem[]; }); const visibleOptionKeys = createMemo( From 8891904ae2a7d5609428a93bd7528e4ccc34d568 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 00:57:53 +0000 Subject: [PATCH 19/75] test(email-compose): clear a send error once the draft meets it Why: after Send refused a draft with no recipient, adding a recipient left "Please select at least one recipient" on screen until the next Send. The message and subject errors stayed the same way. What: for a Gmail and an Outlook inbox, refuse Send for each missing requirement, meet it, and assert the composer drops that error and then sends. Co-authored-by: teo --- .../primitives/email-composer.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/apps/web/src/features/email-compose/primitives/email-composer.test.ts b/apps/web/src/features/email-compose/primitives/email-composer.test.ts index a0cbc42471c..d80df1d3c1d 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.test.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.test.ts @@ -354,6 +354,78 @@ describe('standalone compose controller', () => { } }); + const colleague = { + kind: 'custom', + id: 'colleague@example.com', + data: { + id: 'colleague@example.com', + email: 'colleague@example.com', + invalid: false, + }, + } as const; + type Composer = ReturnType; + const sendRequirements = [ + { + type: 'no_recipient', + leaveUnmet: (root: Composer) => { + root.edit('Ready to send'); + root.state.context.setRecipients('to', []); + }, + meet: (root: Composer) => + root.state.context.setRecipients('to', [colleague]), + }, + { + type: 'no_message', + leaveUnmet: (root: Composer) => + root.state.context.setSubject('Review'), + meet: (root: Composer) => + root.edit('Ready to send'), + }, + { + type: 'no_subject', + leaveUnmet: (root: Composer) => + root.edit('Ready to send', ''), + meet: (root: Composer) => + root.state.context.setSubject('Review'), + }, + ] as const; + + it.each( + (['GMAIL', 'OUTLOOK'] as const).flatMap((provider) => + sendRequirements.map((requirement) => ({ provider, ...requirement })) + ) + )( + 'clears the $type error in a $provider inbox as soon as the draft meets it', + async ({ provider, leaveUnmet, meet, type }) => { + const context = createComposeContext(); + context.accounts.inboxes = () => [ + { + id: 'inbox', + email_address: 'me@example.com', + provider, + settings: {}, + }, + ]; + const root = mountEmailComposer(context); + try { + leaveUnmet(root); + root.state.context.onSend(); + await vi.advanceTimersByTimeAsync(0); + expect(root.state.context.validationError(type)).toBeDefined(); + expect(context.delivery.sendMessage).not.toHaveBeenCalled(); + + meet(root); + expect(root.state.context.validationError(type)).toBeUndefined(); + + root.state.context.onSend(); + await vi.advanceTimersByTimeAsync(0); + expect(context.delivery.sendMessage).toHaveBeenCalledOnce(); + } finally { + root.dispose(); + } + } + ); + it('keeps a picked time local and preserves it when explicit scheduling fails', async () => { const context = createComposeContext(); const root = mountEmailComposer(context); From e4b5a7aceae366ab6e6c2cb4d7124ca21c850114 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 00:57:59 +0000 Subject: [PATCH 20/75] fix(email-compose): keep a reopened draft on its route while its save is queued Why: A compose route reopened with a draft whose save was still queued recorded no draft on its first run, which stripped `email-compose.draft` from the URL. A second reload before the server confirmed the save then opened a blank compose. What: - The standalone composer starts from the draft it opened, which this device can read while its save is queued, instead of from no draft. A draft the composer creates or moves to is still recorded only once the server confirms it. - The email architecture and agent guides say the reopened draft stays in the URL across reloads. Co-authored-by: teo --- .../email-compose/context/compose-capabilities.ts | 2 +- .../features/email-compose/primitives/email-composer.ts | 7 ++++--- docs/agent-guide/email.md | 3 ++- docs/architecture/email.md | 8 +++++--- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/web/src/features/email-compose/context/compose-capabilities.ts b/apps/web/src/features/email-compose/context/compose-capabilities.ts index 65b89cc7df2..beff723127a 100644 --- a/apps/web/src/features/email-compose/context/compose-capabilities.ts +++ b/apps/web/src/features/email-compose/context/compose-capabilities.ts @@ -425,7 +425,7 @@ export interface EmailComposeHost { showDraft?: (id: string) => void; /** A From switch moved the draft to a new id and thread; the old ones are gone. */ showMovedDraft?: (moved: { draftId: string; threadId: string }) => void; - /** The server-confirmed draft a reload should reopen, or undefined when there is none. */ + /** The draft a reload should reopen, or undefined when there is none. */ recordDraft?: (draftId: string | undefined) => void; goBack?: () => void; registerBack?: (handler: () => boolean) => void; diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts index 6d0f3bae299..494ae878887 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -246,13 +246,14 @@ export function createEmailComposer(props: EmailComposerOptions) { const currentDraftId = session.draftId; const [sentDraftId, setSentDraftId] = createSignal(); const currentThreadId = session.threadId; - // A reload can reopen only a draft the server confirmed; until a newer - // identity is confirmed, the host keeps the last one. + // A reload reopens the draft this composer opened, which this device can + // read even while its save is queued. A draft the composer creates or moves + // to is recorded only once the server confirms it. const confirmedDraftId = createMemo((previous) => { const identity = session.identity(); if (identity.kind === 'none') return undefined; return session.serverConfirmed() ? identity.draftId : previous; - }, undefined); + }, initialDraftId); createEffect( on(confirmedDraftId, (draftId) => props.host?.recordDraft?.(draftId)) ); diff --git a/docs/agent-guide/email.md b/docs/agent-guide/email.md index df81784df46..abf5e5b8d96 100644 --- a/docs/agent-guide/email.md +++ b/docs/agent-guide/email.md @@ -89,7 +89,8 @@ never the not-found page; a draft in compose stays open there. Keep editing after the switch and check that no "This draft changed while saving" notice appears. Once a new compose's first save is confirmed, the URL carries its draft (`s0.email-compose.draft` for the first pane): reloading reopens it with -its From, including after a From switch. +its From, including after a From switch. The draft stays in the URL across +further reloads, even while an offline edit is still queued. Opening an attachment opens it in a split from its Drive copy, except a calendar file: it opens its event (see the calendar invitations section of the diff --git a/docs/architecture/email.md b/docs/architecture/email.md index 8acfeed7672..47dfa24df5e 100644 --- a/docs/architecture/email.md +++ b/docs/architecture/email.md @@ -288,9 +288,11 @@ Existing message/thread model fields retain their established snake_case spellin this cleanup does not rename generated schemas or persisted URLs. In particular, the host still reads/writes the existing `draftID` compose route parameter. Those parameters live only in memory, so the compose route also records the -composer's server-confirmed draft in its `email-compose.draft` pane search, -replacing the current history entry, and reopens that draft ahead of -`draftID` (`compose-route-draft.ts`). A From switch that moves a draft keeps +composer's draft in its `email-compose.draft` pane search, replacing the +current history entry, and reopens that draft ahead of `draftID` +(`compose-route-draft.ts`). It records the draft the composer opened right +away, since this device can read it while a save is queued, and records a draft +the composer creates or moves to only once the server confirms it. A From switch that moves a draft keeps the compose route in place, while a thread route follows the moved draft to its new thread (`compose-host-adapter.ts`). From 6bcecd08f90bd37b8295a1d1045b0968de30f2f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 00:59:49 +0000 Subject: [PATCH 21/75] fix(email-compose): drop a send error as soon as the draft meets it Why: Send stored the first failed check as a fixed error, and only the next Send cleared it. Adding a recipient after "Please select at least one recipient" left the error up. A missing message or subject behaved the same; only the inbox error already checked live state. What: list the send requirements with their errors and predicates. Send refuses the first unmet requirement, and validationError reports it only while its predicate still fails. Co-authored-by: teo --- .../primitives/email-composer.ts | 75 +++++++++---------- 1 file changed, 35 insertions(+), 40 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts index 9fb6a400176..4eb5dab4a2a 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -727,8 +727,35 @@ export function createEmailComposer(props: EmailComposerOptions) { // --- Send --- - const [validationError, setValidationError] = - createSignal(null); + const sendRequirements: { + error: ComposeValidationError; + met: () => boolean; + }[] = [ + { + error: { + type: 'no_recipient', + message: 'Please select at least one recipient', + }, + met: () => form.recipients().to.length > 0, + }, + { + error: { type: 'no_message', message: 'Please enter a message' }, + met: () => !!content().trim(), + }, + { + error: { type: 'no_subject', message: 'Please enter a subject' }, + met: () => !!form.subject()?.trim(), + }, + { + error: { + type: 'no_link', + message: 'Unable to find linked email account. Select a sending inbox.', + }, + met: () => !!link(), + }, + ]; + const [refusedRequirement, setRefusedRequirement] = + createSignal<(typeof sendRequirements)[number]>(); // Snapshot the form before any watermark so an undo can reopen it as it was. const rememberForUndo = (draftId: string, inboxId: string) => { @@ -860,43 +887,12 @@ export function createEmailComposer(props: EmailComposerOptions) { sendLocked() ) return; - setValidationError(null); - + const refused = sendRequirements.find((requirement) => !requirement.met()); + setRefusedRequirement(refused); const currentEditor = editor(); const currentLink = link(); const recipients = form.recipients(); - - if (!recipients.to.length) { - setValidationError({ - type: 'no_recipient', - message: 'Please select at least one recipient', - }); - return; - } - - if (!content().trim()) { - setValidationError({ - type: 'no_message', - message: 'Please enter a message', - }); - return; - } - - if (!form.subject()?.trim()) { - setValidationError({ - type: 'no_subject', - message: 'Please enter a subject', - }); - return; - } - - if (!currentLink) { - setValidationError({ - type: 'no_link', - message: 'Unable to find linked email account. Select a sending inbox.', - }); - return; - } + if (refused || !currentLink) return; const offline = sendRefusalBeforeSave( props.connectivity, @@ -1525,10 +1521,9 @@ export function createEmailComposer(props: EmailComposerOptions) { // Validation validationError: (type) => { - if (type === 'no_link' && link()) return undefined; - const error = validationError(); - if (error?.type === type) return error; - return undefined; + const refused = refusedRequirement(); + if (refused?.error.type !== type || refused.met()) return undefined; + return refused.error; }, // Recipients From 410abda3455af03c83dcf961147249b78e0394b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 00:59:49 +0000 Subject: [PATCH 22/75] test(email-compose): leave bounce senders out of a reply Why: Reply on a bounce, or on a thread a bounce joined, put the mailer-daemon or MicrosoftExchange system address in To beside the real participants. What: press Reply (which asks for reply-all) on a Gmail and an Outlook bounce and assert what Send delivers: the other participants without the bounce sender, nothing when only I received it, and a noreply sender kept as a normal one. Co-authored-by: teo --- .../primitives/reply-recipients.test.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 apps/web/src/features/email-compose/primitives/reply-recipients.test.ts diff --git a/apps/web/src/features/email-compose/primitives/reply-recipients.test.ts b/apps/web/src/features/email-compose/primitives/reply-recipients.test.ts new file mode 100644 index 00000000000..74419ace3ee --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/reply-recipients.test.ts @@ -0,0 +1,90 @@ +import { createSignal } from 'solid-js'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { EmailContact } from '../../email-message/core/email-message'; +import { message } from '../../email-message/tests/messages'; +import type { EmailInbox } from '../context/compose-capabilities'; +import type { ReplyType } from '../core/reply-type'; +import { createComposeContext } from '../tests/capabilities'; +import { mountReplyComposer } from '../tests/reply'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +const bounceSenders = [ + { + provider: 'GMAIL', + from: { + name: 'Mail Delivery Subsystem', + email: 'mailer-daemon@googlemail.com', + }, + }, + { + provider: 'OUTLOOK', + from: { + name: 'Microsoft Outlook', + email: + 'MicrosoftExchange329e71ec88ae4615bbc36ab6ce41109e@macro420.onmicrosoft.com', + }, + }, +] as const satisfies { + provider: EmailInbox['provider']; + from: EmailContact; +}[]; + +/** Presses the message card's Reply, which asks for reply-all, and sends. */ +async function sendReply( + provider: EmailInbox['provider'], + from: EmailContact, + to: EmailContact[] +) { + const context = createComposeContext(); + context.accounts.inboxes = () => [ + { id: 'inbox', email_address: 'me@example.com', provider, settings: {} }, + ]; + const [replyType, setReplyType] = createSignal(); + const reply = mountReplyComposer( + context, + () => message('parent', { from, to, subject: 'Numbers' }), + {}, + { replyRequest: { replyType, clear: () => setReplyType(undefined) } } + ); + try { + setReplyType('reply-all'); + await vi.advanceTimersByTimeAsync(0); + await reply.sendEmail(); + return vi + .mocked(context.delivery.sendMessage) + .mock.calls.map(([sent]) => sent.message.to?.map((c) => c.email)); + } finally { + reply.dispose(); + } +} + +it.each(bounceSenders)( + 'replies to the people on a $provider thread but not its bounce sender', + async ({ provider, from }) => { + const participants = [ + { email: 'me@example.com' }, + { email: 'teammate@example.com' }, + ]; + + expect(await sendReply(provider, from, participants)).toEqual([ + ['teammate@example.com'], + ]); + expect( + await sendReply( + provider, + { name: 'Vendor Billing', email: 'noreply@vendor.com' }, + participants + ) + ).toEqual([['noreply@vendor.com', 'teammate@example.com']]); + } +); + +it.each(bounceSenders)( + 'sends nothing when Reply answers a $provider bounce addressed only to me', + async ({ provider, from }) => { + expect(await sendReply(provider, from, [{ email: 'me@example.com' }])) + .toEqual([]); + } +); From ea5a3bffbfd62672ee3eb3d6c8e48ce90fb85ea6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 01:00:45 +0000 Subject: [PATCH 23/75] fix(email-compose): leave bounce senders out of reply recipients Why: reply recipients dropped only my own addresses, so Reply on a bounce put mailer-daemon@ or the Outlook MicrosoftExchange@ system mailbox in To, and a reply on a thread a bounce had joined carried it along with the real participants. Those mailboxes read no replies. What: drop mailer-daemon@, postmaster@ and MicrosoftExchange followed by 32 hex characters wherever reply and reply-all build To and Cc. Reply stays reply-all, and noreply@ senders stay, since they can be a person's address. Co-authored-by: teo --- .../core/recipient-conversion.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/web/src/features/email-compose/core/recipient-conversion.ts b/apps/web/src/features/email-compose/core/recipient-conversion.ts index e2cc359749e..0d94c8d0c92 100644 --- a/apps/web/src/features/email-compose/core/recipient-conversion.ts +++ b/apps/web/src/features/email-compose/core/recipient-conversion.ts @@ -26,6 +26,13 @@ export const convertContactInfoToEmailRecipient = ( return { kind: 'contact', id, data: { ...contact, id, type: 'extracted' } }; }; +// Mailboxes that send delivery failure reports and read no replies. Kept +// narrow on purpose: a noreply@ address can still belong to a person. +const BOUNCE_SENDER = + /^(mailer-daemon|postmaster|microsoftexchange[0-9a-f]{32})@/i; +const isBounceSender = (email: string | undefined) => + !!email && BOUNCE_SENDER.test(email); + // Note: because of the logic, this works with a reference message that is either the message being replied to, or the draft. export const getReplyAllRecipients = ( referenceMessage: EmailMessage | undefined, @@ -38,13 +45,15 @@ export const getReplyAllRecipients = ( ); const isSelf = (email: string | undefined) => !!email && self.has(email.toLowerCase()); + const replyable = (contact: ContactInfo) => + !isSelf(contact.email) && !isBounceSender(contact.email); let to: EmailRecipient[] = []; // If last message was from user - reply to the to recipients (cc is handled separately below) if (isSelf(referenceMessage.from?.email)) { if (referenceMessage.to && referenceMessage.to.length > 0) { to = referenceMessage.to - .filter((recipient) => !isSelf(recipient.email)) + .filter(replyable) .map(convertContactInfoToEmailRecipient); } // Otherwise keep existing recipients @@ -56,13 +65,14 @@ export const getReplyAllRecipients = ( }; const otherRecipients = referenceMessage.to.filter( (recipient) => - !isSelf(recipient.email) && recipient.email.toLowerCase() !== sender.email.toLowerCase() ); - to = [sender, ...otherRecipients].map(convertContactInfoToEmailRecipient); + to = [sender, ...otherRecipients] + .filter(replyable) + .map(convertContactInfoToEmailRecipient); } const cc = (referenceMessage.cc ?? []) - .filter((recipient) => !isSelf(recipient.email)) + .filter(replyable) .map(convertContactInfoToEmailRecipient); return { to, cc, bcc: [] }; }; @@ -84,7 +94,9 @@ export const getReplyRecipientsFromParent = ( // Last message was NOT the user - reply to the sender const sender: ContactInfo = replyingTo.from ?? { email: '' }; return { - to: [convertContactInfoToEmailRecipient(sender)], + to: isBounceSender(sender.email) + ? [] + : [convertContactInfoToEmailRecipient(sender)], cc: [], bcc: [], }; From 451b7205bfebcb6a0b183f04a5751b7ec9ad66d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 01:02:41 +0000 Subject: [PATCH 24/75] fix(recipients): read a named chip's address to screen readers Why: a recipient chip shows only the display name, so two people with the same name looked identical and a screen reader never heard which address the mail goes to. The hover card already shows the address. What: put the address in a screen-reader-only span inside chips that show a name. The visible chip is unchanged. Co-authored-by: teo --- apps/web/src/lib/core/component/RecipientSelector.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/src/lib/core/component/RecipientSelector.tsx b/apps/web/src/lib/core/component/RecipientSelector.tsx index 0fca3c04093..39597483d47 100644 --- a/apps/web/src/lib/core/component/RecipientSelector.tsx +++ b/apps/web/src/lib/core/component/RecipientSelector.tsx @@ -71,6 +71,7 @@ function ChipWithUserTooltip(props: { function RecipientChip(props: { icon?: JSX.Element; label: string; + address?: string; onRemove: () => void; draggable?: boolean; onDragStart?: (e: DragEvent) => void; @@ -85,6 +86,9 @@ function RecipientChip(props: { > {props.icon}

{truncateString(props.label, 20)}

+ + {(address) => {address()}} + ( /> } label={displayText() ?? ''} + address={ + displayText() === email + ? undefined + : email + } onRemove={() => state.remove(option)} draggable={!!props.onChipDragStart} onDragStart={(e) => From c85539bf619afe0ad929490eb099663b6ceeb273 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 01:03:31 +0000 Subject: [PATCH 25/75] docs(email): describe recipient ranking, send errors, and bounce replies Why: the agent guide had no check for which suggestion Enter commits, how send errors clear, or who Reply addresses on a bounce. What: add the browser checks for exact-address and name-prefix ranking, the sending-domain order, live send errors, and bounce senders left out of replies. Co-authored-by: teo --- docs/agent-guide/email.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/agent-guide/email.md b/docs/agent-guide/email.md index 24adc98681e..fd6752ed09e 100644 --- a/docs/agent-guide/email.md +++ b/docs/agent-guide/email.md @@ -499,6 +499,17 @@ placeholder: Forward keeps its recipients, and Reply turns it into a reply without the forwarded message. A reply quoting a message that was itself forwarded stays a reply. Collapsed content is named: **Show forwarded message** on a forward, **Show quoted text** on a reply. +Check recipient suggestions from a Gmail and an Outlook inbox. Typing a full +address and pressing Enter commits that address, ahead of longer matches such +as `teo+123456@macro.com`, whether or not it is a saved contact. A name or +address word that starts with the query ranks above recent substring matches +(`gab` finds Gab Birman). Opened empty, the list puts colleagues on the sending +inbox's domain first. A chip shows the name and its hover card the address. +Press Send with To empty, then add a recipient: the error clears at once, as a +missing message or subject error does once filled in. Reply on a bounce from +`mailer-daemon@`, `postmaster@`, or Outlook's `MicrosoftExchange@` keeps +the other participants and leaves the bounce sender out of To and Cc; a +`noreply@` sender stays. When checking draft autosave, edit the body of a draft with uploaded or forwarded attachments, wait for the save, and reopen it; the attachments should remain visible. In a long thread, scroll away from the bottom while a draft save is pending. From 57d8c55f2df24758b6122dbc098776381ed9eb18 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 01:07:22 +0000 Subject: [PATCH 26/75] test(email-compose): format the send requirement and bounce tests Co-authored-by: teo --- .../email-compose/primitives/email-composer.test.ts | 12 ++++-------- .../primitives/reply-recipients.test.ts | 5 +++-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/email-composer.test.ts b/apps/web/src/features/email-compose/primitives/email-composer.test.ts index d80df1d3c1d..bb6ed6d261f 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.test.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.test.ts @@ -376,17 +376,13 @@ describe('standalone compose controller', () => { }, { type: 'no_message', - leaveUnmet: (root: Composer) => - root.state.context.setSubject('Review'), - meet: (root: Composer) => - root.edit('Ready to send'), + leaveUnmet: (root: Composer) => root.state.context.setSubject('Review'), + meet: (root: Composer) => root.edit('Ready to send'), }, { type: 'no_subject', - leaveUnmet: (root: Composer) => - root.edit('Ready to send', ''), - meet: (root: Composer) => - root.state.context.setSubject('Review'), + leaveUnmet: (root: Composer) => root.edit('Ready to send', ''), + meet: (root: Composer) => root.state.context.setSubject('Review'), }, ] as const; diff --git a/apps/web/src/features/email-compose/primitives/reply-recipients.test.ts b/apps/web/src/features/email-compose/primitives/reply-recipients.test.ts index 74419ace3ee..2a671621d8c 100644 --- a/apps/web/src/features/email-compose/primitives/reply-recipients.test.ts +++ b/apps/web/src/features/email-compose/primitives/reply-recipients.test.ts @@ -84,7 +84,8 @@ it.each(bounceSenders)( it.each(bounceSenders)( 'sends nothing when Reply answers a $provider bounce addressed only to me', async ({ provider, from }) => { - expect(await sendReply(provider, from, [{ email: 'me@example.com' }])) - .toEqual([]); + expect( + await sendReply(provider, from, [{ email: 'me@example.com' }]) + ).toEqual([]); } ); From 595b22f8cb1d7f31dfc699d8e40371cfd6873fe7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 01:17:35 +0000 Subject: [PATCH 27/75] docs(email-compose): say why bounce senders get no reply Why: the comment said these mailboxes read no replies, but postmaster@ is required to accept mail. What holds for all three is that a reply there reaches no one on the thread. What: reword the comment above the bounce sender pattern. Co-authored-by: teo --- .../src/features/email-compose/core/recipient-conversion.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/email-compose/core/recipient-conversion.ts b/apps/web/src/features/email-compose/core/recipient-conversion.ts index 0d94c8d0c92..bcf3b7403b1 100644 --- a/apps/web/src/features/email-compose/core/recipient-conversion.ts +++ b/apps/web/src/features/email-compose/core/recipient-conversion.ts @@ -26,8 +26,8 @@ export const convertContactInfoToEmailRecipient = ( return { kind: 'contact', id, data: { ...contact, id, type: 'extracted' } }; }; -// Mailboxes that send delivery failure reports and read no replies. Kept -// narrow on purpose: a noreply@ address can still belong to a person. +// Senders of delivery failure reports; a reply there reaches no one on the +// thread. Kept narrow on purpose: a noreply@ address can still be a person's. const BOUNCE_SENDER = /^(mailer-daemon|postmaster|microsoftexchange[0-9a-f]{32})@/i; const isBounceSender = (email: string | undefined) => From 36ba8d05d7c16cc7109b91ab7eae1f035938c9b8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 01:52:01 +0000 Subject: [PATCH 28/75] test(email-compose): cover what a queued send reports after it leaves Why: a reply or forward marks its thread done as soon as the outbox accepts it, so a send that later fails leaves the thread archived. The send toast also says "Email sent" before delivery and expires after a fixed 5 seconds, while the server's undo window runs from its own acceptance and lasts longer. What: composer tests for Gmail and Outlook inboxes that drive a queued send through accepted, failed, cancelled and sent progress and check the thread's completion, the last notice, and when the Undo offer ends. An outbox test runs the real journal and dispatcher on fake IndexedDB against a fake server and checks the progress a composer is told. These fail until the fix lands. Co-authored-by: teo --- .../primitives/send-outcome.test.ts | 241 +++++++++++++++++ .../queries/send-progress.test.ts | 248 ++++++++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 apps/web/src/features/email-compose/primitives/send-outcome.test.ts create mode 100644 apps/web/src/features/email-compose/queries/send-progress.test.ts diff --git a/apps/web/src/features/email-compose/primitives/send-outcome.test.ts b/apps/web/src/features/email-compose/primitives/send-outcome.test.ts new file mode 100644 index 00000000000..9b90675d02b --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/send-outcome.test.ts @@ -0,0 +1,241 @@ +import { createSignal } from 'solid-js'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { + EmailComposeContext, + EmailInbox, + EmailSendProgress, +} from '../context/compose-capabilities'; +import type { EmailRecipient } from '../core/email-recipient'; +import type { ReplyType } from '../core/reply-type'; +import { createComposeContext } from '../tests/capabilities'; +import { mountEmailComposer } from '../tests/composer'; +import { mountReplyComposer } from '../tests/reply'; +import type { ReplyComposerOptions } from './reply-composer'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +const providers = ['GMAIL', 'OUTLOOK'] as const; +const teammate: EmailRecipient = { + kind: 'custom', + id: 'teammate@example.com', + data: { + id: 'teammate@example.com', + email: 'teammate@example.com', + invalid: false, + }, +}; + +/** A composer whose queued send reports its progress as the outbox does. */ +function outbox(provider: EmailInbox['provider']) { + const context = createComposeContext(); + context.accounts.inboxes = () => [ + { id: 'inbox', email_address: 'me@example.com', provider, settings: {} }, + ]; + context.delivery.sendMessage = vi.fn(async () => ({ + sendAttemptId: 'attempt', + persistence: 'queued' as const, + draftId: 'draft', + threadId: 'thread', + inboxId: 'inbox', + })); + const watchers = new Map void>(); + context.delivery.watchSend = vi.fn((sendAttemptId, changed) => { + watchers.set(sendAttemptId, changed); + return () => watchers.delete(sendAttemptId); + }); + return { + context, + report: (progress: EmailSendProgress) => + watchers.get('attempt')?.(progress), + }; +} + +/** A thread in the inbox, completed by its send; `undo` returns it to the inbox. */ +function inboxThread() { + const undo = vi.fn(async () => {}); + const onMarkDone = vi.fn>( + (options) => options?.onUndoHandle?.({ id: 'done', undo, dispose() {} }) + ); + return { undo, onMarkDone }; +} + +/** The notice the user sees last, whichever kind it is. */ +function lastNotice(context: EmailComposeContext) { + const { success, failure, alert } = context.notices.feedback; + return ( + [ + ['success', vi.mocked(success).mock], + ['failure', vi.mocked(failure).mock], + ['alert', vi.mocked(alert).mock], + ] as const + ) + .flatMap(([kind, mock]) => + mock.invocationCallOrder.map((order) => ({ kind, order })) + ) + .sort((left, right) => left.order - right.order) + .at(-1)?.kind; +} + +/** When the newest Undo offer ends, on this device's clock. */ +function undoOfferEnds(context: EmailComposeContext) { + const offer = vi + .mocked(context.notices.feedback.success) + .mock.calls.filter(([, options]) => + options?.actions?.some((action) => action.label === 'Undo') + ) + .at(-1); + const duration = offer?.[1]?.duration; + return duration === undefined ? undefined : Date.now() + duration; +} + +it.each(providers)( + '%s: keeps a delivered reply done and offers Undo until the server deadline', + async (provider) => { + const { context, report } = outbox(provider); + const thread = inboxThread(); + const reply = mountReplyComposer( + context, + undefined, + { onMarkDone: thread.onMarkDone }, + { inboxVisible: true } + ); + try { + await reply.sendEmail(); + expect(thread.onMarkDone).toHaveBeenCalledOnce(); + + const undoUntil = new Date(Date.now() + 9_640).toISOString(); + report({ state: 'pending' }); + report({ state: 'accepted', undoUntil }); + expect(undoOfferEnds(context)).toBe(Date.parse(undoUntil)); + + report({ state: 'pending' }); + report({ state: 'sent' }); + await vi.advanceTimersByTimeAsync(0); + expect(thread.undo).not.toHaveBeenCalled(); + expect(thread.onMarkDone).toHaveBeenCalledOnce(); + expect(lastNotice(context)).toBe('success'); + } finally { + reply.dispose(); + } + } +); + +it.each( + providers.flatMap((provider) => + (['reply', 'forward'] as const).map((kind) => [provider, kind] as const) + ) +)( + '%s: returns the thread to the inbox when a %s fails after it was queued', + async (provider, kind: ReplyType) => { + const { context, report } = outbox(provider); + const thread = inboxThread(); + const [replyType, setReplyType] = createSignal(kind); + const reply = mountReplyComposer( + context, + undefined, + { onMarkDone: thread.onMarkDone }, + { + inboxVisible: true, + replyRequest: { replyType, clear: () => setReplyType(undefined) }, + } + ); + try { + await vi.advanceTimersByTimeAsync(0); + if (kind === 'forward') reply.form.setRecipients('to', [teammate]); + await reply.sendEmail(); + expect(thread.onMarkDone).toHaveBeenCalledOnce(); + + report({ + state: 'accepted', + undoUntil: new Date(Date.now() + 10_000).toISOString(), + }); + report({ state: 'pending' }); + report({ state: 'failed' }); + await vi.advanceTimersByTimeAsync(0); + + expect(thread.undo).toHaveBeenCalledOnce(); + expect(lastNotice(context)).toBe('failure'); + } finally { + reply.dispose(); + } + } +); + +it.each(providers)( + '%s: leaves the thread in the inbox when its failed send is restored', + async (provider) => { + const { context, report } = outbox(provider); + const thread = inboxThread(); + const reply = mountReplyComposer( + context, + undefined, + { onMarkDone: thread.onMarkDone }, + { inboxVisible: true } + ); + try { + await reply.sendEmail(); + report({ state: 'failed' }); + await vi.advanceTimersByTimeAsync(0); + report({ state: 'cancelled' }); + await vi.advanceTimersByTimeAsync(0); + + expect(thread.undo).toHaveBeenCalledOnce(); + expect(thread.onMarkDone).toHaveBeenCalledOnce(); + expect(lastNotice(context)).toBe('failure'); + } finally { + reply.dispose(); + } + } +); + +it.each(providers)( + '%s: does not archive a thread whose send failed before the advance completed', + async (provider) => { + const { context, report } = outbox(provider); + let cancelled: (() => boolean) | undefined; + const reply = mountReplyComposer( + context, + undefined, + { + onMarkDone: (options) => { + cancelled = options?.cancelled; + }, + }, + { inboxVisible: true } + ); + try { + await reply.sendEmail(true); + expect(cancelled?.()).toBe(false); + report({ state: 'failed' }); + await vi.advanceTimersByTimeAsync(0); + expect(cancelled?.()).toBe(true); + } finally { + reply.dispose(); + } + } +); + +it.each(providers)( + '%s: offers Undo until the server deadline and reports a new message that fails', + async (provider) => { + const { context, report } = outbox(provider); + const root = mountEmailComposer(context); + try { + root.edit('Quarterly numbers attached'); + await vi.advanceTimersByTimeAsync(600); + root.state.context.onSend(); + await vi.advanceTimersByTimeAsync(0); + expect(context.delivery.sendMessage).toHaveBeenCalledOnce(); + + const undoUntil = new Date(Date.now() + 9_910).toISOString(); + report({ state: 'accepted', undoUntil }); + expect(undoOfferEnds(context)).toBe(Date.parse(undoUntil)); + + report({ state: 'failed' }); + expect(lastNotice(context)).toBe('failure'); + } finally { + root.dispose(); + } + } +); diff --git a/apps/web/src/features/email-compose/queries/send-progress.test.ts b/apps/web/src/features/email-compose/queries/send-progress.test.ts new file mode 100644 index 00000000000..55eba680b30 --- /dev/null +++ b/apps/web/src/features/email-compose/queries/send-progress.test.ts @@ -0,0 +1,248 @@ +import 'fake-indexeddb/auto'; +import { createEmailSendDispatcher } from '@queries/email/email-send-dispatcher'; +import { + emailStorageSession, + localDraftStore, +} from '@queries/email/local-drafts'; +import { + cancelEmailSendQueued, + type EmailSendIntent, +} from '@queries/email/send-queue'; +import type { EmailSendAttemptFieldsFragment } from '@service-storage/graphql/generated/graphql'; +import { CombinedError } from '@urql/core'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { EmailSendProgress } from '../context/compose-capabilities'; +import { watchEmailSendProgress } from './send-progress'; + +vi.mock('@queries/email/local-drafts', async () => { + const { createLocalDraftStore } = await import( + '@queries/email/local-draft-store' + ); + const localDraftStore = createLocalDraftStore(crypto.randomUUID()); + const session = localDraftStore.activate('owner'); + return { + localDraftStore, + emailStorageSession: () => session, + readLocalEmailSends: async () => localDraftStore.sends(await session), + persistLocalEmailSend: async (intent: EmailSendIntent, reserve = false) => + localDraftStore.putSend(await session, intent, reserve), + watchLocalEmailSends: (changed: () => void) => + localDraftStore.subscribe(changed), + }; +}); + +const inboxes = [ + { provider: 'Gmail', linkId: 'gmail-inbox', email: 'me@gmail.example' }, + { provider: 'Outlook', linkId: 'outlook-inbox', email: 'me@outlook.example' }, +] as const; +type Inbox = (typeof inboxes)[number]; + +const stops: (() => void)[] = []; +const runners: ReturnType[] = []; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + vi.setSystemTime(new Date('2026-10-20T19:00:00Z')); + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); +}); +afterEach(() => { + for (const stop of stops.splice(0)) stop(); + for (const runner of runners.splice(0)) runner.dispose(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function intent(inbox: Inbox): EmailSendIntent { + const id = crypto.randomUUID(); + return { + uuid: id, + dispatchVersion: 2, + phase: 'pending', + locallyCancelled: false, + metadata: { + kind: 'email-send-v1', + payload: { + input: { + attempt: { attemptId: id, linkId: inbox.linkId }, + message: { + draftId: `draft-${id}`, + subject: 'Fwd: Quarterly numbers', + bodyText: 'See the attached numbers', + }, + attachmentIds: [], + forwardedAttachmentIds: [], + }, + draft: { + draftId: `draft-${id}`, + threadDbId: 'thread', + subject: 'Fwd: Quarterly numbers', + senderEmail: inbox.email, + senderLinkId: inbox.linkId, + optimisticBodyHtml: null, + }, + }, + }, + }; +} + +function attempt( + queued: EmailSendIntent, + status: EmailSendAttemptFieldsFragment['status'] +): EmailSendAttemptFieldsFragment { + return { + attemptId: queued.uuid, + status, + message: null, + threadId: 'thread', + sendTime: '2026-10-20T19:00:10.640Z', + }; +} + +/** Records one send's progress; `reached` resolves once a state is reported. */ +function follow(sendAttemptId: string) { + const updates: EmailSendProgress[] = []; + const waiting = new Map void>(); + stops.push( + watchEmailSendProgress(sendAttemptId, (progress) => { + updates.push(progress); + waiting.get(progress.state)?.(); + }) + ); + return { + updates, + reached: (state: EmailSendProgress['state']) => + new Promise((resolve) => { + if (updates.some((update) => update.state === state)) resolve(); + else waiting.set(state, resolve); + }), + }; +} + +async function queue(inbox: Inbox) { + const session = await emailStorageSession(); + const queued = await localDraftStore.putSend(session, intent(inbox), true); + return { session, queued, progress: follow(queued.uuid) }; +} + +function dispatch( + session: Awaited>, + server: Pick< + Parameters[0], + 'submit' | 'cancel' | 'status' + > +) { + const runner = createEmailSendDispatcher({ + session, + journal: localDraftStore, + report: vi.fn(), + ...server, + }); + runners.push(runner); + return runner; +} + +it.each(inboxes)( + 'reports a $provider send as undoable until the server deadline, then failed', + async (inbox) => { + const { session, queued, progress } = await queue(inbox); + await progress.reached('pending'); + const runner = dispatch(session, { + submit: async () => attempt(queued, 'ACCEPTED'), + cancel: vi.fn(), + status: async () => attempt(queued, 'FAILED'), + }); + await runner.wake(); + await progress.reached('accepted'); + vi.setSystemTime(Date.now() + 5_000); + await runner.wake(); + await progress.reached('failed'); + + expect(progress.updates).toEqual([ + { state: 'pending' }, + { state: 'accepted', undoUntil: '2026-10-20T19:00:10.640Z' }, + { state: 'failed' }, + ]); + } +); + +it.each(inboxes)( + 'reports a $provider send the server rejects as failed', + async (inbox) => { + const { session, progress } = await queue(inbox); + await progress.reached('pending'); + const runner = dispatch(session, { + submit: async () => { + throw new CombinedError({ + graphQLErrors: [ + { + message: 'Attachment unavailable', + extensions: { code: 'INVALID' }, + }, + ], + }); + }, + cancel: vi.fn(), + status: vi.fn(), + }); + await runner.wake(); + await progress.reached('failed'); + + expect(progress.updates).toEqual([ + { state: 'pending' }, + { state: 'failed' }, + ]); + } +); + +it.each(inboxes)( + 'stops offering Undo for a $provider send once cancellation is requested', + async (inbox) => { + const { session, queued, progress } = await queue(inbox); + await progress.reached('pending'); + const runner = dispatch(session, { + submit: async () => attempt(queued, 'ACCEPTED'), + cancel: async () => attempt(queued, 'CANCELLED'), + status: async () => attempt(queued, 'ACCEPTED'), + }); + await runner.wake(); + await progress.reached('accepted'); + const current = (await localDraftStore.sends(session)).find( + (row) => row.uuid === queued.uuid + ); + if (!current) throw new Error('The accepted send left the outbox'); + await cancelEmailSendQueued(current); + await runner.wake(); + await progress.reached('cancelled'); + + expect(progress.updates).toEqual([ + { state: 'pending' }, + { state: 'accepted', undoUntil: '2026-10-20T19:00:10.640Z' }, + { state: 'pending' }, + { state: 'cancelled' }, + ]); + } +); + +it.each(inboxes)( + 'reports a delivered $provider send as sent', + async (inbox) => { + const { session, queued, progress } = await queue(inbox); + await progress.reached('pending'); + const runner = dispatch(session, { + submit: async () => attempt(queued, 'ACCEPTED'), + cancel: vi.fn(), + status: async () => attempt(queued, 'SENT'), + }); + await runner.wake(); + await progress.reached('accepted'); + vi.setSystemTime(Date.now() + 5_000); + await runner.wake(); + await progress.reached('sent'); + + expect(progress.updates).toEqual([ + { state: 'pending' }, + { state: 'accepted', undoUntil: '2026-10-20T19:00:10.640Z' }, + { state: 'sent' }, + ]); + } +); From 61e789001ac25abcccc7a95d17cdd816b48fb647 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 02:30:21 +0000 Subject: [PATCH 29/75] test(email-compose): cover a thread marked done after its send failed Why: marking a thread done registers its undo handle a few microtasks after the composer checks whether the send was undone. A failure reported in that gap finds no handle, so the thread would stay out of the inbox. What: a Gmail and an Outlook case that report the failure before the mark-done handle arrives and expect the thread to return to the inbox. Co-authored-by: teo --- .../primitives/send-outcome.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/web/src/features/email-compose/primitives/send-outcome.test.ts b/apps/web/src/features/email-compose/primitives/send-outcome.test.ts index 9b90675d02b..2891dee7009 100644 --- a/apps/web/src/features/email-compose/primitives/send-outcome.test.ts +++ b/apps/web/src/features/email-compose/primitives/send-outcome.test.ts @@ -4,6 +4,7 @@ import type { EmailComposeContext, EmailInbox, EmailSendProgress, + EmailUndoHandle, } from '../context/compose-capabilities'; import type { EmailRecipient } from '../core/email-recipient'; import type { ReplyType } from '../core/reply-type'; @@ -216,6 +217,36 @@ it.each(providers)( } ); +it.each(providers)( + '%s: returns the thread to the inbox when it is marked done after its send failed', + async (provider) => { + const { context, report } = outbox(provider); + const undo = vi.fn(async () => {}); + let markedDone: ((handle: EmailUndoHandle) => void) | undefined; + const reply = mountReplyComposer( + context, + undefined, + { + onMarkDone: (options) => { + markedDone = options?.onUndoHandle; + }, + }, + { inboxVisible: true } + ); + try { + await reply.sendEmail(true); + report({ state: 'failed' }); + await vi.advanceTimersByTimeAsync(0); + markedDone?.({ id: 'done', undo, dispose() {} }); + await vi.advanceTimersByTimeAsync(0); + + expect(undo).toHaveBeenCalledOnce(); + } finally { + reply.dispose(); + } + } +); + it.each(providers)( '%s: offers Undo until the server deadline and reports a new message that fails', async (provider) => { From d82c4c1bb99936aa68c7fd158cb18e4e56069365 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 02:35:07 +0000 Subject: [PATCH 30/75] fix(email-compose): follow a queued send to its outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the composers treated the local outbox write as a finished send. They said "Email sent" at once, offered Undo for a fixed 5 seconds from that moment, and marked a replied thread done. The server keeps a send undoable until its own deadline, about 10 seconds after it accepts the send. A send that failed later still read as sent, and its thread stayed out of the inbox, so it looked like an archive the user never asked for. What: the outbox now reports each send's progress to the composers through a new delivery capability. The send notice reads "Sending…" and keeps Undo on offer until the deadline the server returned. It says "Email sent" only once the send is delivered, and a persistent failure notice replaces it if the send fails. A reply whose send fails, is cancelled, or is restored puts its thread back in the inbox, including when marking done finishes after the failure. A delivered send keeps today's behavior. The change applies to every provider. Co-authored-by: teo --- .../features/email-compose/compose-adapter.ts | 2 + .../context/compose-capabilities.ts | 18 ++++ .../primitives/compose-persistence.test.ts | 12 ++- .../primitives/email-composer.ts | 44 ++++------ .../primitives/queued-send-notice.ts | 87 +++++++++++++++++++ .../primitives/reply-composer.ts | 68 +++++++-------- .../primitives/send-schedule.test.ts | 21 +++-- .../email-compose/queries/send-progress.ts | 83 ++++++++++++++++++ 8 files changed, 259 insertions(+), 76 deletions(-) create mode 100644 apps/web/src/features/email-compose/primitives/queued-send-notice.ts create mode 100644 apps/web/src/features/email-compose/queries/send-progress.ts diff --git a/apps/web/src/features/email-compose/compose-adapter.ts b/apps/web/src/features/email-compose/compose-adapter.ts index 6f3c4fd7719..ec31975e954 100644 --- a/apps/web/src/features/email-compose/compose-adapter.ts +++ b/apps/web/src/features/email-compose/compose-adapter.ts @@ -112,6 +112,7 @@ import { } from './queries/draft-lifecycle'; import { createEmailInboxSource } from './queries/inbox-source'; import { queuedDraftSaveArgs } from './queries/queued-draft'; +import { watchEmailSendProgress } from './queries/send-progress'; import { restoreDraftBodyAfterUndo, runQueuedUndoSend, @@ -573,6 +574,7 @@ export function createEmailComposeContext( emailSendMatchesDraft(intent, draftId) && emailSendLocked(intent) )), + watchSend: watchEmailSendProgress, async sendMessage({ inboxId, ...input }) { if ( !inboxSource diff --git a/apps/web/src/features/email-compose/context/compose-capabilities.ts b/apps/web/src/features/email-compose/context/compose-capabilities.ts index 65b89cc7df2..0f7abc9a943 100644 --- a/apps/web/src/features/email-compose/context/compose-capabilities.ts +++ b/apps/web/src/features/email-compose/context/compose-capabilities.ts @@ -235,6 +235,19 @@ export interface OutgoingEmail { >; } +/** + * One queued send as this device's outbox last recorded it. `accepted` is + * cancellable until the server's `undoUntil`; `pending` covers waiting, + * delivering, and cancellation in progress. + */ +export type EmailSendProgress = + | { state: 'pending' } + | { state: 'accepted'; undoUntil: string } + | { state: 'sent' } + | { state: 'failed' } + | { state: 'cancelled' } + | { state: 'unconfirmed' }; + export interface EmailDelivery { /** Whether sends can be persisted and replayed without connectivity. */ queueActive?: (inboxId?: string) => boolean; @@ -242,6 +255,11 @@ export interface EmailDelivery { sendLocked?: (draftId: string | undefined) => boolean; /** Durable sends on their way; cancelled, failed and scheduled sends are excluded. */ outgoing?: () => OutgoingEmail[]; + /** Reports each change until the send is sent, fails, is cancelled, or leaves this device. */ + watchSend?( + sendAttemptId: string, + changed: (progress: EmailSendProgress) => void + ): () => void; sendMessage(input: SendEmailDraft): Promise; unschedule(input: { draftGeneration?: number; diff --git a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts index 218beddb118..ede9afaf928 100644 --- a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts +++ b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts @@ -687,8 +687,10 @@ it('does not mistake its own in-flight send for a delivery elsewhere', async () expect(composeContext.notices.feedback.alert).not.toHaveBeenCalled(); expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(2); expect(composeContext.notices.feedback.success).toHaveBeenCalledWith( - 'Email sent', - expect.anything() + expect.any(String), + expect.objectContaining({ + actions: [expect.objectContaining({ label: 'Undo' })], + }) ); root.dispose(); }); @@ -721,8 +723,10 @@ it('does not announce a plain send as a scheduled one', async () => { ); expect(composeContext.notices.feedback.alert).not.toHaveBeenCalled(); expect(composeContext.notices.feedback.success).toHaveBeenCalledWith( - 'Email sent', - expect.anything() + expect.any(String), + expect.objectContaining({ + actions: [expect.objectContaining({ label: 'Undo' })], + }) ); root.dispose(); }); diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts index 4eb5dab4a2a..886fc0b08be 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -79,6 +79,7 @@ import { createDraftSaveNotice } from './draft-save-notice'; import { createDraftSession } from './draft-session'; import { createDraftTransfer } from './draft-transfer'; import { createEmailSendSchedule } from './email-send-schedule'; +import { announceQueuedSend } from './queued-send-notice'; import { pendingInlineMedia, refuseSend, @@ -836,33 +837,22 @@ export function createEmailComposer(props: EmailComposerOptions) { // The outbox delivers an online send right away; offline it waits on this device. const offline = props.connectivity.looksOffline(); if (draftId) endUndoSend(draftId); - try { - const toastId = props.notices.feedback.success( - offline ? 'Email queued to send' : 'Email sent', - { - actions: draftId - ? [ - { - label: 'Undo', - onClick: () => { - if (toastId != null) - props.notices.feedback.dismiss(toastId); - void undoSend( - draftId, - threadId ?? undefined, - inboxId, - identity.sendAttemptId - ).catch(props.notices.reportError); - }, - }, - ] - : undefined, - duration: 5_000, - } - ); - } catch (error) { - props.notices.reportError(error); - } + announceQueuedSend({ + notices: props.notices, + delivery: props.delivery, + sendAttemptId: identity.sendAttemptId, + queuedOffline: offline, + undo: draftId + ? () => { + void undoSend( + draftId, + threadId ?? undefined, + inboxId, + identity.sendAttemptId + ).catch(props.notices.reportError); + } + : undefined, + }); try { // The server has no thread to show until an offline send goes out. if (offline) props.host?.goBack?.(); diff --git a/apps/web/src/features/email-compose/primitives/queued-send-notice.ts b/apps/web/src/features/email-compose/primitives/queued-send-notice.ts new file mode 100644 index 00000000000..e278162c6b4 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/queued-send-notice.ts @@ -0,0 +1,87 @@ +import { match } from 'ts-pattern'; +import type { + EmailComposeFeedback, + EmailDelivery, +} from '../context/compose-capabilities'; + +/** + * Tells the user only what a queued send has done. Undo stays on offer until + * the server's own deadline, and the send is called sent once it is delivered. + */ +export function announceQueuedSend(options: { + notices: EmailComposeFeedback; + delivery: Pick; + sendAttemptId: string | undefined; + queuedOffline: boolean; + undo: (() => void) | undefined; + /** The send failed or was cancelled, so nothing was delivered. */ + onUndelivered?: () => void; +}): void { + const { notices, undo } = options; + let undoNotice: number | undefined; + let undoRequested = false; + let accepted = false; + const withdrawUndo = () => { + if (undoNotice != null) notices.feedback.dismiss(undoNotice); + undoNotice = undefined; + }; + const offerUndo = (message: string, duration?: number) => { + try { + undoNotice = notices.feedback.success(message, { + actions: undo + ? [ + { + label: 'Undo', + onClick: () => { + undoRequested = true; + withdrawUndo(); + undo(); + }, + }, + ] + : undefined, + duration, + }); + } catch (error) { + notices.reportError(error); + } + }; + offerUndo(options.queuedOffline ? 'Email queued to send' : 'Sending…'); + if (!options.sendAttemptId) return; + options.delivery.watchSend?.(options.sendAttemptId, (progress) => + match(progress) + .with({ state: 'pending' }, () => { + // After acceptance, pending means delivery or cancellation has begun. + if (accepted) withdrawUndo(); + }) + .with({ state: 'accepted' }, ({ undoUntil }) => { + accepted = true; + const remaining = Date.parse(undoUntil) - Date.now(); + if (!undoRequested && remaining > 0) offerUndo('Sending…', remaining); + }) + .with({ state: 'sent' }, () => { + withdrawUndo(); + notices.feedback.success('Email sent'); + }) + .with({ state: 'failed' }, () => { + withdrawUndo(); + notices.feedback.failure('Email failed to send', { + subtext: + 'Open its conversation or Drafts and choose Cancel to recover the draft.', + persistent: true, + }); + options.onUndelivered?.(); + }) + .with({ state: 'cancelled' }, () => { + withdrawUndo(); + options.onUndelivered?.(); + }) + .with({ state: 'unconfirmed' }, () => { + withdrawUndo(); + notices.feedback.alert('Delivery unconfirmed', { + subtext: 'Check your sent mail. We will not resend automatically.', + }); + }) + .exhaustive() + ); +} diff --git a/apps/web/src/features/email-compose/primitives/reply-composer.ts b/apps/web/src/features/email-compose/primitives/reply-composer.ts index 4723c02c506..28fd11c11cb 100644 --- a/apps/web/src/features/email-compose/primitives/reply-composer.ts +++ b/apps/web/src/features/email-compose/primitives/reply-composer.ts @@ -80,6 +80,7 @@ import { prepareMacroBody, TOGGLE_APPEND_EMAIL_THREAD_COMMAND, } from './prepare-email-body'; +import { announceQueuedSend } from './queued-send-notice'; import { createReplyComposerFocus, takesFocusOnMount, @@ -154,7 +155,7 @@ export type ReplyComposerOptions = { onUndoHandle?: (handle: EmailUndoHandle) => void; /** False keeps the view on this thread instead of opening the next one. */ navigate?: boolean; - /** True once the send was undone; marking done may still be pending. */ + /** True once the send was undone or failed; marking done may still be pending. */ cancelled?: () => boolean; }) => void; setShowReply?: Setter; @@ -1354,9 +1355,18 @@ export function createReplyComposer( // Each undo action retains this send's inbox, thread and mark-done, even // if the composer sends again or navigation disposes the view. let markDoneUndoHandle: EmailUndoHandle | undefined; - // Advancing may archive after a page loads; an undo before then must - // not leave the restored thread marked done. - let sendUndone = false; + // Advancing may archive after a page loads; an undo or failure before + // then must not leave the thread marked done. + let returnedToInbox = false; + const returnToInbox = async () => { + returnedToInbox = true; + const doneHandle = markDoneUndoHandle; + markDoneUndoHandle = undefined; + await doneHandle?.undo({ + onError: () => + props.notices.feedback.failure('Failed to restore thread to inbox'), + }); + }; const undoSend = (draftId: string) => props.delivery.undoSend({ sendAttemptId: result.sendAttemptId, @@ -1364,47 +1374,30 @@ export function createReplyComposer( draftId, inboxId, onUndone: async (undo) => { - sendUndone = true; + returnedToInbox = true; await restoreAfterUndoSend( draftId, result.threadId, inboxId, undo?.draftRestored ); - const doneHandle = markDoneUndoHandle; - markDoneUndoHandle = undefined; - await doneHandle?.undo({ - onError: () => - props.notices.feedback.failure( - 'Failed to restore thread to inbox' - ), - }); + await returnToInbox(); }, }); // The outbox delivers an online send right away; offline it waits on this device. const offline = props.connectivity.looksOffline(); - try { - const toastId = props.notices.feedback.success( - offline ? 'Email queued to send' : 'Email sent', - { - actions: draftId - ? [ - { - label: 'Undo', - onClick: () => { - if (toastId != null) - props.notices.feedback.dismiss(toastId); - void undoSend(draftId).catch(props.notices.reportError); - }, - }, - ] - : undefined, - duration: 5_000, - } - ); - } catch (error) { - props.notices.reportError(error); - } + announceQueuedSend({ + notices: props.notices, + delivery: props.delivery, + sendAttemptId: result.sendAttemptId, + queuedOffline: offline, + undo: draftId + ? () => { + void undoSend(draftId).catch(props.notices.reportError); + } + : undefined, + onUndelivered: () => fireAndForget(returnToInbox(), 'email-compose'), + }); for (const mention of prepared.mentions) { try { props.recordMention(sourceEntityId, mention.documentId); @@ -1420,10 +1413,13 @@ export function createReplyComposer( silent: true, onUndoHandle: (handle) => { markDoneUndoHandle = handle; + // Marking done can finish after the send was undone or failed. + if (returnedToInbox) + fireAndForget(returnToInbox(), 'email-compose'); }, navigate: markDone, cancelled: () => - sendUndone || sendOperation !== sendOperationVersion, + returnedToInbox || sendOperation !== sendOperationVersion, }); } catch (error) { props.notices.reportError(error); diff --git a/apps/web/src/features/email-compose/primitives/send-schedule.test.ts b/apps/web/src/features/email-compose/primitives/send-schedule.test.ts index 871c8acbf48..16e7eec8209 100644 --- a/apps/web/src/features/email-compose/primitives/send-schedule.test.ts +++ b/apps/web/src/features/email-compose/primitives/send-schedule.test.ts @@ -25,6 +25,15 @@ function hoursFromNow(hours: number): Date { return new Date(Date.now() + hours * 60 * 60 * 1000); } +/** The send notice that offers Undo. */ +function undoOffer(context: EmailComposeContext) { + return vi + .mocked(context.notices.feedback.success) + .mock.calls.find(([, options]) => + options?.actions?.some((action) => action.label === 'Undo') + ); +} + it.each(['standalone', 'reply'] as const)( '%s refreshes lifecycle after a queued cancellation before accepting an old sent observation', async (kind) => { @@ -584,9 +593,7 @@ describe('send and schedule ordering', () => { try { state.edit('Earlier reply'); await state.sendEmail(); - const sentNotice = vi - .mocked(context.notices.feedback.success) - .mock.calls.find(([text]) => text === 'Email sent'); + const sentNotice = undoOffer(context); state.edit('Newer reply that must survive'); sentNotice?.[1]?.actions?.[0].onClick(); @@ -625,9 +632,7 @@ describe('send and schedule ordering', () => { try { const send = state.sendEmail(true); await vi.advanceTimersByTimeAsync(0); - const sentNotice = vi - .mocked(composeContext.notices.feedback.success) - .mock.calls.find(([text]) => text === 'Email sent'); + const sentNotice = undoOffer(composeContext); expect(onMarkDone).toHaveBeenCalledOnce(); // Send and mark done moves on to the next thread, like Mark done. expect(onMarkDone).toHaveBeenCalledWith( @@ -1272,9 +1277,7 @@ describe('send and schedule ordering', () => { expect(composeContext.delivery.sendMessage).toHaveBeenCalledWith( expect.objectContaining({ inboxId: 'secondary' }) ); - const notice = vi - .mocked(composeContext.notices.feedback.success) - .mock.calls.find(([text]) => text === 'Email sent'); + const notice = undoOffer(composeContext); if (remount) state.dispose(); notice?.[1]?.actions?.[0].onClick(); await vi.advanceTimersByTimeAsync(0); diff --git a/apps/web/src/features/email-compose/queries/send-progress.ts b/apps/web/src/features/email-compose/queries/send-progress.ts new file mode 100644 index 00000000000..241e6106598 --- /dev/null +++ b/apps/web/src/features/email-compose/queries/send-progress.ts @@ -0,0 +1,83 @@ +import { deepEqual } from '@core/util/compareUtils'; +import { fireAndForget } from '@core/util/fireAndForget'; +import { + type EmailSendIntent, + emailSendLocked, + readEmailSendIntents, + settledSendAttempt, + watchEmailSends, +} from '@queries/email/send-queue'; +import type { EmailSendProgress } from '../context/compose-capabilities'; + +function emailSendProgress(intent: EmailSendIntent): EmailSendProgress { + const attempt = settledSendAttempt(intent); + if (attempt?.status === 'SENT') return { state: 'sent' }; + if ( + attempt?.status === 'CANCELLED' || + (intent.cancellationRequested && !emailSendLocked(intent)) + ) + return { state: 'cancelled' }; + // Undo owns the outcome once requested; it reports its own result. + if (intent.cancellationRequested) return { state: 'pending' }; + if (attempt?.status === 'DELIVERY_UNCONFIRMED') + return { state: 'unconfirmed' }; + if (intent.phase === 'failed' || attempt?.status === 'FAILED') + return { state: 'failed' }; + if (attempt?.status === 'ACCEPTED' && attempt.sendTime) + return { state: 'accepted', undoUntil: attempt.sendTime }; + return { state: 'pending' }; +} + +export function watchEmailSendProgress( + sendAttemptId: string, + changed: (progress: EmailSendProgress) => void +): () => void { + let last: EmailSendProgress | undefined; + let stopped = false; + let reading = false; + let rerun = false; + const stop = () => { + stopped = true; + unsubscribe(); + }; + const read = async () => { + const intent = (await readEmailSendIntents()).find( + (row) => row.uuid === sendAttemptId + ); + if (stopped) return; + // Retired after delivery or restored after cancellation, possibly by another tab. + if (!intent) return stop(); + const progress = emailSendProgress(intent); + if (last && deepEqual(last, progress)) return; + last = progress; + if ( + progress.state === 'sent' || + progress.state === 'failed' || + progress.state === 'cancelled' + ) + stop(); + changed(progress); + }; + const drain = async () => { + reading = true; + try { + do { + rerun = false; + await read(); + } while (rerun && !stopped); + } finally { + reading = false; + } + }; + const refresh = () => { + if (stopped) return; + if (reading) { + rerun = true; + return; + } + fireAndForget(drain(), 'email-compose'); + }; + const unsubscribe = watchEmailSends(refresh); + refresh(); + return stop; +} From 42140da9469dc1c65970cdbd6fc84b34dfce2155 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 02:35:08 +0000 Subject: [PATCH 31/75] docs(email): describe the send notice and a failed send's thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the guide and two comments still described an "Email sent" notice that appears as soon as the user presses Send. What: document "Sending…" with Undo until the server's deadline, "Email sent" after delivery, the failure and unconfirmed notices, and a failed or restored reply returning its thread to the inbox. Co-authored-by: teo --- .../next-soup/actions/make-mark-done-action.ts | 2 +- apps/web/src/lib/queries/email/thread.ts | 2 +- docs/agent-guide/email.md | 18 ++++++++++++------ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/web/src/features/next-soup/actions/make-mark-done-action.ts b/apps/web/src/features/next-soup/actions/make-mark-done-action.ts index 81f69c4488d..9c46bbb7d0a 100644 --- a/apps/web/src/features/next-soup/actions/make-mark-done-action.ts +++ b/apps/web/src/features/next-soup/actions/make-mark-done-action.ts @@ -90,7 +90,7 @@ type MarkDoneVariables = { notificationEntities: NotificationEntityRef[]; restoreFocus?: () => void; /** Suppress the "Marked as done" toast, e.g. for send-triggered mark done - * where it would replace the "Email sent" toast. */ + * where it would replace the send toast. */ silent?: boolean; /** Receives the undo handle once the mark-done is pushed onto the undo * stack, so callers (e.g. undo-send) can reverse it programmatically. */ diff --git a/apps/web/src/lib/queries/email/thread.ts b/apps/web/src/lib/queries/email/thread.ts index 87a74b643e5..f6ea2ab95e1 100644 --- a/apps/web/src/lib/queries/email/thread.ts +++ b/apps/web/src/lib/queries/email/thread.ts @@ -443,7 +443,7 @@ type ArchiveThreadParams = { /** Target inbox for a non-primary inbox; sent as the X-Email-Link-Id header. */ linkId?: string; /** Suppress the success toast, e.g. for a send-triggered archive where the - * "Email sent" toast (with its undo-send action) is already up and this + * send toast (with its undo-send action) is already up and this * toast's own Undo would reverse only the archive, not the send. */ silent?: boolean; /** Receives the undo handle once the archive is pushed onto the undo diff --git a/docs/agent-guide/email.md b/docs/agent-guide/email.md index fd6752ed09e..b5ecbbcd624 100644 --- a/docs/agent-guide/email.md +++ b/docs/agent-guide/email.md @@ -630,7 +630,9 @@ Send and mark done (Shift+Cmd+Enter) and Mark done open the next email, counted from where the thread sat when it was opened (a reply draft lifts its thread to the top of Signal while it is written). If the user has already moved to another thread when the send finishes, the replied thread is marked done -without navigating; Undo before then leaves it in the inbox. +without navigating; Undo before then leaves it in the inbox. A delivered send +keeps the thread done. If the send fails after it was queued, or is cancelled or +restored, the thread returns to Inbox; verify this for Gmail and Outlook. Sending a message shows it in the open thread immediately, while delivery is still pending. It stays visible until the thread refresh confirms it, with no duplicate message. Failed delivery removes that message and restores the reply @@ -641,7 +643,7 @@ focus belongs to the sent message card in the same thread pane, even while the thread refresh is still pending. Replying again requires clicking Reply or using a reply shortcut. -After a successful send, the `Email sent` notice offers `Undo`. Undo restores the +After a send is queued, its notice offers `Undo`. Undo restores the sent envelope and editable content, including when the reply used another inbox; a slow background refresh must not keep the restored editor disabled. A rejected send reports failure and restores its original reply editor if it is still mounted. @@ -671,11 +673,15 @@ status panel with **Cancel**. An authenticated app-level dispatcher submits online immediately and retries the same attempt independently of unrelated draft, archive, and property mutations. -An online send shows **Email sent** with **Undo**: a new message opens its +An online send shows **Sending…** with **Undo**: a new message opens its conversation and a reply closes its composer, and the message appears as sent -there immediately, before the server or provider confirms it. An offline send -shows **Email queued to send** and returns to the email list. Routine delivery -is otherwise silent: the status panel shows a send only while it waits on this +there immediately, before the server or provider confirms it. Once the server +accepts the send, **Undo** stays on offer until the server's undo deadline (the +accepted attempt's `sendTime`), not for a fixed client duration. The notice says +**Email sent** only after delivery. A send that fails after it was queued shows +a persistent **Email failed to send**, and **Delivery unconfirmed** reports a +send the provider did not confirm. An offline send shows **Email queued to +send** and returns to the email list. The status panel shows a send only while it waits on this device (**Queued on this device**), needs attention, or is scheduled (**Scheduled**). On phones the panel sits below the safe area and the floating header. Only one active send can own a draft across tabs; queued composers From 7310a4daa7c9a37d9d094ec36a834971cd6e2990 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 02:51:31 +0000 Subject: [PATCH 32/75] docs(email): record queued-send progress in the compose architecture Why: the architecture guide listed the delivery capability and send failures without the progress a queued send now reports. What: name send progress on EmailDelivery, add the failure row for a queued send that fails later, and describe how the notice and mark-done revert follow it. Co-authored-by: teo --- docs/architecture/email.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/architecture/email.md b/docs/architecture/email.md index 47dfa24df5e..4c96256a753 100644 --- a/docs/architecture/email.md +++ b/docs/architecture/email.md @@ -102,7 +102,7 @@ test exercises this ordering. | `EmailReplySession` | Thread identity, recipient options, personal-reply classification, a targeted reply request, and host intents for leaving the composer or removing its draft | | `EmailDraftStorage` | Save/delete and restore an undone draft, and drop a moved draft's working copy; inputs use domain inbox IDs and completion intent | | `EmailAttachmentStorage` | Upload, forward, and remove draft attachments | -| `EmailDelivery` | Send, undo, schedule, unschedule, and archive operations | +| `EmailDelivery` | Send, undo, schedule, unschedule, and archive operations, and each queued send's progress | | `EmailComposeFeedback` | User notices and error reporting | | `EmailComposeAccounts` | Inbox identities, availability, and the primary inbox | | `EmailComposePresentation` | View-only device state, signature visibility, upgrade action, and link preparation | @@ -264,6 +264,7 @@ its failure handling. Adapter tests therefore exercise real TanStack mutations. | --- | --- | | Draft save/delete or attachment write | Existing shared mutation reports the write failure; callers do not add a second schedule/save notice. | | Send | Composer reports the failed send once. A failed reply restores only its original still-mounted editor; a newer editor's work is preserved. | +| Queued send that fails later | The send notice reports the failure; a reply that marked its thread done returns it to the inbox. | | Schedule/unschedule | Schedule workflow reports the failed request and keeps the last confirmed time. | | Post-send refresh/navigation/analytics | Report the presentation/cache error; do not report that delivery failed or enable a duplicate send. | | Archive after scheduling | Keep the confirmed schedule and identify the archive failure separately. | @@ -276,6 +277,12 @@ and undo handle; sending again must preserve the earlier notification's undo action. Standalone compose marks completion before its navigation callback, so disposal cannot autosave or resend the successful message. +The send notice follows each queued send through `EmailDelivery.watchSend`, +which reads the device outbox. Undo stays on offer until the deadline the server +returned with the accepted attempt. A reply whose send fails, is cancelled, or is +restored reverses its mark-done through the same undo handle, including when +marking done finishes later. + `EmailThreadSource.refresh()` and `fetchOlder()` require `Promise`. Their adapters await the underlying query, and callers that need fresh messages await that completion. They must not launch the request and resolve early. From 4ebc72588f0a1f57e1ddacf37c5fb03ccc369a07 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 02:48:45 +0000 Subject: [PATCH 33/75] fix(email-compose): keep a moved draft when its thread cannot open Why: a From switch commits the move, then asks the host to show the moved thread. If that navigation threw, the switch reported a failed inbox change with a retry for a move that had already succeeded, and the pending save of the new sender went unawaited. What: the composer reports a navigation error without failing the switch and always awaits the save. A test throws from the host on Outlook to Gmail and Gmail to Outlook moves. Co-authored-by: teo --- .../draft-transfer-identity.test.ts | 41 +++++++++++++++++++ .../primitives/email-composer.ts | 11 ++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts b/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts index fb11fe7a8b7..0b3f69b0909 100644 --- a/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts +++ b/apps/web/src/features/email-compose/primitives/draft-transfer-identity.test.ts @@ -165,6 +165,47 @@ it.each(directions)( } ); +it.each(directions)( + 'keeps a draft moved from %s to %s when the host cannot show it', + async (_from, _to, source, destination) => { + const { context, moved } = createDeviceContext(source, destination); + const navigation = new Error('Could not open the moved thread'); + const root = mountEmailComposer( + context, + { + showMovedDraft: () => { + throw navigation; + }, + }, + { + draft: message('draft', { + is_draft: true, + link_id: 'inbox', + from: { email: source.email_address }, + }), + } + ); + try { + root.edit('Moving draft'); + await root.state.flushLocal(); + root.state.context.onSelectInbox?.('other'); + expect(await moved).toEqual({ + saved: expect.objectContaining({ + inboxId: 'other', + draft: expect.objectContaining({ + db_id: 'moved-draft', + sender_email: destination.email_address, + }), + }), + }); + expect(context.notices.feedback.failure).not.toHaveBeenCalled(); + expect(context.notices.reportError).toHaveBeenCalledWith(navigation); + } finally { + root.dispose(); + } + } +); + it.each(directions)( 'keeps saving a reply locally after its sender moves it from %s to %s', async (_from, _to, source, destination) => { diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts index 32a0a2f1928..ed0bd663962 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -1289,6 +1289,15 @@ export function createEmailComposer(props: EmailComposerOptions) { autosave.cancel(); }, }); + // The move has committed, so a host that cannot show it must not turn the + // switch into a failed inbox change. + const showMovedDraft = (moved: { draftId: string; threadId: string }) => { + try { + props.host?.showMovedDraft?.(moved); + } catch (error) { + props.notices.reportError(error); + } + }; const selectInbox = async (inboxId: string, senderEmail?: string) => { if ( sendLocked() || @@ -1325,7 +1334,7 @@ export function createEmailComposer(props: EmailComposerOptions) { // The save captures the new sender before the host can unmount this // composer to show the moved draft. const saved = autosave.save(); - if (moved) props.host?.showMovedDraft?.(moved); + if (moved) showMovedDraft(moved); await saved; } catch (error) { props.notices.reportError(error); From 3f4519e36c69f36ba41afe918474c11b79ac9a78 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 03:07:10 +0000 Subject: [PATCH 34/75] test(email-compose): cover a send accepted after its undo deadline Why: the first send notice offers Undo before the server accepts the send. If acceptance arrives after the server's deadline, that offer stays clickable for a send that can no longer be undone. What: Gmail and Outlook cases that report acceptance with a past deadline and expect no Undo offer to remain open. Co-authored-by: teo --- .../primitives/send-outcome.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/web/src/features/email-compose/primitives/send-outcome.test.ts b/apps/web/src/features/email-compose/primitives/send-outcome.test.ts index 2891dee7009..fcf992b9a5a 100644 --- a/apps/web/src/features/email-compose/primitives/send-outcome.test.ts +++ b/apps/web/src/features/email-compose/primitives/send-outcome.test.ts @@ -90,6 +90,17 @@ function undoOfferEnds(context: EmailComposeContext) { return duration === undefined ? undefined : Date.now() + duration; } +/** Undo offers still on screen, given notices that return their ids. */ +function openUndoOffers(context: EmailComposeContext) { + const { success, dismiss } = vi.mocked(context.notices.feedback); + const dismissed = new Set(dismiss.mock.calls.map(([id]) => id)); + return success.mock.calls.filter( + ([, options], index) => + options?.actions?.some((action) => action.label === 'Undo') && + !dismissed.has(success.mock.results[index]?.value) + ).length; +} + it.each(providers)( '%s: keeps a delivered reply done and offers Undo until the server deadline', async (provider) => { @@ -217,6 +228,30 @@ it.each(providers)( } ); +it.each(providers)( + '%s: withdraws Undo when the server accepts the send after its deadline', + async (provider) => { + const { context, report } = outbox(provider); + let notice = 0; + vi.mocked(context.notices.feedback.success).mockImplementation( + () => ++notice + ); + const reply = mountReplyComposer(context); + try { + await reply.sendEmail(); + expect(openUndoOffers(context)).toBe(1); + + report({ + state: 'accepted', + undoUntil: new Date(Date.now() - 1_000).toISOString(), + }); + expect(openUndoOffers(context)).toBe(0); + } finally { + reply.dispose(); + } + } +); + it.each(providers)( '%s: returns the thread to the inbox when it is marked done after its send failed', async (provider) => { From 2feb574c493a4dc2def1a321c178109fc76daaf6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 03:10:02 +0000 Subject: [PATCH 35/75] fix(email-compose): withdraw Undo once a send's deadline has passed Why: when the server accepted a send after its undo deadline, the notice skipped the new offer but left the first one clickable until its timer ran out. What: acceptance with no time left, or after Undo was pressed, withdraws the open Undo offer. Co-authored-by: teo --- .../src/features/email-compose/primitives/queued-send-notice.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/features/email-compose/primitives/queued-send-notice.ts b/apps/web/src/features/email-compose/primitives/queued-send-notice.ts index e278162c6b4..9c9b0bfa508 100644 --- a/apps/web/src/features/email-compose/primitives/queued-send-notice.ts +++ b/apps/web/src/features/email-compose/primitives/queued-send-notice.ts @@ -58,6 +58,7 @@ export function announceQueuedSend(options: { accepted = true; const remaining = Date.parse(undoUntil) - Date.now(); if (!undoRequested && remaining > 0) offerUndo('Sending…', remaining); + else withdrawUndo(); }) .with({ state: 'sent' }, () => { withdrawUndo(); From 409887e91f64d3e1e95b684baabe72cf29cd7684 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 03:10:02 +0000 Subject: [PATCH 36/75] feat(email-compose): record the send outcome this device observed Why: the outbox traced each submit and status call, but not the outcome the composer acted on. A thread returned to the inbox after a failed send could not be found by its attempt ID. What: when a watched send is sent, fails, is cancelled, or cannot be confirmed, record an email_send.lifecycle event with the attempt ID, status, phase, and time since the send was queued. No message content is recorded. Co-authored-by: teo --- .../features/email-compose/queries/send-progress.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/web/src/features/email-compose/queries/send-progress.ts b/apps/web/src/features/email-compose/queries/send-progress.ts index 241e6106598..c9571ffcda6 100644 --- a/apps/web/src/features/email-compose/queries/send-progress.ts +++ b/apps/web/src/features/email-compose/queries/send-progress.ts @@ -7,6 +7,7 @@ import { settledSendAttempt, watchEmailSends, } from '@queries/email/send-queue'; +import { recordEmailSend } from '@queries/email/send-telemetry'; import type { EmailSendProgress } from '../context/compose-capabilities'; function emailSendProgress(intent: EmailSendIntent): EmailSendProgress { @@ -32,6 +33,7 @@ export function watchEmailSendProgress( sendAttemptId: string, changed: (progress: EmailSendProgress) => void ): () => void { + const started = performance.now(); let last: EmailSendProgress | undefined; let stopped = false; let reading = false; @@ -50,6 +52,16 @@ export function watchEmailSendProgress( const progress = emailSendProgress(intent); if (last && deepEqual(last, progress)) return; last = progress; + if (progress.state !== 'pending' && progress.state !== 'accepted') + recordEmailSend( + { + operation: 'status', + outcome: 'observed', + status: settledSendAttempt(intent)?.status, + durationMs: performance.now() - started, + }, + intent + ); if ( progress.state === 'sent' || progress.state === 'failed' || From 70cb2446722e729e76b90e668a8111436c463c1b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 03:10:03 +0000 Subject: [PATCH 37/75] fix(email-compose): keep the quoted-content toggle's name stable Why: the toggle reports its state through aria-pressed, and its name also flipped between Show and Hide, so a screen reader announced conflicting state such as "Hide quoted text, pressed". What: name the toggle after the content it shows, and keep Show or Hide in its tooltip. The agent guide gives the stable name. Co-authored-by: teo --- apps/web/src/features/email-compose/views/reply-input.tsx | 2 +- docs/agent-guide/email.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/email-compose/views/reply-input.tsx b/apps/web/src/features/email-compose/views/reply-input.tsx index 3d64a800376..6a83feab1d1 100644 --- a/apps/web/src/features/email-compose/views/reply-input.tsx +++ b/apps/web/src/features/email-compose/views/reply-input.tsx @@ -750,7 +750,7 @@ function LoadedReplyInputView(props: ReplyInputViewProps) { Date: Sun, 25 Oct 2026 03:32:35 +0000 Subject: [PATCH 38/75] test(email-compose): expect Delete to leave before the server settles Why: Delete draft waits for an in-flight save and the server delete before it navigates away. A slow write keeps the composer greyed out, and on a draft-only thread the optimistic delete empties the thread first, so the user lands on a blank thread view whose operations query keeps polling a 404. What: Gmail and Outlook cases where Delete must leave while the first save, a save after a pasted image, or the server delete is still pending, and must do so before the delete starts. Failed deletes must still be reported after leaving. The thread operations query must stop polling once the server reports the thread missing and keep polling through transient failures. Co-authored-by: teo --- .../primitives/compose-persistence.test.ts | 158 ++++++++++++++---- .../email-thread-operations.test.tsx | 25 ++- 2 files changed, 145 insertions(+), 38 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts index ede9afaf928..6bdcfddacc1 100644 --- a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts +++ b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts @@ -1,3 +1,4 @@ +import { $createImageNode } from '@macro-inc/lexical-core'; import { $getRoot } from 'lexical'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { message } from '../../email-message/tests/messages'; @@ -17,6 +18,14 @@ const response: PersistedEmailIdentity = { threadId: 'thread', inboxId: 'inbox', }; +const providers = ['GMAIL', 'OUTLOOK'] as const; +function providerContext(provider: (typeof providers)[number]) { + const composeContext = createComposeContext(); + composeContext.accounts.inboxes = () => [ + { id: 'inbox', email_address: 'me@example.com', settings: {}, provider }, + ]; + return composeContext; +} beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); @@ -77,26 +86,72 @@ it('discard cancels an unsaved debounce without creating a draft', async () => { expect(composeContext.drafts.saveDraft).not.toHaveBeenCalled(); expect(composeContext.drafts.deleteDraft).not.toHaveBeenCalled(); }); -it('discard waits for an in-flight first save and deletes its returned draft', async () => { - const pending = Promise.withResolvers(); - const composeContext = createComposeContext(); - vi.mocked(composeContext.drafts.saveDraft).mockReturnValueOnce( - pending.promise - ); - const root = mountEmailComposer(composeContext); - root.edit('First'); - await vi.advanceTimersByTimeAsync(600); - root.edit('Discard these changes too'); - const discard = root.state.deleteDraftAndReset(); - root.dispose(); - pending.resolve(response); - await discard; - await vi.advanceTimersByTimeAsync(1000); - expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); - expect(composeContext.drafts.deleteDraft).toHaveBeenCalledWith( - expect.objectContaining({ draftId: 'saved-id' }) - ); -}); +it.each(providers)( + 'Delete leaves a %s draft at once and deletes the ID its in-flight first save returns', + async (provider) => { + const pending = Promise.withResolvers(); + const composeContext = providerContext(provider); + vi.mocked(composeContext.drafts.saveDraft).mockReturnValueOnce( + pending.promise + ); + const goBack = vi.fn(() => root.dispose()); + const root = mountEmailComposer(composeContext, { goBack }); + root.edit('First'); + await vi.advanceTimersByTimeAsync(600); + root.edit('Discard these changes too'); + const discard = root.state.deleteDraftAndReset(); + await vi.advanceTimersByTimeAsync(0); + expect(goBack).toHaveBeenCalledOnce(); + pending.resolve(response); + await discard; + await vi.advanceTimersByTimeAsync(1000); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.drafts.deleteDraft).toHaveBeenCalledWith( + expect.objectContaining({ draftId: 'saved-id' }) + ); + } +); +it.each(providers)( + 'Delete leaves a %s draft whose send waits on a pasted image while its save is on the wire', + async (provider) => { + const composeContext = providerContext(provider); + const goBack = vi.fn(() => root.dispose()); + const root = mountEmailComposer(composeContext, { goBack }); + root.edit('Body'); + await vi.advanceTimersByTimeAsync(600); + const saving = Promise.withResolvers(); + vi.mocked(composeContext.drafts.saveDraft).mockReturnValueOnce( + saving.promise + ); + root.editor.update( + () => + $getRoot().append( + $createImageNode({ + srcType: 'local', + url: 'blob:http://localhost/pasted', + alt: 'pasted', + }) + ), + { discrete: true } + ); + root.state.context.onContentChange('Body'); + await vi.advanceTimersByTimeAsync(600); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(2); + root.state.context.onSend(); + await vi.advanceTimersByTimeAsync(0); + expect(composeContext.delivery.sendMessage).not.toHaveBeenCalled(); + + root.state.context.onDelete?.(); + await vi.advanceTimersByTimeAsync(0); + expect(goBack).toHaveBeenCalledOnce(); + saving.resolve({ draftId: 'draft', threadId: 'thread', inboxId: 'inbox' }); + await vi.advanceTimersByTimeAsync(1000); + expect(composeContext.drafts.deleteDraft).toHaveBeenCalledWith( + expect.objectContaining({ draftId: 'draft' }) + ); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(2); + } +); it('discards a standalone draft at its server generation', async () => { const composeContext = createComposeContext(); // Each send that Undo cancels advances the server's generation twice. @@ -151,7 +206,7 @@ it('sends a reloaded reply without appending its quoted thread again', async () ); }); -it('keeps the draft editable after failed deletion and saves later edits', async () => { +it('reports a failed deletion and keeps a draft its host still shows editable', async () => { const composeContext = createComposeContext(); const goBack = vi.fn(); const root = mountEmailComposer(composeContext, { goBack }); @@ -161,35 +216,64 @@ it('keeps the draft editable after failed deletion and saves later edits', async new Error('offline') ); await expect(root.state.deleteDraftAndReset()).rejects.toThrow('offline'); - expect(goBack).not.toHaveBeenCalled(); + expect(goBack).toHaveBeenCalledOnce(); + expect(composeContext.notices.feedback.failure).toHaveBeenCalledWith( + 'Failed to delete draft' + ); root.edit('Still here'); await vi.advanceTimersByTimeAsync(600); root.dispose(); expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(2); }); - -it('toolbar discard returns to the list after deletion without recreating the draft on disposal', async () => { +it('reports a failed deletion after the composer has left without saving the draft again', async () => { const composeContext = createComposeContext(); - const pending = Promise.withResolvers(); const goBack = vi.fn(() => root.dispose()); const root = mountEmailComposer(composeContext, { goBack }); - root.edit('Discard this standalone thread'); + root.edit('Saved'); await vi.advanceTimersByTimeAsync(600); - vi.mocked(composeContext.drafts.deleteDraft).mockReturnValueOnce( - pending.promise + vi.mocked(composeContext.drafts.deleteDraft).mockRejectedValueOnce( + new Error('offline') ); - - root.state.context.onDelete?.(); - await vi.advanceTimersByTimeAsync(0); - expect(composeContext.drafts.deleteDraft).toHaveBeenCalledOnce(); - expect(goBack).not.toHaveBeenCalled(); - pending.resolve(); + await expect(root.state.deleteDraftAndReset()).rejects.toThrow('offline'); await vi.advanceTimersByTimeAsync(1000); - expect(goBack).toHaveBeenCalledOnce(); - expect(root.state.context.hasDraft()).toBe(false); + expect(composeContext.notices.feedback.failure).toHaveBeenCalledWith( + 'Failed to delete draft' + ); expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); }); + +it.each(providers)( + 'toolbar discard leaves a %s draft before deleting it and does not recreate it on disposal', + async (provider) => { + const composeContext = providerContext(provider); + const pending = Promise.withResolvers(); + const goBack = vi.fn(() => root.dispose()); + const root = mountEmailComposer(composeContext, { goBack }); + root.edit('Discard this standalone thread'); + await vi.advanceTimersByTimeAsync(600); + // Deleting a draft-only thread empties the open thread as soon as the + // delete starts, so the composer has to be gone by then. + let leftBeforeDelete = false; + vi.mocked(composeContext.drafts.deleteDraft).mockImplementationOnce(() => { + leftBeforeDelete = goBack.mock.calls.length > 0; + return pending.promise; + }); + + root.state.context.onDelete?.(); + await vi.advanceTimersByTimeAsync(0); + expect(goBack).toHaveBeenCalledOnce(); + expect(leftBeforeDelete).toBe(true); + pending.resolve(); + await vi.advanceTimersByTimeAsync(1000); + + expect(composeContext.drafts.deleteDraft).toHaveBeenCalledWith( + expect.objectContaining({ draftId: 'draft' }) + ); + expect(root.state.context.hasDraft()).toBe(false); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); + } +); it('waits for the saved draft ID, prevents duplicate sends, and does not recreate the sent draft on disposal', async () => { const pending = Promise.withResolvers(); const composeContext = createComposeContext(); @@ -312,7 +396,7 @@ it('rejects sender/schedule changes and repeated discard while a deletion is pen ); const discard = root.state.deleteDraftAndReset(); expect(await root.state.deleteDraftAndReset()).toBe(false); - expect(goBack).not.toHaveBeenCalled(); + expect(goBack).toHaveBeenCalledOnce(); root.state.context.onSelectInbox?.('other-inbox'); root.state.context.schedule.onSelect(new Date('2026-12-01T12:00:00Z')); expect(root.state.context.selectedInboxId?.()).toBe('inbox'); diff --git a/apps/web/src/features/email-thread/email-thread-operations.test.tsx b/apps/web/src/features/email-thread/email-thread-operations.test.tsx index 2b3a9cef972..46c43ea37b5 100644 --- a/apps/web/src/features/email-thread/email-thread-operations.test.tsx +++ b/apps/web/src/features/email-thread/email-thread-operations.test.tsx @@ -2,7 +2,7 @@ import type { MailboxOperation } from '@service-email/generated/schemas'; import { cleanup, render, screen } from '@solidjs/testing-library'; import { onlineManager } from '@tanstack/query-core'; import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; -import { type Ok, ok } from 'neverthrow'; +import { err, type Ok, ok } from 'neverthrow'; import { Suspense } from 'solid-js'; import { afterEach, expect, it, vi } from 'vitest'; import { @@ -23,6 +23,7 @@ afterEach(() => { cleanup(); onlineManager.setOnline(true); vi.clearAllMocks(); + vi.useRealTimers(); }); const operation = ( state: MailboxOperation['state'], @@ -94,6 +95,28 @@ it.each(['fetching', 'paused', 'disabled'] as const)( client.clear(); } ); +it.each([ + ['stops polling a thread the server no longer has', 'NOT_FOUND', 1], + ['keeps polling a thread through a transient failure', 'NETWORK_ERROR', 3], +] as const)('%s', async (_, code, requests) => { + vi.useFakeTimers(); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + threadOperations.mockResolvedValue(err([{ code, message: code }])); + render(() => ( + + + + )); + await vi.advanceTimersByTimeAsync(12_000); + expect(threadOperations).toHaveBeenCalledTimes(requests); + client.clear(); +}); it('does not announce the mark-as-read that opening a thread queues', () => { const read = { kind: 'read', value: true } as const; render(() => ( From bd6c23918f0517bbec5fc0a4ef488028b594e573 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 03:36:50 +0000 Subject: [PATCH 39/75] test(email-thread): expect a draft-only thread to close when Delete starts Why: The thread view hosting a standalone draft only closes once the server delete settles, so the optimistic delete leaves an empty "[No subject]" thread on screen in the meantime. What: The owning email view must close on the Delete click, before the delete resolves or rejects, still without using the split's back history. A rejected delete must report "Failed to delete draft" and must not save the draft again. Co-authored-by: teo --- .../views/standalone-draft-navigation.test.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/web/src/features/email-thread/views/standalone-draft-navigation.test.tsx b/apps/web/src/features/email-thread/views/standalone-draft-navigation.test.tsx index cbe5fe47d42..7b6ae2c83aa 100644 --- a/apps/web/src/features/email-thread/views/standalone-draft-navigation.test.tsx +++ b/apps/web/src/features/email-thread/views/standalone-draft-navigation.test.tsx @@ -73,21 +73,24 @@ it.each(['accepted', 'rejected'] as const)( )); try { view.getByText('Delete draft').click(); + expect(view.getByText('Email list')).toBeTruthy(); + expect(returnToList).toHaveBeenCalledOnce(); await waitFor(() => expect(compose.drafts.deleteDraft).toHaveBeenCalledOnce() ); - expect(returnToList).not.toHaveBeenCalled(); - expect(splitBack).not.toHaveBeenCalled(); if (outcome === 'accepted') { pending.resolve(); - await waitFor(() => expect(view.getByText('Email list')).toBeTruthy()); - expect(returnToList).toHaveBeenCalledOnce(); } else { pending.reject(new Error('Deletion failed')); - await pending.promise.catch(() => {}); - expect(view.getByText('Delete draft')).toBeTruthy(); - expect(returnToList).not.toHaveBeenCalled(); + await waitFor(() => + expect(compose.notices.feedback.failure).toHaveBeenCalledWith( + 'Failed to delete draft' + ) + ); } + await pending.promise.catch(() => {}); + expect(view.getByText('Email list')).toBeTruthy(); + expect(returnToList).toHaveBeenCalledOnce(); expect(splitBack).not.toHaveBeenCalled(); expect(compose.drafts.saveDraft).not.toHaveBeenCalled(); } finally { From b6d25f3a33a2b5a5bdd35558a6b9c53697ed55cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 03:36:51 +0000 Subject: [PATCH 40/75] fix(email-compose): leave a standalone draft before deleting it Why: Delete draft awaited an in-flight save, the local flush, and the queued server delete before it navigated. Each can wait on the mutation queue's request and delivery timeouts, so after a pasted image left an Outlook save on the wire the button greyed out and the composer stayed open. On a draft-only thread the optimistic delete also emptied the thread around the composer first, leaving a blank "[No subject]" view until the delete settled. What: deleteDraftAndReset navigates as soon as it pauses autosave and cancels the pending save, then finishes the save, flush, and delete in the background. Discarding keeps autosave paused, so unmounting cannot save the draft again. A failed delete still reports "Failed to delete draft"; a host that keeps the composer mounted keeps it editable. Co-authored-by: teo --- .../features/email-compose/primitives/email-composer.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts index ed0bd663962..61ff13499b5 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -1111,6 +1111,11 @@ export function createEmailComposer(props: EmailComposerOptions) { if (persistencePaused() || scheduling()) return false; setDiscarding(true); autosave.cancel(); + // A standalone draft owns its thread, so nothing is left to show. Leave + // before the delete: a queued save or delete can take minutes, and the + // optimistic delete empties a draft-only thread around this composer. + // Discarding pauses autosave, so disposal cannot save the draft again. + props.host?.goBack?.(); try { // A first save may still be creating the draft. Delete its returned ID // after it settles so discard cannot leave an orphan behind. @@ -1139,10 +1144,6 @@ export function createEmailComposer(props: EmailComposerOptions) { } identityVersion += 1; resetState(); - // A standalone draft owns its thread; after discard there is no - // conversation left to display. Reset before leaving so disposal cannot - // flush the deleted draft back into the save queue. - props.host?.goBack?.(); return true; } finally { setDiscarding(false); From 4b7c41a66ec8d8ae2d05ebc0aa7849ccc0ba579d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 03:36:51 +0000 Subject: [PATCH 41/75] fix(email-thread): stop polling operations for a thread that is gone Why: The thread operations query polled every five seconds even after the server answered 404, so a deleted thread left open kept requesting email/threads//operations. What: Return no refetch interval while the last fetch failed with NOT_FOUND. Transient failures keep polling, and the next successful fetch, such as after a mailbox action invalidates email queries, restarts the interval. Co-authored-by: teo --- .../features/email-thread/queries/thread-operations.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/features/email-thread/queries/thread-operations.ts b/apps/web/src/features/email-thread/queries/thread-operations.ts index c5252c0df34..9021134ac8d 100644 --- a/apps/web/src/features/email-thread/queries/thread-operations.ts +++ b/apps/web/src/features/email-thread/queries/thread-operations.ts @@ -1,4 +1,4 @@ -import { throwOnErr } from '@core/util/result'; +import { thrownResultErrorHasCode, throwOnErr } from '@core/util/result'; import { refreshEmailThreadCache } from '@queries/email/cache-cleanup'; import { emailKeys } from '@queries/email/keys'; import { emailClient } from '@service-email/client'; @@ -27,8 +27,10 @@ export function useThreadOperations( previous = { thread, pending }; return operations; }, - // Keep an open thread current after actions from another window or client. - refetchInterval: 5000, + // Keep an open thread current after actions from another window or + // client. A deleted thread never comes back, so stop asking for it. + refetchInterval: (query) => + thrownResultErrorHasCode(query.state.error, 'NOT_FOUND') ? false : 5000, })); return query; } From 7f1989399882e37d60e39fa154d6733768459de5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 03:37:43 +0000 Subject: [PATCH 42/75] docs(email): describe Delete leaving before the draft deletion settles Why: The agent guide said a failed deletion keeps the standalone composer open, and nothing recorded why discard now navigates before its writes settle or that a 404 thread stops polling operations. What: Update the discard checks in the email agent guide, add the operations polling rule next to the not-found thread checks, and record the discard ordering beside the send ordering in the email architecture doc. Co-authored-by: teo --- docs/agent-guide/email.md | 13 +++++++++---- docs/architecture/email.md | 7 ++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/agent-guide/email.md b/docs/agent-guide/email.md index d59e5f25a03..669270661d6 100644 --- a/docs/agent-guide/email.md +++ b/docs/agent-guide/email.md @@ -906,16 +906,21 @@ completion must not archive the restored thread. With GraphQL Mail enabled, discarding the last draft in a thread must remove the thread from every local Mail view, including after queued replay. Discarding a -standalone draft from its composer returns to the previous list after deletion -is accepted, including offline; a failed deletion keeps the composer open. -Check both the toolbar trash button and the mobile Delete Draft action. +standalone draft from its composer returns to the previous list at once, even +while a save or the deletion is still on the wire, including offline. The +deletion finishes in the background; a failed deletion reports **Failed to +delete draft** and keeps the draft. No empty **[No subject]** thread may show +in between. Check both the toolbar trash button and the mobile Delete Draft +action, including after a pasted image leaves a save pending. In Email's inline detail, deletion closes the detail and preserves the current mail tab and filters; it must not navigate split history to an older composer. Discarding a reply draft must preserve the remaining conversation. A previously cached thread that the server no longer has must leave the lists after opening it or receiving a 404 while changing its labels; a cached record must not override the server's not-found response. Check the same behavior after REST discard. Offline/network -errors alone must never evict a cached thread. +errors alone must never evict a cached thread. An open thread the server answers +404 for stops polling `email/threads//operations`; transient failures keep +polling. Choosing or clearing a send time is local preparation only. The composer remains editable and autosaves normally, shows **Scheduled send: ...** (the time and the diff --git a/docs/architecture/email.md b/docs/architecture/email.md index 4c96256a753..4535aa4e0bd 100644 --- a/docs/architecture/email.md +++ b/docs/architecture/email.md @@ -275,7 +275,12 @@ error-reported refresh. A slow refresh must not delay Undo or leave an Undo-restored editor disabled. Each send owns its mentions, completion target, and undo handle; sending again must preserve the earlier notification's undo action. Standalone compose marks completion before its navigation callback, so -disposal cannot autosave or resend the successful message. +disposal cannot autosave or resend the successful message. Discard navigates +first instead: it pauses autosave and cancels the pending edit, leaves, then +finishes the in-flight save, the local flush, and the deletion in the +background. A queued write can wait out the mutation queue's request and +delivery timeouts, and the optimistic delete empties a draft-only thread +around the composer, so leaving cannot wait on either. The send notice follows each queued send through `EmailDelivery.watchSend`, which reads the device outbox. Undo stays on offer until the deadline the server From 4fdf8f692eb4e90b660649fe14a22f5ec0400fd8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 03:40:54 +0000 Subject: [PATCH 43/75] refactor(email-thread): keep the operations poll policy at module scope Why: Query callbacks created inside a hook can stay cached after the thread unmounts and retain the hook's closure. The web data rules ask for polling policies at module scope. What: Move the NOT_FOUND-aware refetch interval out of useThreadOperations into a module-level function. Co-authored-by: teo --- .../features/email-thread/queries/thread-operations.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/features/email-thread/queries/thread-operations.ts b/apps/web/src/features/email-thread/queries/thread-operations.ts index 9021134ac8d..380e566f8b5 100644 --- a/apps/web/src/features/email-thread/queries/thread-operations.ts +++ b/apps/web/src/features/email-thread/queries/thread-operations.ts @@ -5,6 +5,11 @@ import { emailClient } from '@service-email/client'; import { useQuery } from '@tanstack/solid-query'; import type { Accessor } from 'solid-js'; +// Keep an open thread current after actions from another window or client. +// A deleted thread never comes back, so stop asking for it. +const operationsPollInterval = (query: { state: { error: unknown } }) => + thrownResultErrorHasCode(query.state.error, 'NOT_FOUND') ? false : 5000; + export function useThreadOperations( threadId: Accessor, enabled: Accessor @@ -27,10 +32,7 @@ export function useThreadOperations( previous = { thread, pending }; return operations; }, - // Keep an open thread current after actions from another window or - // client. A deleted thread never comes back, so stop asking for it. - refetchInterval: (query) => - thrownResultErrorHasCode(query.state.error, 'NOT_FOUND') ? false : 5000, + refetchInterval: operationsPollInterval, })); return query; } From 18d135037f4336d3556248061d35a4e322e00fdb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 03:59:56 +0000 Subject: [PATCH 44/75] docs(email-compose): tighten the discard ordering wording Why: The composer comment and the architecture note joined clauses with a colon, which reads as one run-on rule. What: Split them into plain sentences with the same constraints. Co-authored-by: teo --- .../src/features/email-compose/primitives/email-composer.ts | 4 ++-- docs/architecture/email.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts index 61ff13499b5..4e60ec37c9c 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -1112,9 +1112,9 @@ export function createEmailComposer(props: EmailComposerOptions) { setDiscarding(true); autosave.cancel(); // A standalone draft owns its thread, so nothing is left to show. Leave - // before the delete: a queued save or delete can take minutes, and the + // first, because a queued save or delete can take minutes and the // optimistic delete empties a draft-only thread around this composer. - // Discarding pauses autosave, so disposal cannot save the draft again. + // Discarding keeps autosave paused, so disposal cannot save it again. props.host?.goBack?.(); try { // A first save may still be creating the draft. Delete its returned ID diff --git a/docs/architecture/email.md b/docs/architecture/email.md index 4535aa4e0bd..0b7d7470df4 100644 --- a/docs/architecture/email.md +++ b/docs/architecture/email.md @@ -276,7 +276,7 @@ Undo-restored editor disabled. Each send owns its mentions, completion target, and undo handle; sending again must preserve the earlier notification's undo action. Standalone compose marks completion before its navigation callback, so disposal cannot autosave or resend the successful message. Discard navigates -first instead: it pauses autosave and cancels the pending edit, leaves, then +first instead. It pauses autosave and cancels the pending edit, leaves, then finishes the in-flight save, the local flush, and the deletion in the background. A queued write can wait out the mutation queue's request and delivery timeouts, and the optimistic delete empties a draft-only thread From 3a0faab8d715a3bbcceffa2493b8fc81900e521d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 04:12:29 +0000 Subject: [PATCH 45/75] test(email-compose): expect Delete to survive a host that cannot leave Why Delete now leaves before it deletes. If the host's navigation throws, the draft stays undeleted and the composer stays disabled. What Add a Gmail and an Outlook case where the host's goBack throws. Delete must still delete the draft, re-enable the composer, and report the navigation error. Co-authored-by: teo --- .../primitives/compose-persistence.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts index 6bdcfddacc1..1cfe6e84b55 100644 --- a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts +++ b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts @@ -274,6 +274,32 @@ it.each(providers)( expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); } ); +it.each(providers)( + 'Delete still deletes a %s draft and frees the composer when its host cannot leave', + async (provider) => { + const composeContext = providerContext(provider); + const navigation = new Error('navigation failed'); + const goBack = vi.fn(() => { + throw navigation; + }); + const root = mountEmailComposer(composeContext, { goBack }); + root.edit('Discard this draft'); + await vi.advanceTimersByTimeAsync(600); + + root.state.context.onDelete?.(); + await vi.advanceTimersByTimeAsync(1000); + + expect(composeContext.drafts.deleteDraft).toHaveBeenCalledWith( + expect.objectContaining({ draftId: 'draft' }) + ); + expect(root.state.context.disabled()).toBe(false); + expect(goBack).toHaveBeenCalledOnce(); + expect(composeContext.notices.reportError).toHaveBeenCalledWith(navigation); + root.dispose(); + await vi.advanceTimersByTimeAsync(1000); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); + } +); it('waits for the saved draft ID, prevents duplicate sends, and does not recreate the sent draft on disposal', async () => { const pending = Promise.withResolvers(); const composeContext = createComposeContext(); From 1d3a052452337a15d3f1aad323a5bdace17017b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 04:12:58 +0000 Subject: [PATCH 46/75] fix(email-compose): delete a draft even when its host cannot leave Why Delete leaves before it deletes. A host whose goBack throws stopped Delete before the deletion ran, and the composer stayed disabled. What Report a throwing goBack through the composer's error notices and carry on with the deletion, as the sender switch does for showMovedDraft. Co-authored-by: teo --- .../features/email-compose/primitives/email-composer.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts index 4e60ec37c9c..20239789511 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -1115,7 +1115,12 @@ export function createEmailComposer(props: EmailComposerOptions) { // first, because a queued save or delete can take minutes and the // optimistic delete empties a draft-only thread around this composer. // Discarding keeps autosave paused, so disposal cannot save it again. - props.host?.goBack?.(); + // A host that cannot leave must not keep the draft from being deleted. + try { + props.host?.goBack?.(); + } catch (error) { + props.notices.reportError(error); + } try { // A first save may still be creating the draft. Delete its returned ID // after it settles so discard cannot leave an orphan behind. From 32710e5100a858184ec72d6f8a7bfdc9b080c23c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 04:13:43 +0000 Subject: [PATCH 47/75] docs(email): note that Delete survives a host that cannot leave Why The discard ordering paragraph did not say what happens when leaving fails. What State that a failed navigation is reported and the deletion still runs. Co-authored-by: teo --- docs/architecture/email.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/architecture/email.md b/docs/architecture/email.md index 0b7d7470df4..b9fc4360baa 100644 --- a/docs/architecture/email.md +++ b/docs/architecture/email.md @@ -280,7 +280,8 @@ first instead. It pauses autosave and cancels the pending edit, leaves, then finishes the in-flight save, the local flush, and the deletion in the background. A queued write can wait out the mutation queue's request and delivery timeouts, and the optimistic delete empties a draft-only thread -around the composer, so leaving cannot wait on either. +around the composer, so leaving cannot wait on either. A host that fails to +leave is reported, and the deletion still runs. The send notice follows each queued send through `EmailDelivery.watchSend`, which reads the device outbox. Undo stays on offer until the deadline the server From cef96b3fa650cc13adf5164aa344a4c5f293cf47 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 04:31:30 +0000 Subject: [PATCH 48/75] test(email-compose): cover edits made around an emptied draft's delete Why: clearing every field of a saved draft deletes it, and recipients typed while that delete is slow or after it fails before reaching the queue are lost behind "This draft session is no longer active". What: drive the real composer, IndexedDB working copies and the queued delete on Gmail and Outlook, faking only the GraphQL transport and the cache worker. One case holds the delete on the network; the other times out its cache read before the delete is queued. Co-authored-by: teo --- .../primitives/emptied-draft-delete.test.ts | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 apps/web/src/features/email-compose/primitives/emptied-draft-delete.test.ts diff --git a/apps/web/src/features/email-compose/primitives/emptied-draft-delete.test.ts b/apps/web/src/features/email-compose/primitives/emptied-draft-delete.test.ts new file mode 100644 index 00000000000..1396cce4c63 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/emptied-draft-delete.test.ts @@ -0,0 +1,290 @@ +import 'fake-indexeddb/auto'; +import { createClient, type Operation } from '@urql/core'; +import { IDBFactory } from 'fake-indexeddb'; +import { Kind } from 'graphql'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { filter, fromPromise, mergeMap, pipe } from 'wonka'; +import { + DraftPersistRejected, + type EmailInbox, + type SaveEmailDraft, +} from '../context/compose-capabilities'; +import type { EmailRecipient } from '../core/email-recipient'; +import { createComposeContext } from '../tests/capabilities'; +import { mountEmailComposer } from '../tests/composer'; + +const network = vi.hoisted(() => ({ + client: undefined as unknown, + readRecordsByKeys: undefined as unknown as ( + request: unknown + ) => Promise, +})); + +vi.mock('@app/lib/analytics', () => ({ + analytics: { track: vi.fn(), posthog: { captureException: vi.fn() } }, +})); +vi.mock('@queries/client', () => ({ + queryClient: { + getQueryData: () => ({ authenticated: true, id: 'owner' }), + invalidateQueries: vi.fn(async () => {}), + }, +})); +vi.mock('@core/component/Toast/Toast', () => ({ + toast: { failure: vi.fn() }, +})); +vi.mock('@core/constant/featureFlags', () => ({ + enableGraphqlSoup: 'graphql-soup', + isFeatureEnabled: () => true, +})); +vi.mock('@macro-inc/observability', () => ({ + Telemetry: { error: vi.fn() }, +})); +vi.mock('@service-storage/graphql-soup', () => ({ + assertEmailDraftQueueAvailable: () => {}, + getGraphqlCacheHost: () => ({ + readRecordsByKeys: (request: unknown) => network.readRecordsByKeys(request), + }), + getGraphqlSoupClient: () => network.client, + graphqlCacheEnabled: () => true, + graphqlCacheRecoveryState: () => undefined, + graphqlDraftQueueBlocked: () => false, + retryGraphqlCacheInitialization: async () => {}, +})); +vi.mock('@queries/soup/cache', () => ({ + invalidateAllSoup: vi.fn(), + invalidateSoupEntity: vi.fn(), + refetchSoupEntity: vi.fn(async () => {}), +})); +vi.mock('@queries/email/draft-cache', () => ({ + markThreadDraftSaved: vi.fn(), +})); +vi.mock('@queries/email/thread', () => ({ fetchAndCacheThread: vi.fn() })); + +let localDrafts: typeof import('@queries/email/local-drafts'); +let draftQueue: typeof import('@queries/email/draft-queue'); + +beforeEach(async () => { + vi.resetModules(); + vi.stubGlobal('indexedDB', new IDBFactory()); + localStorage.clear(); + localDrafts = await import('@queries/email/local-drafts'); + draftQueue = await import('@queries/email/draft-queue'); +}); +afterEach(async () => { + await localDrafts.localDraftStore.close(); + vi.unstubAllGlobals(); +}); + +const providers: [string, EmailInbox][] = [ + [ + 'Gmail', + { + id: 'inbox', + email_address: 'teo@gmail.example', + provider: 'GMAIL', + settings: {}, + }, + ], + [ + 'Outlook', + { + id: 'inbox', + email_address: 'teo@outlook.example', + provider: 'OUTLOOK', + settings: {}, + }, + ], +]; + +const recipient = (email: string): EmailRecipient => ({ + kind: 'custom', + id: `macro|${email}`, + data: { id: `macro|${email}`, email, invalid: false }, +}); + +const operationName = (operation: Operation) => + operation.query.definitions.find( + (definition) => definition.kind === Kind.OPERATION_DEFINITION + )?.name?.value; + +/** + * The durable draft queue against IndexedDB working copies. Only the + * GraphQL transport and the cache worker are fake: a delete waits for the + * test to answer it, then comes back queued, as a timed-out request does. + */ +function createQueueContext(inbox: EmailInbox) { + const deleteSent = Promise.withResolvers(); + const deleteAnswered = Promise.withResolvers(); + network.readRecordsByKeys = async () => ({ revision: '1', records: [] }); + network.client = createClient({ + url: 'http://example.test/graphql', + exchanges: [ + () => (operations$) => + pipe( + operations$, + filter((operation) => operation.kind === 'mutation'), + mergeMap((operation) => + fromPromise( + (async () => { + if (operationName(operation) === 'DeleteEmailDraft') { + deleteSent.resolve(); + await deleteAnswered.promise; + } + return { + operation, + stale: false, + hasNext: false, + extensions: { + normalizedCacheMutationDisposition: { + kind: 'queued', + transactionId: crypto.randomUUID(), + }, + }, + }; + })() + ) + ) + ), + ], + }); + + const context = createComposeContext(); + context.accounts.inboxes = () => [inbox]; + context.drafts.saveLocalDraft = (input) => + localDrafts.saveLocalDraft({ + ...input, + senderEmail: inbox.email_address, + }); + const saves: SaveEmailDraft[] = []; + context.drafts.saveDraft = vi.fn(async (input) => { + saves.push(input); + return { + draftId: input.clientHandles?.draftId ?? input.draft.db_id ?? '', + threadId: input.clientHandles?.threadId ?? input.draft.thread_db_id, + inboxId: input.inboxId ?? inbox.id, + persistence: 'queued' as const, + }; + }); + context.drafts.deleteDraft = vi.fn(async (input) => { + if (!input.threadId) throw new Error('A new draft has a thread handle'); + const outcome = await draftQueue.deleteEmailDraftQueued({ + draftId: input.draftId, + draftGeneration: input.draftGeneration, + threadId: input.threadId, + completingThread: input.completingThread, + }); + if (outcome.kind === 'rejected') + throw new DraftPersistRejected(outcome.code); + }); + return { + context, + saves, + deleteSent: deleteSent.promise, + answerDelete: () => deleteAnswered.resolve(), + }; +} + +const addressedTo = (save: SaveEmailDraft, email: string) => + save.draft.to?.some((contact) => contact.email === email) ?? false; + +const localSaveFailures = (failure: ReturnType) => + failure.mock.calls.filter( + ([message]) => message === 'Draft could not be saved on this device' + ); + +/** Type, then clear every field so the composer deletes the draft it saved. */ +async function emptySavedDraft( + root: ReturnType, + saves: SaveEmailDraft[] +) { + root.edit('First version', ''); + await vi.waitFor(() => expect(saves).toHaveLength(1)); + root.edit('', ''); + root.state.context.setRecipients('to', []); +} + +it.each(providers)( + 'keeps a recipient added on %s while the emptied draft is still being deleted', + async (_provider, inbox) => { + const { context, saves, deleteSent, answerDelete } = + createQueueContext(inbox); + const root = mountEmailComposer(context); + try { + await emptySavedDraft(root, saves); + await deleteSent; + + root.state.context.setRecipients('to', [recipient('new@example.com')]); + await root.state.flushLocal().catch(() => {}); + expect( + localSaveFailures(vi.mocked(context.notices.feedback.failure)) + ).toEqual([]); + expect(root.state.localSaveState()).not.toBe('failed'); + + answerDelete(); + await vi.waitFor( + () => + expect( + saves.some((save) => addressedTo(save, 'new@example.com')) + ).toBe(true), + { timeout: 3_000 } + ); + const saved = saves.find((save) => addressedTo(save, 'new@example.com'))!; + expect(saved.clientHandles?.draftId).not.toBe( + saves[0].clientHandles?.draftId + ); + expect( + await localDrafts.readLocalDraft(saved.clientHandles!.draftId) + ).toMatchObject({ + content: { + to: [expect.objectContaining({ email: 'new@example.com' })], + }, + }); + expect(root.state.localSaveState()).toBe('saved'); + expect( + localSaveFailures(vi.mocked(context.notices.feedback.failure)) + ).toEqual([]); + } finally { + root.dispose(); + } + } +); + +it.each(providers)( + 'keeps a recipient added on %s after the emptied draft could not be queued for deletion', + async (_provider, inbox) => { + const { context, saves } = createQueueContext(inbox); + network.readRecordsByKeys = async () => { + throw new Error('cache worker timeout: read-records-by-keys'); + }; + const root = mountEmailComposer(context); + try { + await emptySavedDraft(root, saves); + await vi.waitFor(() => + expect(context.notices.feedback.failure).toHaveBeenCalledWith( + 'Failed to delete draft' + ) + ); + + root.state.context.setRecipients('to', [recipient('new@example.com')]); + await vi.waitFor( + () => + expect( + saves.some((save) => addressedTo(save, 'new@example.com')) + ).toBe(true), + { timeout: 3_000 } + ); + const draftId = saves[0].clientHandles!.draftId; + expect(await localDrafts.readLocalDraft(draftId)).toMatchObject({ + content: { + to: [expect.objectContaining({ email: 'new@example.com' })], + }, + }); + expect(root.state.localSaveState()).toBe('saved'); + expect( + localSaveFailures(vi.mocked(context.notices.feedback.failure)) + ).toEqual([]); + } finally { + root.dispose(); + } + } +); From fb96a63b928f044e8a1c2d7a4f861237d8d9b779 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 04:35:52 +0000 Subject: [PATCH 49/75] test(email-compose): hold the emptied draft's delete past autosave Why: a slow delete outlasts the 500 ms autosave debounce, so the debounced save is skipped too and the edit stays unsaved. The reply composer deletes an emptied reply through the same persistence. What: hold the delete past the debounce, assert the local save settles without a failure notice, and run both cases on the reply composer. Co-authored-by: teo --- .../primitives/emptied-draft-delete.test.ts | 187 +++++++++++++----- 1 file changed, 142 insertions(+), 45 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/emptied-draft-delete.test.ts b/apps/web/src/features/email-compose/primitives/emptied-draft-delete.test.ts index 1396cce4c63..cbe8130f110 100644 --- a/apps/web/src/features/email-compose/primitives/emptied-draft-delete.test.ts +++ b/apps/web/src/features/email-compose/primitives/emptied-draft-delete.test.ts @@ -9,9 +9,11 @@ import { type EmailInbox, type SaveEmailDraft, } from '../context/compose-capabilities'; +import { decodeBase64Utf8 } from '../core/decode-base64'; import type { EmailRecipient } from '../core/email-recipient'; import { createComposeContext } from '../tests/capabilities'; import { mountEmailComposer } from '../tests/composer'; +import { mountReplyComposer } from '../tests/reply'; const network = vi.hoisted(() => ({ client: undefined as unknown, @@ -186,11 +188,45 @@ function createQueueContext(inbox: EmailInbox) { const addressedTo = (save: SaveEmailDraft, email: string) => save.draft.to?.some((contact) => contact.email === email) ?? false; +const saysIn = (save: SaveEmailDraft, text: string) => + decodeBase64Utf8(save.draft.body_html ?? '').includes(text); -const localSaveFailures = (failure: ReturnType) => - failure.mock.calls.filter( - ([message]) => message === 'Draft could not be saved on this device' +async function waitForSave( + saves: SaveEmailDraft[], + matches: (save: SaveEmailDraft) => boolean +) { + return await vi.waitFor( + () => { + const save = saves.find(matches); + expect(save).toBeDefined(); + return save!; + }, + { timeout: 3_000 } ); +} + +/** Edits are kept on the device and no local-save failure was shown. */ +async function expectSavedLocally( + state: { localSaveState(): 'saving' | 'saved' | 'failed' }, + failure: ReturnType +) { + await vi.waitFor(() => expect(state.localSaveState()).toBe('saved'), { + timeout: 3_000, + }); + expect( + failure.mock.calls.filter( + ([message]) => message === 'Draft could not be saved on this device' + ) + ).toEqual([]); +} + +/** A slow delete outlasts the composer's 500 ms autosave debounce. */ +const outlastAutosaveDebounce = () => + new Promise((resolve) => setTimeout(resolve, 700)); + +const timedOutCacheRead = async () => { + throw new Error('cache worker timeout: read-records-by-keys'); +}; /** Type, then clear every field so the composer deletes the draft it saved. */ async function emptySavedDraft( @@ -198,9 +234,24 @@ async function emptySavedDraft( saves: SaveEmailDraft[] ) { root.edit('First version', ''); - await vi.waitFor(() => expect(saves).toHaveLength(1)); + const first = await waitForSave(saves, (save) => + saysIn(save, 'First version') + ); root.edit('', ''); root.state.context.setRecipients('to', []); + return first.clientHandles!.draftId; +} + +async function emptySavedReply( + reply: ReturnType, + saves: SaveEmailDraft[] +) { + reply.edit('First version'); + const first = await waitForSave(saves, (save) => + saysIn(save, 'First version') + ); + reply.edit(''); + return first.clientHandles!.draftId; } it.each(providers)( @@ -210,39 +261,26 @@ it.each(providers)( createQueueContext(inbox); const root = mountEmailComposer(context); try { - await emptySavedDraft(root, saves); + const emptied = await emptySavedDraft(root, saves); await deleteSent; - root.state.context.setRecipients('to', [recipient('new@example.com')]); - await root.state.flushLocal().catch(() => {}); - expect( - localSaveFailures(vi.mocked(context.notices.feedback.failure)) - ).toEqual([]); - expect(root.state.localSaveState()).not.toBe('failed'); - + await outlastAutosaveDebounce(); answerDelete(); - await vi.waitFor( - () => - expect( - saves.some((save) => addressedTo(save, 'new@example.com')) - ).toBe(true), - { timeout: 3_000 } + + await expectSavedLocally( + root.state, + vi.mocked(context.notices.feedback.failure) ); - const saved = saves.find((save) => addressedTo(save, 'new@example.com'))!; - expect(saved.clientHandles?.draftId).not.toBe( - saves[0].clientHandles?.draftId + const saved = await waitForSave(saves, (save) => + addressedTo(save, 'new@example.com') ); - expect( - await localDrafts.readLocalDraft(saved.clientHandles!.draftId) - ).toMatchObject({ + const draftId = saved.clientHandles!.draftId; + expect(draftId).not.toBe(emptied); + expect(await localDrafts.readLocalDraft(draftId)).toMatchObject({ content: { to: [expect.objectContaining({ email: 'new@example.com' })], }, }); - expect(root.state.localSaveState()).toBe('saved'); - expect( - localSaveFailures(vi.mocked(context.notices.feedback.failure)) - ).toEqual([]); } finally { root.dispose(); } @@ -253,38 +291,97 @@ it.each(providers)( 'keeps a recipient added on %s after the emptied draft could not be queued for deletion', async (_provider, inbox) => { const { context, saves } = createQueueContext(inbox); - network.readRecordsByKeys = async () => { - throw new Error('cache worker timeout: read-records-by-keys'); - }; + network.readRecordsByKeys = timedOutCacheRead; const root = mountEmailComposer(context); try { - await emptySavedDraft(root, saves); + const emptied = await emptySavedDraft(root, saves); await vi.waitFor(() => expect(context.notices.feedback.failure).toHaveBeenCalledWith( 'Failed to delete draft' ) ); - root.state.context.setRecipients('to', [recipient('new@example.com')]); - await vi.waitFor( - () => - expect( - saves.some((save) => addressedTo(save, 'new@example.com')) - ).toBe(true), - { timeout: 3_000 } + + await expectSavedLocally( + root.state, + vi.mocked(context.notices.feedback.failure) ); - const draftId = saves[0].clientHandles!.draftId; - expect(await localDrafts.readLocalDraft(draftId)).toMatchObject({ + const saved = await waitForSave(saves, (save) => + addressedTo(save, 'new@example.com') + ); + expect(saved.clientHandles?.draftId).toBe(emptied); + expect(await localDrafts.readLocalDraft(emptied)).toMatchObject({ content: { to: [expect.objectContaining({ email: 'new@example.com' })], }, }); - expect(root.state.localSaveState()).toBe('saved'); - expect( - localSaveFailures(vi.mocked(context.notices.feedback.failure)) - ).toEqual([]); } finally { root.dispose(); } } ); + +it.each(providers)( + 'keeps reply text typed on %s while the emptied reply is still being deleted', + async (_provider, inbox) => { + const { context, saves, deleteSent, answerDelete } = + createQueueContext(inbox); + const reply = mountReplyComposer(context); + try { + const emptied = await emptySavedReply(reply, saves); + await deleteSent; + reply.edit('Second version'); + await outlastAutosaveDebounce(); + answerDelete(); + + await expectSavedLocally( + reply, + vi.mocked(context.notices.feedback.failure) + ); + const saved = await waitForSave(saves, (save) => + saysIn(save, 'Second version') + ); + const draftId = saved.clientHandles!.draftId; + expect(draftId).not.toBe(emptied); + const local = await localDrafts.readLocalDraft(draftId); + expect(decodeBase64Utf8(local?.content.body_html ?? '')).toContain( + 'Second version' + ); + } finally { + reply.dispose(); + } + } +); + +it.each(providers)( + 'keeps reply text typed on %s after the emptied reply could not be queued for deletion', + async (_provider, inbox) => { + const { context, saves } = createQueueContext(inbox); + network.readRecordsByKeys = timedOutCacheRead; + const reply = mountReplyComposer(context); + try { + const emptied = await emptySavedReply(reply, saves); + await vi.waitFor(() => + expect(context.notices.feedback.failure).toHaveBeenCalledWith( + 'Failed to delete draft' + ) + ); + reply.edit('Second version'); + + await expectSavedLocally( + reply, + vi.mocked(context.notices.feedback.failure) + ); + const saved = await waitForSave(saves, (save) => + saysIn(save, 'Second version') + ); + expect(saved.clientHandles?.draftId).toBe(emptied); + const local = await localDrafts.readLocalDraft(emptied); + expect(decodeBase64Utf8(local?.content.body_html ?? '')).toContain( + 'Second version' + ); + } finally { + reply.dispose(); + } + } +); From fc8121d20fa32f73cef9373c08b0f43f08ed535e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 04:41:10 +0000 Subject: [PATCH 50/75] test(email-compose): cover an emptied draft's uncertain delete enqueue Why: a delete whose enqueue outcome is uncertain comes back as a network failure and leaves the working copy fenced the same way. What: answer the delete with a network error and expect a recipient added afterwards to save on Gmail and Outlook. Co-authored-by: teo --- .../primitives/emptied-draft-delete.test.ts | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/emptied-draft-delete.test.ts b/apps/web/src/features/email-compose/primitives/emptied-draft-delete.test.ts index cbe8130f110..5817002ab84 100644 --- a/apps/web/src/features/email-compose/primitives/emptied-draft-delete.test.ts +++ b/apps/web/src/features/email-compose/primitives/emptied-draft-delete.test.ts @@ -1,5 +1,5 @@ import 'fake-indexeddb/auto'; -import { createClient, type Operation } from '@urql/core'; +import { CombinedError, createClient, type Operation } from '@urql/core'; import { IDBFactory } from 'fake-indexeddb'; import { Kind } from 'graphql'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; @@ -17,6 +17,7 @@ import { mountReplyComposer } from '../tests/reply'; const network = vi.hoisted(() => ({ client: undefined as unknown, + uncertainDelete: false, readRecordsByKeys: undefined as unknown as ( request: unknown ) => Promise, @@ -117,6 +118,7 @@ const operationName = (operation: Operation) => function createQueueContext(inbox: EmailInbox) { const deleteSent = Promise.withResolvers(); const deleteAnswered = Promise.withResolvers(); + network.uncertainDelete = false; network.readRecordsByKeys = async () => ({ revision: '1', records: [] }); network.client = createClient({ url: 'http://example.test/graphql', @@ -129,6 +131,17 @@ function createQueueContext(inbox: EmailInbox) { fromPromise( (async () => { if (operationName(operation) === 'DeleteEmailDraft') { + if (network.uncertainDelete) + return { + operation, + stale: false, + hasNext: false, + error: new CombinedError({ + networkError: new Error( + 'cache storage changed during enqueue' + ), + }), + }; deleteSent.resolve(); await deleteAnswered.promise; } @@ -228,6 +241,25 @@ const timedOutCacheRead = async () => { throw new Error('cache worker timeout: read-records-by-keys'); }; +const unqueuedDeletes = providers.flatMap(([provider, inbox]) => [ + [ + provider, + 'its cache read timed out', + inbox, + () => { + network.readRecordsByKeys = timedOutCacheRead; + }, + ] as const, + [ + provider, + 'its enqueue outcome was uncertain', + inbox, + () => { + network.uncertainDelete = true; + }, + ] as const, +]); + /** Type, then clear every field so the composer deletes the draft it saved. */ async function emptySavedDraft( root: ReturnType, @@ -287,11 +319,11 @@ it.each(providers)( } ); -it.each(providers)( - 'keeps a recipient added on %s after the emptied draft could not be queued for deletion', - async (_provider, inbox) => { +it.each(unqueuedDeletes)( + "keeps a recipient added on %s after the emptied draft's delete failed because %s", + async (_provider, _failure, inbox, failDelete) => { const { context, saves } = createQueueContext(inbox); - network.readRecordsByKeys = timedOutCacheRead; + failDelete(); const root = mountEmailComposer(context); try { const emptied = await emptySavedDraft(root, saves); From 736fbac1f227f25f7b117b42b19d458bc419d62a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 04:41:10 +0000 Subject: [PATCH 51/75] fix(email): release a draft whose delete never reached the queue Why: a queued delete marks the working copy as deleting before it reads the cache and enqueues. When that read times out or the enqueue outcome is uncertain, nothing clears the mark, so every later edit to the draft fails with "This draft session is no longer active" and its server save is skipped. What: abandonDraftDelete returns an unadmitted delete's working copy to dirty and clears its attempt, so the editor can keep saving and a late replay of an uncertain enqueue is refused. deleteEmailDraftQueued calls it when the delete throws or fails before admission. An admitted delete is left to the queue. Co-authored-by: teo --- apps/web/src/lib/queries/email/draft-queue.ts | 44 ++++++++++++------- .../lib/queries/email/local-drafts.test.ts | 23 ++++++++++ .../web/src/lib/queries/email/local-drafts.ts | 18 ++++++++ 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/apps/web/src/lib/queries/email/draft-queue.ts b/apps/web/src/lib/queries/email/draft-queue.ts index 2f993a79d35..6838f413636 100644 --- a/apps/web/src/lib/queries/email/draft-queue.ts +++ b/apps/web/src/lib/queries/email/draft-queue.ts @@ -40,6 +40,7 @@ import { import { mapGraphqlEmailMessage } from './graphql/mapper'; import { emailKeys } from './keys'; import { + abandonDraftDelete, beginDraftAttempt, draftSyncPaused, localDraftMessage, @@ -376,22 +377,35 @@ export async function deleteEmailDraftQueued(input: { assertEmailDraftQueueAvailable(); const local = await readLocalDraft(input.draftId); const attempt = local ? await beginDraftAttempt(local, 'delete') : undefined; - const cached = await readCachedDraftAndThread(input.draftId, input.threadId); - const client = getGraphqlSoupClient(); - assertEmailDraftQueueAvailable(); - const outcome = await executeGraphqlDeleteEmailDraft(client, { - existingThread: cached.existingThread, - mutationUuid: cached.mutationUuid ?? input.draftId, - draftId: local?.draftId ?? String(cached.draftId), - clientMetadata: attempt, - draftGeneration: - input.draftGeneration ?? - local?.content.draft_generation ?? - cached.existingDraft?.draftGeneration ?? - 0, - threadDbId: cached.threadDbId, - }); + const abandon = async () => { + if (attempt) await abandonDraftDelete(attempt).catch(reportError); + }; + let outcome: Awaited>; + try { + const cached = await readCachedDraftAndThread( + input.draftId, + input.threadId + ); + const client = getGraphqlSoupClient(); + assertEmailDraftQueueAvailable(); + outcome = await executeGraphqlDeleteEmailDraft(client, { + existingThread: cached.existingThread, + mutationUuid: cached.mutationUuid ?? input.draftId, + draftId: local?.draftId ?? String(cached.draftId), + clientMetadata: attempt, + draftGeneration: + input.draftGeneration ?? + local?.content.draft_generation ?? + cached.existingDraft?.draftGeneration ?? + 0, + threadDbId: cached.threadDbId, + }); + } catch (error) { + await abandon(); + throw error; + } if (outcome.kind === 'failed') { + await abandon(); return { kind: 'rejected', code: rejection(outcome.code, 'Failed to delete draft', input.threadId), diff --git a/apps/web/src/lib/queries/email/local-drafts.test.ts b/apps/web/src/lib/queries/email/local-drafts.test.ts index c6e070f2b12..8777ae07eb0 100644 --- a/apps/web/src/lib/queries/email/local-drafts.test.ts +++ b/apps/web/src/lib/queries/email/local-drafts.test.ts @@ -523,6 +523,29 @@ describe('draft queue recovery', () => { acknowledgedRevision: draft.revision, }); }); + it('keeps edits after an abandoned delete and refuses its late replay', async () => { + const draft = await runtime.saveLocalDraft(input()); + const deleting = await runtime.beginDraftAttempt(draft, 'delete'); + await runtime.abandonDraftDelete(deleting); + await runtime.saveLocalDraft(input('Kept edit')); + const replay = { ...claimed(deleting), operationName: 'DeleteEmailDraft' }; + expect( + await runtime.localDraftQueueLifecycle(host).beforeMutationAttempt!( + replay + ) + ).toBe(false); + expect(await runtime.readLocalDraft('local')).toMatchObject({ + status: 'dirty', + content: { subject: 'Kept edit' }, + }); + }); + it('leaves an admitted delete to the queue', async () => { + const draft = await runtime.saveLocalDraft(input()); + const deleting = await runtime.beginDraftAttempt(draft, 'delete'); + await runtime.markDraftAttemptQueued(deleting); + await runtime.abandonDraftDelete(deleting); + expect((await runtime.readLocalDraft('local'))?.status).toBe('deleting'); + }); it('does not acknowledge later edits or let an old result clear newer failure', async () => { const first = await runtime.saveLocalDraft(input('First')); const saving = await runtime.beginDraftAttempt(first, 'save'); diff --git a/apps/web/src/lib/queries/email/local-drafts.ts b/apps/web/src/lib/queries/email/local-drafts.ts index 0765c00f3dc..bac7e4f46de 100644 --- a/apps/web/src/lib/queries/email/local-drafts.ts +++ b/apps/web/src/lib/queries/email/local-drafts.ts @@ -503,6 +503,24 @@ export async function beginDraftAttempt( return attempt; } +/** + * A delete that never reached the queue returns the working copy to its + * editor. Clearing the attempt also refuses a replay of an enqueue whose + * outcome was uncertain. + */ +export async function abandonDraftDelete(attempt: DraftAttempt): Promise { + const owner = await emailStorageSession(); + if (owner.accountId !== attempt.accountId) return; + await localDraftStore.update(owner, attempt.draftKey, (draft) => + draft.generation !== attempt.generation || + draft.latestAttemptId !== attempt.id || + draft.queuedAttemptId === attempt.id || + draft.status !== 'deleting' + ? draft + : { ...draft, latestAttemptId: undefined, status: 'dirty' } + ); +} + /** Admission is separate from intent: two stores cannot enqueue atomically. */ export async function markDraftAttemptQueued( attempt: DraftAttempt From 6bd5bf9b47416a0f049e9be0992562823da48950 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 04:41:10 +0000 Subject: [PATCH 52/75] fix(email-compose): save edits made while an emptied draft is deleted Why: clearing every field deletes the draft, but the composer keeps its identity until the delete answers. Edits made meanwhile are written to the copy being deleted, which refuses them with "This draft session is no longer active", and their server save is skipped. A slow Outlook delete keeps that window open for seconds. What: local writes wait for an in-flight remove, then address the draft it leaves: a fresh draft after the delete, or the same draft when the delete failed. Both composers share this persistence. Co-authored-by: teo --- .../primitives/draft-persistence.ts | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/draft-persistence.ts b/apps/web/src/features/email-compose/primitives/draft-persistence.ts index 89bc1b421e9..3c5c9c542ae 100644 --- a/apps/web/src/features/email-compose/primitives/draft-persistence.ts +++ b/apps/web/src/features/email-compose/primitives/draft-persistence.ts @@ -71,6 +71,7 @@ export function createDraftPersistence(options: { const { session } = options; let localRevision = options.localDraft?.revision; let localGeneration = options.localDraft?.generation; + let removal: Promise = Promise.resolve(); const mint = (draft: EmailDraft) => { if (session.draftId()) return; localRevision = undefined; @@ -109,6 +110,9 @@ export function createDraftPersistence(options: { attachments: readonly DraftFormAttachment[]; }) { if (!options.drafts.saveLocalDraft) return; + // The copy being deleted refuses edits. Address the draft the delete + // leaves: a fresh one, or the same one when the delete failed. + await removal; // Preserve an emptied working copy too; a failed delete must not restore // the earlier body on reload. An untouched composer has no identity. if (!input.draft && !session.draftId()) return; @@ -162,23 +166,29 @@ export function createDraftPersistence(options: { }, /** The draft has no content left: delete its row and drop its identity. */ async remove(input: { inboxId?: string; completingThread?: boolean }) { - const draftId = session.draftId(); - const epoch = session.epoch(); - if (draftId) { - try { - await options.drafts.deleteDraft({ - draftId, - draftGeneration: options.draftGeneration?.() ?? 0, - threadId: session.threadId(), - ...input, - }); - } catch (error) { - recordRejection(error, epoch); - throw error; + const removed = Promise.withResolvers(); + removal = removed.promise; + try { + const draftId = session.draftId(); + const epoch = session.epoch(); + if (draftId) { + try { + await options.drafts.deleteDraft({ + draftId, + draftGeneration: options.draftGeneration?.() ?? 0, + threadId: session.threadId(), + ...input, + }); + } catch (error) { + recordRejection(error, epoch); + throw error; + } } + if (session.isStale(epoch)) return; + session.dispatch({ type: 'emptied' }); + } finally { + removed.resolve(); } - if (session.isStale(epoch)) return; - session.dispatch({ type: 'emptied' }); }, /** From 9de789e3bf1f9febffb273b9083e830325a45b82 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 04:50:57 +0000 Subject: [PATCH 53/75] test(email-compose): cover typing that reaches a field before To focuses Why: Under load the To field's deferred mount focus runs after the user has already clicked into the subject or message, and the rest of their typing lands in To. What: Mount the real compose view and drive it with user-event on Gmail and Outlook inboxes. Type into the subject or message before the deferred focus runs, then after, and expect every character in the field the user chose. Also pin that a fresh compose still focuses To. Co-authored-by: teo --- .../views/compose-focus.test.tsx | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 apps/web/src/features/email-compose/views/compose-focus.test.tsx diff --git a/apps/web/src/features/email-compose/views/compose-focus.test.tsx b/apps/web/src/features/email-compose/views/compose-focus.test.tsx new file mode 100644 index 00000000000..4bd23a9d750 --- /dev/null +++ b/apps/web/src/features/email-compose/views/compose-focus.test.tsx @@ -0,0 +1,156 @@ +import { cleanup, render, screen } from '@solidjs/testing-library'; +import userEvent from '@testing-library/user-event'; +import type { JSX, ParentProps } from 'solid-js'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { useCompose } from '../context/compose-context'; +import { createComposeContext } from '../tests/capabilities'; +import { createEmailEditor, setEmailEditorText } from '../tests/editor'; +import { EmailComposeView } from './email-compose'; + +vi.mock('@components/app/split-layout/components/SplitHeader', () => ({ + SplitHeaderLeft: () => null, +})); +vi.mock('@components/app/split-layout/components/SplitLabel', () => ({ + SplitHeaderBadge: () => null, + StaticSplitLabel: () => null, +})); +vi.mock('@core/mobile/WrapUnlessMobile', () => ({ + WrapUnlessMobile: (props: ParentProps) => props.children, +})); +vi.mock('@core/hotkey/hotkeys', () => ({ + registerHotkey: vi.fn(), + useHotkeyDOMScope: () => [vi.fn(), 'compose-email'], +})); +vi.mock('@core/context/user', () => ({ + useEmail: () => () => 'me@example.com', + useUserId: () => () => 'macro|me@example.com', +})); +vi.mock('@core/user/dmActivity', () => ({ + useAugmentUserWithDmActivity: () => (user: unknown) => user, +})); +vi.mock('@core/component/HoverCard', () => ({ + HoverCard: (props: { trigger: JSX.Element }) => <>{props.trigger}, +})); +vi.mock('@core/component/UserIcon', () => ({ UserIcon: () => null })); +vi.mock('virtua/solid', async () => { + const { For } = await import('solid-js'); + return { + VList: (props: { + data: unknown[]; + children: (item: unknown) => JSX.Element; + }) => {props.children}, + }; +}); +vi.mock('../components/signature-preview', () => ({ + SignaturePreview: () => null, +})); +vi.mock('../components/email-schedule-summary', () => ({ + EmailScheduleBar: () => null, +})); +vi.mock('./compose-toolbar', () => ({ EmailComposeToolbar: () => null })); +// The rich text editor is out of scope; the body only needs to take typing. +vi.mock('./compose-body', () => ({ + ComposeBody: () => { + const state = useCompose(); + const editor = createEmailEditor(); + state.captureEditor(editor); + state.onContentChange(''); + return ( +