` with
+ * explicit `
` separators, matching how Gmail structures composed mail.
+ * Email clients apply their own margins to `
`, so relying on them renders
+ * differently per client. The editor shows a paragraph break as a blank line,
+ * so non-empty paragraphs are joined by two `
`s; empty paragraphs already
+ * export their own `
` and need no extra separator.
+ */
+export function flattenConsecutiveParagraphs(container: Element) {
+ let nextSibling: Element | null = null;
+ let div: HTMLDivElement | undefined;
+
+ for (const p of container.querySelectorAll('p')) {
+ const attributes = Array.from(p.attributes);
+ if (
+ !div ||
+ p !== nextSibling ||
+ div.attributes.length !== attributes.length ||
+ attributes.some(({ name, value }) => div?.getAttribute(name) !== value)
+ ) {
+ div = document.createElement('div');
+ for (const { name, value } of attributes) div.setAttribute(name, value);
+ p.before(div);
+ }
+ nextSibling = p.nextElementSibling;
+ const isEmpty =
+ !p.textContent?.trim() && !p.querySelector('img, video, iframe, canvas');
+ div.append(...p.childNodes);
+ if (nextSibling?.matches('p') && !isEmpty) {
+ // Retain the blank line even when the next paragraph has a different style.
+ div.append(document.createElement('br'), document.createElement('br'));
+ }
+ p.remove();
+ }
+}
diff --git a/apps/web/src/features/email-compose/primitives/mention-to-cc.test.ts b/apps/web/src/features/email-compose/primitives/mention-to-cc.test.ts
new file mode 100644
index 00000000000..75bb233e700
--- /dev/null
+++ b/apps/web/src/features/email-compose/primitives/mention-to-cc.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it, vi } from 'vitest';
+import { convertContactInfoToEmailRecipient } from '../core/recipient-conversion';
+import { addUserMentionToCc } from './mention-to-cc';
+
+describe('mention recipients', () => {
+ it('adds a known contact once and leaves recipients in To or Bcc untouched', () => {
+ const contact = convertContactInfoToEmailRecipient({
+ email: 'person@example.com',
+ name: 'Person',
+ });
+ const setCc = vi.fn();
+ const onRecipientAdded = vi.fn();
+ const params = {
+ mention: { email: contact.data.email },
+ recipientOptions: [contact],
+ toRecipients: [],
+ ccRecipients: [],
+ bccRecipients: [],
+ setCc,
+ onRecipientAdded,
+ };
+ addUserMentionToCc(params);
+ expect(setCc).toHaveBeenCalledWith([contact]);
+ expect(onRecipientAdded).toHaveBeenCalledWith(contact.data.email);
+ setCc.mockClear();
+ onRecipientAdded.mockClear();
+ for (const field of ['toRecipients', 'ccRecipients', 'bccRecipients'])
+ addUserMentionToCc({ ...params, [field]: [contact] });
+ expect(setCc).not.toHaveBeenCalled();
+ expect(onRecipientAdded).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/features/block-email/util/mentionToCc.ts b/apps/web/src/features/email-compose/primitives/mention-to-cc.ts
similarity index 67%
rename from apps/web/src/features/block-email/util/mentionToCc.ts
rename to apps/web/src/features/email-compose/primitives/mention-to-cc.ts
index 0349615f14d..b46c3c61fd1 100644
--- a/apps/web/src/features/block-email/util/mentionToCc.ts
+++ b/apps/web/src/features/email-compose/primitives/mention-to-cc.ts
@@ -1,15 +1,15 @@
-import type { EmailRecipient } from '@block-email/component/EmailContext';
-import { convertContactInfoToEmailRecipient } from '@block-email/util/recipientConversion';
-import type { UserMentionRecord } from '@core/component/LexicalMarkdown/utils/mentionsUtils';
-import { toast } from '@core/component/Toast/Toast';
+import type { EmailRecipient } from '@app/features/email-compose/core/email-recipient';
+
+import { convertContactInfoToEmailRecipient } from '../core/recipient-conversion';
export function addUserMentionToCc(params: {
- mention: UserMentionRecord;
+ mention: { email?: string };
recipientOptions: EmailRecipient[];
toRecipients: EmailRecipient[];
ccRecipients: EmailRecipient[];
bccRecipients: EmailRecipient[];
setCc: (next: EmailRecipient[]) => void;
+ onRecipientAdded?: (email: string) => void;
}) {
const {
mention,
@@ -38,5 +38,5 @@ export function addUserMentionToCc(params: {
convertContactInfoToEmailRecipient({ email: mentionEmail });
setCc([...ccRecipients, userOption]);
- toast.success(`${mentionEmail} added to CC`);
+ params.onRecipientAdded?.(mentionEmail);
}
diff --git a/apps/web/src/features/email-compose/primitives/prepare-email-body.test.ts b/apps/web/src/features/email-compose/primitives/prepare-email-body.test.ts
new file mode 100644
index 00000000000..556d19b55b6
--- /dev/null
+++ b/apps/web/src/features/email-compose/primitives/prepare-email-body.test.ts
@@ -0,0 +1,110 @@
+// @vitest-environment jsdom
+
+import { $generateNodesFromDOM } from '@lexical/html';
+import { DocumentMentionNode } from '@macro-inc/lexical-core';
+import { $getRoot, $nodesOfType, createEditor } from 'lexical';
+import { describe, expect, it } from 'vitest';
+import { message } from '../../email-message/tests/messages';
+import { decodeBase64Utf8 } from '../core/decode-base64';
+import { prepareEmailBodyFromHtml } from './prepare-email-body';
+
+const replyingTo = message('original', {
+ from: { name: 'Ada Lovelace', email: 'ada@example.com' },
+ to: [],
+ cc: [],
+ bcc: [],
+ subject: 'Numbers',
+ body_html_sanitized: null,
+ body_text: 'original message text',
+ internal_date_ts: '2026-08-01T12:00:00Z',
+ attachments: [],
+});
+
+describe('prepareEmailBodyFromHtml', () => {
+ it.each(['reply', 'forward'] as const)(
+ 'keeps quoted Macro document links re-importable as rich mentions in a %s',
+ (replyType) => {
+ const prepared = prepareEmailBodyFromHtml('
My reply
', {
+ replyType,
+ replyingTo: {
+ ...replyingTo,
+ body_html_sanitized:
+ '
Read Architecture
',
+ },
+ });
+ const dom = new DOMParser().parseFromString(
+ decodeBase64Utf8(prepared.bodyHtml),
+ 'text/html'
+ );
+ const editor = createEditor({
+ nodes: [DocumentMentionNode],
+ onError(error) {
+ throw error;
+ },
+ });
+ editor.update(
+ () => $getRoot().append(...$generateNodesFromDOM(editor, dom)),
+ { discrete: true }
+ );
+ editor.read(() => {
+ expect(
+ $nodesOfType(DocumentMentionNode).map((node) => node.exportJSON())
+ ).toMatchObject([
+ {
+ documentId: 'document-123',
+ documentName: 'Architecture',
+ blockName: 'md',
+ },
+ ]);
+ });
+ }
+ );
+ it.each(['reply', 'forward'] as const)(
+ 'preserves original theme rules and image-map links in an outgoing %s',
+ (replyType) => {
+ const prepared = prepareEmailBodyFromHtml('
My reply
', {
+ replyType,
+ replyingTo: {
+ ...replyingTo,
+ body_html_sanitized:
+ '
Original message
',
+ },
+ });
+ const decoded = decodeBase64Utf8(prepared.bodyHtml);
+ expect(decoded).toContain('prefers-color-scheme');
+ expect(decoded).toContain('var(--tone,black)');
+ expect(decoded).toContain('href="https://example.com/accept"');
+ }
+ );
+ it('does not add a quote block without appendReply (undo-send restore)', () => {
+ const prepared = prepareEmailBodyFromHtml('
hi there
');
+ const decoded = decodeBase64Utf8(prepared.bodyHtml);
+ expect(decoded).toContain('hi there');
+ expect(decoded).not.toContain('macro_quote');
+ });
+
+ it('appends the replied-to message when appendReply is provided', () => {
+ const prepared = prepareEmailBodyFromHtml('
hi there
', {
+ replyType: 'reply',
+ replyingTo,
+ });
+ const decoded = decodeBase64Utf8(prepared.bodyHtml);
+ const body = new DOMParser().parseFromString(decoded, 'text/html').body;
+ const quotes = body.querySelectorAll('.macro_quote');
+ expect(quotes).toHaveLength(1);
+ expect(quotes[0].textContent).toContain('original message text');
+ expect(quotes[0].textContent).toContain('wrote:');
+ });
+
+ it('does not double-append when the quote is already in the body', () => {
+ const prepared = prepareEmailBodyFromHtml(
+ '
hi there
already quoted
',
+ { replyType: 'reply', replyingTo }
+ );
+ const decoded = decodeBase64Utf8(prepared.bodyHtml);
+ const body = new DOMParser().parseFromString(decoded, 'text/html').body;
+ const quotes = body.querySelectorAll('.macro_quote');
+ expect(quotes).toHaveLength(1);
+ expect(quotes[0].textContent).toContain('already quoted');
+ });
+});
diff --git a/apps/web/src/features/block-email/util/prepareEmailBody.ts b/apps/web/src/features/email-compose/primitives/prepare-email-body.ts
similarity index 89%
rename from apps/web/src/features/block-email/util/prepareEmailBody.ts
rename to apps/web/src/features/email-compose/primitives/prepare-email-body.ts
index 9a7a122b8e2..104a90f04d4 100644
--- a/apps/web/src/features/block-email/util/prepareEmailBody.ts
+++ b/apps/web/src/features/email-compose/primitives/prepare-email-body.ts
@@ -1,9 +1,10 @@
+import type { EmailMessage } from '@app/features/email-message/core/email-message';
import { convertDocumentMentionsToLinks } from '@core/component/LexicalMarkdown/utils/convertDocumentMentionsToLinks';
-import { scrubActiveContent } from '@core/email';
import { formatEmailDate } from '@core/util/date';
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html';
import { $createQuoteNode } from '@lexical/rich-text';
import { $dfsIterator } from '@lexical/utils';
+import { sanitizeEmailHtml } from '@macro-inc/email-renderer';
import type { DocumentMentionInfo } from '@macro-inc/lexical-core';
import {
$createClassedBlockNode,
@@ -12,7 +13,6 @@ import {
$isClassedBlockNode,
type ClassedBlockNode,
} from '@macro-inc/lexical-core';
-import type { ApiMessage } from '@service-email/generated/schemas';
import {
$addUpdateTag,
$createLineBreakNode,
@@ -22,12 +22,14 @@ import {
$isLineBreakNode,
$setSelection,
COMMAND_PRIORITY_EDITOR,
- createCommand,
type LexicalEditor,
type LexicalNode,
} from 'lexical';
-import { flattenConsecutiveParagraphs } from './flattenConsecutiveParagraphs';
-import type { ReplyType } from './replyType';
+import type { ReplyType } from '../core/reply-type';
+import { TOGGLE_APPEND_EMAIL_THREAD_COMMAND } from './email-editor-commands';
+import { flattenConsecutiveParagraphs } from './flatten-consecutive-paragraphs';
+
+export { TOGGLE_APPEND_EMAIL_THREAD_COMMAND } from './email-editor-commands';
export function clearEmailBody(editor: LexicalEditor | undefined) {
if (!editor) return;
@@ -42,21 +44,12 @@ export function clearEmailBody(editor: LexicalEditor | undefined) {
);
}
-export const TOGGLE_APPEND_EMAIL_THREAD_COMMAND = createCommand<{
- replyingTo: ApiMessage | undefined;
- replyType?: ReplyType;
- visible: boolean;
- /** Whether the quoted message is personal (drives theme-adapted rendering
- * of the quoted html, matching the message view) */
- isPersonal?: boolean;
-}>('TOGGLE_APPEND_EMAIL_THREAD_COMMAND');
-
type HeaderDescriptor =
| { kind: 'forward'; lines: string[] }
| { kind: 'reply'; text: string };
function buildHeaderDescriptor(
- replyingTo: ApiMessage,
+ replyingTo: EmailMessage,
replyType: ReplyType | undefined
): HeaderDescriptor {
const replyingToDate = replyingTo.internal_date_ts ?? replyingTo.created_at;
@@ -112,7 +105,7 @@ function buildHeaderDescriptor(
}
function $generateHeaderNodes(
- replyingTo: ApiMessage,
+ replyingTo: EmailMessage,
replyType: ReplyType | undefined
): LexicalNode[] {
const descriptor = buildHeaderDescriptor(replyingTo, replyType);
@@ -152,7 +145,7 @@ const REPLYING_TO_ID_ATTRIBUTE = 'data-replying-to-id';
const $appendPreviousEmail = (
editor: LexicalEditor,
- replyingTo: ApiMessage | undefined,
+ replyingTo: EmailMessage | undefined,
replyType: ReplyType | undefined,
isPersonal?: boolean
) => {
@@ -179,10 +172,10 @@ const $appendPreviousEmail = (
quoteNode.append(textNode);
} else {
const parser = new DOMParser();
- const dom = parser.parseFromString(replyingToBodyHTML, 'text/html');
- // The quoted body ends up in the live document (adopted nodes, or a shadow
- // root inside the html-render node), so scrub it while it is still inert.
- scrubActiveContent(dom);
+ const dom = parser.parseFromString(
+ sanitizeEmailHtml(replyingToBodyHTML),
+ 'text/html'
+ );
// Forwards always embed the original as a non-editable HTML Render Node so
// the recipient gets the exact original markup. For replies, a table is a
// good indicator of content we can't convert into editable nodes correctly.
@@ -208,13 +201,13 @@ const $appendPreviousEmail = (
return true;
};
-function* $findPreviousEmailNode(replyingToID: string | undefined) {
- if (!replyingToID) yield;
+function* $findPreviousEmailNode(replyingToId: string | undefined) {
+ if (!replyingToId) yield;
for (const { node } of $dfsIterator()) {
if (!$isClassedBlockNode(node)) continue;
- const replyingToIDAttr = node.__attributes?.[REPLYING_TO_ID_ATTRIBUTE];
- if (!replyingToIDAttr || replyingToIDAttr !== replyingToID) {
+ const replyingToIdAttr = node.__attributes?.[REPLYING_TO_ID_ATTRIBUTE];
+ if (!replyingToIdAttr || replyingToIdAttr !== replyingToId) {
// In our case, quoted text replies do not exist more than once in the
// same message so returning any classed block node with the proper class
// should be valid. This is probably fine but we might not want to do this.
@@ -232,14 +225,14 @@ function* $findPreviousEmailNode(replyingToID: string | undefined) {
function removeAppendedThread(
editor: LexicalEditor,
- replyingToID: string | undefined
+ replyingToId: string | undefined
) {
- if (!replyingToID) return;
+ if (!replyingToId) return;
editor.update(
() => {
$addUpdateTag('skip-dom-selection');
- for (const node of $findPreviousEmailNode(replyingToID)) {
+ for (const node of $findPreviousEmailNode(replyingToId)) {
if (!node) continue;
node.remove();
@@ -256,13 +249,18 @@ export function registerToggleAppendedThread(editor: LexicalEditor) {
// Programmatic content change: don't let selection reconciliation
// move DOM focus into the editor
$addUpdateTag('skip-dom-selection');
- const replyingToID = replyingTo?.replying_to_id ?? undefined;
+ const replyingToId =
+ replyingTo?.db_id ?? replyingTo?.replying_to_id ?? undefined;
if (!visible) {
- removeAppendedThread(editor, replyingToID);
+ removeAppendedThread(editor, replyingToId);
return true;
}
+ // Restoring a draft or remounting a forward may request visibility again.
+ for (const node of $findPreviousEmailNode(replyingToId)) {
+ if (node) return true;
+ }
$appendPreviousEmail(editor, replyingTo, replyType, isPersonal);
// Appending leaves a dirty selection inside the quote; any later
// update would flush it to the DOM and steal focus into the editor
@@ -329,7 +327,7 @@ async function _appendItemsAsMacroMentions(
}
function getAppendedReplyElement(
- replyingTo: ApiMessage,
+ replyingTo: EmailMessage,
replyType: ReplyType | undefined
) {
const wrapper = document.createElement('div');
@@ -358,12 +356,9 @@ function getAppendedReplyElement(
quote.textContent = replyingTo.body_text ?? '';
} else {
const innerDom = new DOMParser().parseFromString(
- replyingToBodyHTML,
+ sanitizeEmailHtml(replyingToBodyHTML),
'text/html'
);
- // These nodes are adopted into the live document below, which starts image
- // loads — scrub before that, not after.
- scrubActiveContent(innerDom);
// Extract style tags from head to preserve email styling for weirdo emails with initial style tags.
const styleTags = innerDom.head?.querySelectorAll('style');
styleTags?.forEach((style) => {
@@ -397,7 +392,7 @@ export function prepareEmailBody(
// if this argument is provided, we append the message being replied to the html email body
appendReply?: {
replyType: ReplyType | undefined;
- replyingTo: ApiMessage;
+ replyingTo: EmailMessage;
}
): {
bodyHtml: string;
@@ -421,7 +416,7 @@ export function prepareEmailBodyFromHtml(
generatedHtml: string,
appendReply?: {
replyType: ReplyType | undefined;
- replyingTo: ApiMessage;
+ replyingTo: EmailMessage;
}
): {
bodyHtml: string;
diff --git a/apps/web/src/features/email-compose/primitives/reply-composer-focus.test.ts b/apps/web/src/features/email-compose/primitives/reply-composer-focus.test.ts
new file mode 100644
index 00000000000..a9746993050
--- /dev/null
+++ b/apps/web/src/features/email-compose/primitives/reply-composer-focus.test.ts
@@ -0,0 +1,91 @@
+import { createRoot } from 'solid-js';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createReplyComposerFocus } from './reply-composer-focus';
+
+const disposers: (() => void)[] = [];
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+ setTimeout(() => callback(0), 16)
+ );
+ vi.stubGlobal('cancelAnimationFrame', clearTimeout);
+});
+afterEach(() => {
+ disposers.splice(0).forEach((dispose) => dispose());
+ document.body.replaceChildren();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+function setup() {
+ const container = document.createElement('div');
+ const to = document.createElement('input');
+ const body = document.createElement('div');
+ const editorInput = document.createElement('input');
+ body.append(editorInput);
+ container.append(to, body);
+ document.body.append(container);
+ const editor = { focus: vi.fn(() => editorInput.focus()) };
+ const expandRecipients = vi.fn();
+ return createRoot((dispose) => {
+ disposers.push(dispose);
+ const focus = createReplyComposerFocus({
+ editor: () => editor,
+ container: () => container,
+ footer: () => undefined,
+ scrollContainer: () => body,
+ toInput: () => to,
+ expandRecipients,
+ });
+ return { dispose, focus, editor, to, editorInput, expandRecipients };
+ });
+}
+
+describe('reply focus lifetime', () => {
+ it('holds forward focus through editor reconciliation until deliberate interaction', () => {
+ const state = setup();
+ state.focus.forward();
+ expect(state.expandRecipients).toHaveBeenCalledOnce();
+ vi.advanceTimersByTime(100);
+ expect(document.activeElement).toBe(state.to);
+ state.editorInput.focus();
+ expect(document.activeElement).toBe(state.to);
+ document.dispatchEvent(new Event('pointerdown'));
+ state.editorInput.focus();
+ expect(document.activeElement).toBe(state.editorInput);
+ });
+
+ it('cancels queued focus and removes the forward focus guard on disposal', () => {
+ const state = setup();
+ const onFocused = vi.fn();
+ state.focus.forward();
+ vi.advanceTimersByTime(100);
+ expect(document.activeElement).toBe(state.to);
+ state.focus.forward();
+ state.focus.reply();
+ state.focus.editor(onFocused);
+ state.dispose();
+ state.editorInput.focus();
+ expect(document.activeElement).toBe(state.editorInput);
+ vi.runAllTimers();
+ expect(state.editor.focus).not.toHaveBeenCalled();
+ expect(onFocused).not.toHaveBeenCalled();
+ expect(document.activeElement).not.toBe(state.to);
+ });
+
+ it('disarms forward focus when keyboard navigation or outside focus takes over', () => {
+ const state = setup();
+ state.focus.forward();
+ vi.advanceTimersByTime(100);
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
+ state.editorInput.focus();
+ expect(document.activeElement).toBe(state.editorInput);
+ state.focus.forward();
+ vi.advanceTimersByTime(100);
+ const outside = document.createElement('input');
+ document.body.append(outside);
+ outside.focus();
+ state.editorInput.focus();
+ expect(document.activeElement).toBe(state.editorInput);
+ });
+});
diff --git a/apps/web/src/features/email-compose/primitives/reply-composer-focus.ts b/apps/web/src/features/email-compose/primitives/reply-composer-focus.ts
new file mode 100644
index 00000000000..fddc1e9cffa
--- /dev/null
+++ b/apps/web/src/features/email-compose/primitives/reply-composer-focus.ts
@@ -0,0 +1,86 @@
+import { makeEventListener } from '@solid-primitives/event-listener';
+import { type Accessor, onCleanup, onMount } from 'solid-js';
+
+/** Keeps forward recipients focused through Lexical's deferred selection work. */
+export function createReplyComposerFocus(options: {
+ editor: Accessor<{ focus(): void } | undefined>;
+ container: Accessor
;
+ footer: Accessor;
+ scrollContainer: Accessor;
+ toInput: Accessor;
+ expandRecipients: () => void;
+}) {
+ let bounceEditorFocusGrabs = false;
+ const timers = new Set>();
+ const frames = new Set();
+ onCleanup(() => {
+ for (const timer of timers) clearTimeout(timer);
+ for (const frame of frames) cancelAnimationFrame(frame);
+ });
+
+ function afterQuoteLayout(callback: () => void) {
+ const timer = setTimeout(() => {
+ timers.delete(timer);
+ callback();
+ }, 100);
+ timers.add(timer);
+ }
+
+ onMount(() => {
+ const disarm = () => {
+ bounceEditorFocusGrabs = false;
+ };
+ makeEventListener(document, 'pointerdown', disarm, true);
+ makeEventListener(
+ document,
+ 'keydown',
+ (event) => {
+ if (event.key === 'Tab' || event.key === 'Escape') disarm();
+ },
+ true
+ );
+ makeEventListener(
+ document,
+ 'focusin',
+ (event) => {
+ if (!bounceEditorFocusGrabs) return;
+ const target = event.target as Node;
+ if (!options.container()?.contains(target)) {
+ disarm();
+ } else if (options.scrollContainer()?.contains(target)) {
+ options.toInput()?.focus();
+ }
+ },
+ true
+ );
+ });
+
+ return {
+ forward() {
+ options.expandRecipients();
+ afterQuoteLayout(() => {
+ if (options.toInput()) {
+ bounceEditorFocusGrabs = true;
+ options.toInput()?.focus();
+ }
+ options.footer()?.scrollIntoView({ block: 'nearest' });
+ });
+ },
+ reply() {
+ afterQuoteLayout(() => {
+ options.editor()?.focus();
+ options.footer()?.scrollIntoView({ block: 'nearest' });
+ });
+ },
+ editor(onFocused: () => void) {
+ const editor = options.editor();
+ if (!editor) return;
+ const frame = requestAnimationFrame(() => {
+ frames.delete(frame);
+ editor.focus();
+ onFocused();
+ });
+ frames.add(frame);
+ },
+ };
+}
diff --git a/apps/web/src/features/email-compose/primitives/reply-composer.ts b/apps/web/src/features/email-compose/primitives/reply-composer.ts
new file mode 100644
index 00000000000..b467114eb0f
--- /dev/null
+++ b/apps/web/src/features/email-compose/primitives/reply-composer.ts
@@ -0,0 +1,979 @@
+import {
+ MACRO_EMAIL_SIGNATURE,
+ MAX_ATTACHMENTS_BYTES_SIZE,
+} from '@app/features/email-compose/core/constants';
+import type { EmailMessage } from '@app/features/email-message/core/email-message';
+import type { UserMentionRecord } from '@core/component/LexicalMarkdown/utils/mentionsUtils';
+import { setEditorStateFromHtml } from '@core/component/LexicalMarkdown/utils/setEditorStateFromHtml';
+import { plural } from '@core/util/string';
+import { $generateHtmlFromNodes } from '@lexical/html';
+import {
+ $appendWatermarkNodeToLast,
+ $removeAllWatermarkNodes,
+} from '@macro-inc/lexical-core';
+import type { LexicalEditor } from 'lexical';
+import { $addUpdateTag, $getRoot } from 'lexical';
+import {
+ type Accessor,
+ createEffect,
+ createMemo,
+ createSignal,
+ on,
+ onCleanup,
+ onMount,
+ type Setter,
+ untrack,
+} from 'solid-js';
+import type {
+ EmailAttachmentStorage,
+ EmailComposeAccounts,
+ EmailComposeFeedback,
+ EmailDelivery,
+ EmailDraftStorage,
+ EmailUndoHandle,
+} from '../context/compose-capabilities';
+import type { EmailReplySession } from '../context/email-form-inputs';
+import type { EmailDraft } from '../core/email-draft';
+import {
+ convertContactInfoToEmailRecipient,
+ convertEmailRecipientToContactInfo,
+} from '../core/recipient-conversion';
+import { createAttachmentPersistence } from './attachment-persistence';
+import { createDraftAutosave } from './draft-autosave';
+import type { DraftFormAttachment } from './email-form-state';
+import type { EmailFormContextValue, FormAccessKey } from './email-form-types';
+import { createEmailSendSchedule } from './email-send-schedule';
+import { addUserMentionToCc } from './mention-to-cc';
+import {
+ clearEmailBody,
+ hasDraftContent,
+ prepareEmailBody,
+ prepareMacroBody,
+ TOGGLE_APPEND_EMAIL_THREAD_COMMAND,
+} from './prepare-email-body';
+import { createReplyComposerFocus } from './reply-composer-focus';
+import { createReplyRecipientFields } from './reply-recipient-fields';
+import { endUndoSend } from './undo-send-claim';
+import { createEmailUndoStore } from './undo-store';
+
+type UndoReplySnapshot = {
+ threadId: string;
+ inboxId: string | undefined;
+ draftId: string;
+ bodyHtml: string;
+ attachments: DraftFormAttachment[];
+ includeSignature: boolean;
+ /** Whether the quoted thread was appended in the editor at send time.
+ * Restored so the quoted-text toggle matches the restored body — otherwise
+ * it reads as "not appended" and appends a duplicate quote block. */
+ replyAppended: boolean;
+ /** Draft payload for restoring the server-side draft on undo. The
+ * unscheduled message keeps the sent body (appended reply chain, injected
+ * signature), so undo re-saves the draft with the pre-send content —
+ * bodyHtml above, prepared at undo time, fills body_html. */
+ draftRestore: EmailDraft;
+};
+const replyUndo = createEmailUndoStore();
+
+export type ReplyComposerOptions = {
+ drafts: EmailDraftStorage;
+ attachmentStorage: EmailAttachmentStorage;
+ delivery: EmailDelivery;
+ notices: EmailComposeFeedback;
+ accounts: EmailComposeAccounts;
+ viewerEmail: Accessor;
+ hasPaidAccess: Accessor;
+ recordMention(sourceId: string, targetId: string): void;
+ focusAfterReplyRequest: Accessor;
+ session: EmailReplySession;
+ sourceEntityId: string;
+ replyingTo: Accessor;
+ isEditingExisting?: boolean;
+ draft?: EmailMessage;
+ preloadedHtml?: string;
+ /** Seed identity of the draft this composer mounted from — becomes part of
+ * the form-state cache key so a remount on a newer draft version gets a
+ * freshly seeded form. See ThreadReplyInput's seed key. */
+ formSeed?: string;
+ /** Reports the composer gaining local state worth keeping — a first edit
+ * or save (every modification funnels through scheduleDraftSave) or an
+ * undo-send restore. The parent latches the seed key on it so the input
+ * stops remounting on later draft versions. */
+ onEngaged?: () => void;
+ sideEffectOnSend?: (newMessageId: string | null) => void | Promise;
+ onMarkDone?: (opts?: {
+ silent?: boolean;
+ onUndoHandle?: (handle: EmailUndoHandle) => void;
+ nextEntityId?: string;
+ }) => void;
+ setShowReply?: Setter;
+};
+
+export function createReplyComposer(
+ props: ReplyComposerOptions,
+ editor: Accessor,
+ dom: {
+ container: Accessor;
+ footer: Accessor;
+ },
+ forms: (key?: FormAccessKey) => EmailFormContextValue
+) {
+ const ctx = props.session;
+ // Each keyed composer owns a target and draft version. Parent props can already
+ // point at the next target when Solid disposes this editor and flushes its save.
+ const replyTarget = props.replyingTo();
+ const draftSeed = props.draft;
+ const initialThread = ctx.thread();
+ const thread = () => {
+ const current = ctx.thread();
+ return current?.db_id === initialThread?.db_id ? current : initialThread;
+ };
+ const form = forms(
+ replyTarget?.db_id
+ ? {
+ type: 'replying_to',
+ messageId: replyTarget.db_id,
+ seed: props.formSeed,
+ }
+ : draftSeed?.db_id
+ ? {
+ type: 'draft',
+ messageId: draftSeed.db_id,
+ seed: props.formSeed,
+ }
+ : undefined
+ );
+ const sourceEntityId = props.sourceEntityId;
+ const undoKey = `${sourceEntityId}:${replyTarget?.db_id ?? draftSeed?.replying_to_id ?? draftSeed?.db_id ?? 'new'}`;
+ const userEmail = props.viewerEmail;
+
+ const primaryInboxId = props.accounts.primaryId;
+ // Capture this domain inbox ID for each asynchronous operation.
+ const activeInboxId = () =>
+ form.selectedInboxId() ??
+ thread()?.link_id ??
+ draftSeed?.link_id ??
+ primaryInboxId() ??
+ props.accounts.inboxes()[0]?.id;
+ // The address of the inbox this input sends from, for the "from" display.
+ const activeInboxEmail = () =>
+ props.accounts.inboxes().find((l) => l.id === activeInboxId())
+ ?.email_address ?? userEmail();
+
+ // The full Link object for the sending inbox (for its saved signature and the
+ // "add to replies & forwards" preference).
+ const sendingInbox = createMemo(() =>
+ props.accounts.inboxes().find((l) => l.id === activeInboxId())
+ );
+ const signature = () => sendingInbox()?.settings.signature ?? undefined;
+ // Whether this reply includes the signature. Defaults on, reset per reply,
+ // and dismissable via the preview ✕.
+ const [includeSignature, setIncludeSignature] = createSignal(true);
+ // Signature HTML for the preview (and whether to show it): only for
+ // replies/forwards, when the inbox's "add to replies & forwards" setting is on
+ // and the user hasn't dismissed it. The backend does the actual injection on
+ // send — this just mirrors when that will happen.
+ const replySignatureHtml = (): string | undefined =>
+ replyTarget &&
+ includeSignature() &&
+ sendingInbox()?.settings.signature_on_replies_forwards
+ ? signature()
+ : undefined;
+
+ const [bodyMacro, setBodyMacro] = createSignal('');
+ const [scrollContainer, setScrollContainer] = createSignal();
+ // Gmail-style sizing: the composer opens compact and grows to the full cap
+ // once the user scrolls the content
+ const [composerExpanded, setComposerExpanded] = createSignal(false);
+ // Appended quoted thread starts hidden behind a "⋯" pill (desktop). A
+ // draft reloaded with the quote already appended opens expanded instead —
+ // that's how the composer looked when the draft was saved.
+ const [quoteCollapsed, setQuoteCollapsed] = createSignal(
+ !form.replyAppended()
+ );
+ const recipients = createReplyRecipientFields({
+ values: form.recipients,
+ setValues: form.setRecipients,
+ onChange: scheduleDraftSave,
+ container: dom.container,
+ disabled: () => submitting() || pendingDeletion() || scheduling(),
+ });
+ const focus = createReplyComposerFocus({
+ editor,
+ container: dom.container,
+ footer: dom.footer,
+ scrollContainer,
+ toInput: recipients.toRef,
+ expandRecipients: () => recipients.setShowExpandedRecipients(true),
+ });
+ // A pending undo-send restore that belongs to this thread (inline reply
+ // remount case). It carries a just-undone send. Consumed below.
+ const restoredSnapshot = replyUndo.takePending(undoKey);
+
+ // Switching inboxes can move a draft out of the displayed thread. Keep its
+ // persisted identity together for subsequent saves, discard, schedule and undo.
+ const [savedDraft, setSavedDraft] = createSignal<
+ | {
+ id: string;
+ threadId: string | undefined;
+ }
+ | undefined
+ >(
+ restoredSnapshot
+ ? { id: restoredSnapshot.draftId, threadId: restoredSnapshot.threadId }
+ : draftSeed?.db_id
+ ? { id: draftSeed.db_id, threadId: draftSeed.thread_db_id }
+ : undefined
+ );
+ const savedDraftId = () => savedDraft()?.id;
+ const savedDraftThreadId = () => savedDraft()?.threadId;
+
+ // Consume the undo-send snapshot so a later composer mount doesn't restore
+ // it again. Use bodyHtml as initialHtml for the editor, restore attachments
+ // on mount.
+ const restoreEnvelope = (snapshot: UndoReplySnapshot) => {
+ form.setSelectedInbox(snapshot.inboxId);
+ form.setSubject(snapshot.draftRestore.subject);
+ for (const field of ['to', 'cc', 'bcc'] as const) {
+ form.setRecipients(
+ field,
+ (snapshot.draftRestore[field] ?? []).map(
+ convertContactInfoToEmailRecipient
+ )
+ );
+ }
+ };
+ if (restoredSnapshot) {
+ restoreEnvelope(restoredSnapshot);
+ onMount(() => {
+ // Restored content is local state worth keeping — latch the seed.
+ props.onEngaged?.();
+ for (const attachment of restoredSnapshot.attachments) {
+ form.attachments.add(attachment);
+ }
+ setIncludeSignature(restoredSnapshot.includeSignature);
+ form.setReplyAppended(restoredSnapshot.replyAppended);
+ // Reopen with the quote visible, as it was when the send was undone.
+ if (restoredSnapshot.replyAppended) setQuoteCollapsed(false);
+ });
+ }
+
+ // Register a callback so stale undoSend closures from a previous mount can
+ // restore state into this (the live) component instance.
+ const restoreMountedReply = (snapshot: UndoReplySnapshot) => {
+ const draftId = snapshot.draftId;
+ props.onEngaged?.();
+ setSavedDraft({ id: draftId, threadId: snapshot.threadId });
+ restoreEnvelope(snapshot);
+ const currentEditor = editor();
+ if (currentEditor && snapshot.bodyHtml) {
+ setEditorStateFromHtml(currentEditor, snapshot.bodyHtml);
+ }
+ for (const attachment of snapshot.attachments) {
+ form.attachments.add(attachment);
+ }
+ setIncludeSignature(snapshot.includeSignature);
+ form.setReplyAppended(snapshot.replyAppended);
+ // Reopen with the quote visible, as it was when the send was undone.
+ if (snapshot.replyAppended) setQuoteCollapsed(false);
+ };
+ let mounted = true;
+ const unregisterUndo = replyUndo.register(undoKey, restoreMountedReply);
+ onCleanup(() => {
+ mounted = false;
+ unregisterUndo();
+ });
+
+ const initialHtml = () => restoredSnapshot?.bodyHtml ?? props.preloadedHtml;
+ const [editorConnected, setEditorConnected] = createSignal(false);
+ const handleEditorConnect = () => {
+ const currentEditor = editor();
+ if (!currentEditor) return;
+ const html = initialHtml();
+ if (html) {
+ // Restore content without letting selection reconciliation grab focus
+ currentEditor.update(() => {
+ $addUpdateTag('skip-dom-selection');
+ setEditorStateFromHtml(currentEditor, html, true);
+ });
+ }
+ setEditorConnected(true);
+ };
+
+ // Everything that follows a successful unschedule: consume the send
+ // snapshot, scrub the sent message from the thread cache, restore the
+ // server-side draft and the composer.
+ const restoreAfterUndoSend = async (
+ draftId: string,
+ sentThreadId: string | undefined,
+ inboxId: string | undefined
+ ) => {
+ const snapshot = replyUndo.take(draftId);
+
+ // Reconcile the actual message thread, which can differ from the host when
+ // replying from another inbox. The host's undoKey still owns local recovery.
+ const threadId = sentThreadId ?? snapshot?.threadId;
+ await props.drafts.restoreDraft({
+ draftId,
+ threadId,
+ draft: snapshot?.draftRestore,
+ html: snapshot?.bodyHtml,
+ inboxId,
+ });
+
+ if (snapshot) {
+ // Resolve the live registration after cache updates and unmounts settle.
+ setTimeout(() => replyUndo.restore(undoKey, snapshot), 0);
+ props.setShowReply?.(true);
+ }
+ };
+
+ const attachmentPersistence = createAttachmentPersistence({
+ services: props.attachmentStorage,
+ attachments: form.attachments,
+ draftId: savedDraftId,
+ inboxId: activeInboxId,
+ });
+
+ createEffect(
+ on(form.editRevision, () => scheduleDraftSave(), { defer: true })
+ );
+
+ // The mounted composer owns focus, editor commands, and their cleanup.
+ createEffect(() => {
+ const rt = form.replyType();
+ if (!editorConnected()) return;
+ untrack(() => {
+ setComposerExpanded(false);
+ if (rt === 'forward') {
+ setQuoteCollapsed(true);
+ 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 {
+ focus.reply();
+ }
+ });
+ });
+
+ const [sendPhase, setSendPhase] = createSignal<
+ 'idle' | 'preparing' | 'sending'
+ >('idle');
+ const submitting = () => sendPhase() !== 'idle';
+ const sending = () => sendPhase() === 'sending';
+ const [pendingDeletion, setPendingDeletion] = createSignal(false);
+
+ function collectDraft() {
+ $removeAllWatermarkNodes(editor());
+ const prepared = prepareEmailBody(editor());
+ if (!prepared) {
+ props.notices.reportError(
+ new Error('Unable to prepare email body for draft collection.')
+ );
+ return null;
+ }
+ if (
+ !hasDraftContent(
+ prepared.bodyText,
+ form.subject(),
+ form.attachments.list().length
+ )
+ ) {
+ return null;
+ }
+ // We attach the drafts entirely using bodyHTML (because this is how the appended reply parsing works) so we are not including bodyMacro or bodyText
+ return {
+ bcc: form.recipients().bcc.map(convertEmailRecipientToContactInfo),
+ body_html: prepared.bodyHtml,
+ cc: form.recipients().cc.map(convertEmailRecipientToContactInfo),
+ provider_id: draftSeed?.provider_id,
+ replying_to_id: replyTarget?.db_id,
+ subject: form.subject(),
+ to: form.recipients().to.map(convertEmailRecipientToContactInfo),
+ };
+ }
+
+ const captureSave = (completingThread = false) => ({
+ draft: collectDraft(),
+ thread: thread(),
+ inboxId: activeInboxId(),
+ completingThread,
+ });
+ async function persistDraft({
+ draft: draftToSave,
+ thread: currentThread,
+ inboxId,
+ completingThread,
+ }: ReturnType) {
+ if (!draftToSave) {
+ const draftId = savedDraftId();
+ if (draftId) {
+ await props.drafts.deleteDraft({
+ draftId,
+ threadId: savedDraftThreadId(),
+ inboxId,
+ completingThread,
+ });
+ }
+ setSavedDraft(undefined);
+ return;
+ }
+ if (!currentThread) {
+ props.notices.reportError(
+ new Error('Failed to save draft: thread not found')
+ );
+ return;
+ }
+
+ const draftResponse = await props.drafts.saveDraft({
+ draft: {
+ ...draftToSave,
+ db_id: savedDraftId(),
+ provider_thread_id: currentThread.provider_id,
+ thread_db_id: currentThread.db_id,
+ },
+ inboxId,
+ completingThread,
+ previousThreadId: savedDraftThreadId(),
+ });
+
+ const draftId = draftResponse.draftId;
+ if (draftId) {
+ setSavedDraft({
+ id: draftId,
+ threadId: draftResponse.threadId ?? undefined,
+ });
+ await attachmentPersistence.upload(draftId, { inboxId });
+
+ const forwarded = form.attachments
+ .list()
+ .filter((attachment) => attachment.type === 'forwarded');
+ if (forwarded.length) {
+ await props.attachmentStorage.addForwardedAttachments({
+ draftId: draftId,
+ attachments: forwarded.map((a) => ({
+ attachmentId: a.attachmentId,
+ })),
+ inboxId,
+ });
+ }
+
+ return draftId;
+ }
+ }
+
+ const autosave = createDraftAutosave({
+ capture: captureSave,
+ persist: persistDraft,
+ paused: () => submitting() || pendingDeletion(),
+ });
+ function executeSaveDraft(completingThread = false) {
+ return autosave.save(captureSave(completingThread));
+ }
+ function scheduleDraftSave() {
+ if (submitting() || pendingDeletion()) return;
+ props.onEngaged?.();
+ autosave.schedule();
+ }
+
+ // Persist the draft immediately when the user switches the sending inbox, even
+ // without a text edit, so it moves to the new inbox and the choice survives a
+ // refresh. Driven by the explicit switch (below) rather than inbox reactivity.
+ const persistDraftOnSenderSwitch = (inboxId: string) => {
+ if (submitting() || pendingDeletion() || scheduling()) return;
+ props.onEngaged?.();
+ form.setSelectedInbox(inboxId);
+ autosave.cancel();
+ void executeSaveDraft().catch(() => {});
+ };
+
+ createEffect(() => {
+ const requestReplyType = ctx.replyRequest.replyType();
+
+ if (!requestReplyType) return;
+
+ if (form.replyType() !== requestReplyType) {
+ form.setReplyType(requestReplyType);
+ } else if (requestReplyType === 'forward') {
+ // setReplyType is skipped when the type is unchanged, so land the
+ // cursor in the To field explicitly
+ focus.forward();
+ }
+ // Forwards focus the To field; focusing the editor would steal it back
+ if (requestReplyType !== 'forward') {
+ if (props.focusAfterReplyRequest()) focus.editor(() => {});
+ }
+ ctx.replyRequest.clear();
+ });
+
+ // We are consuming the first change, because it is the initial value
+ let firstChangeConsumed = false;
+ const handleChange = (value: string) => {
+ setBodyMacro(value);
+ if (!firstChangeConsumed) {
+ firstChangeConsumed = true;
+ return;
+ }
+ untrack(scheduleDraftSave);
+ };
+
+ const hasPaidAccess = props.hasPaidAccess;
+
+ const sendEmail = async (markDone = false) => {
+ if (scheduling()) return;
+ if (submitting() || pendingDeletion() || attachmentPersistence.uploading())
+ return;
+
+ const to = form.recipients().to.map(convertEmailRecipientToContactInfo);
+ const cc = form.recipients().cc.map(convertEmailRecipientToContactInfo);
+ const bcc = form.recipients().bcc.map(convertEmailRecipientToContactInfo);
+
+ if ((to?.length ?? 0) + (cc?.length ?? 0) + (bcc?.length ?? 0) === 0) {
+ props.notices.feedback.failure(
+ 'Email failed to send. No recipients provided'
+ );
+ return;
+ }
+
+ const currentThread = thread();
+ if (!currentThread) {
+ props.notices.reportError(
+ new Error("Can't send email, no email thread found")
+ );
+ props.notices.feedback.failure('Email failed to send');
+ return;
+ }
+
+ let inboxId = activeInboxId();
+ if (!inboxId) {
+ if (props.accounts.loading()) {
+ props.notices.feedback.alert('Loading email accounts...');
+ return;
+ }
+
+ if (props.accounts.failed()) {
+ props.notices.feedback.failure(
+ 'Email failed to send: Could not load email accounts'
+ );
+ props.notices.reportError('Failed to load email links');
+ return;
+ }
+
+ const inboxes = props.accounts.inboxes();
+ if (inboxes.length < 1) {
+ props.notices.feedback.failure(
+ 'Email failed to send: No email account connected'
+ );
+ props.notices.reportError('No links found');
+ return;
+ }
+ inboxId = primaryInboxId() ?? inboxes[0].id;
+ }
+
+ const currentEditor = editor();
+
+ // Sending a reply marks the thread done. Gated on inbox_visible because
+ // onMarkDone (archiveThread) toggles: an already-archived thread (e.g.
+ // replying from search or the sent view) would be unarchived.
+ const willMarkDone = markDone || currentThread.inbox_visible;
+ const nextEntityId = willMarkDone
+ ? ctx.getMarkDoneNavigationTargetId()
+ : undefined;
+
+ setSendPhase('preparing');
+ try {
+ // Ensure draft is saved before sending so undo-send always has a draft to restore
+ autosave.cancel();
+ await executeSaveDraft(willMarkDone);
+
+ // Snapshot editor state before watermark so undo-send can restore it.
+ // Remember by draft so sends in separate composers cannot replace each other.
+ if (currentEditor) {
+ const snapshotHtml = currentEditor.read(() =>
+ $generateHtmlFromNodes(currentEditor)
+ );
+ const snapshotDraftId = savedDraftId();
+ const snapshotThreadId = savedDraftThreadId();
+ if (snapshotDraftId && snapshotThreadId) {
+ replyUndo.remember({
+ threadId: snapshotThreadId,
+ inboxId,
+ draftId: snapshotDraftId,
+ bodyHtml: snapshotHtml,
+ attachments: [...form.attachments.list()],
+ includeSignature: includeSignature(),
+ replyAppended: form.replyAppended(),
+ draftRestore: {
+ bcc,
+ cc,
+ db_id: snapshotDraftId,
+ provider_id: draftSeed?.provider_id,
+ provider_thread_id: currentThread.provider_id,
+ replying_to_id: replyTarget?.db_id,
+ subject: form.subject(),
+ thread_db_id: snapshotThreadId,
+ to,
+ },
+ });
+ }
+ }
+
+ // Scheduling may have started while the draft save was pending.
+ if (scheduling() || form.sendTime()) {
+ return;
+ }
+
+ // Append watermark after all validation passes so failed sends don't
+ // leave orphaned watermark nodes in the editor tree.
+ const cleanupWatermark = $appendWatermarkNodeToLast(
+ currentEditor,
+ !hasPaidAccess() ? MACRO_EMAIL_SIGNATURE : undefined
+ );
+
+ const replyingTo = replyTarget;
+
+ const prepared = prepareEmailBody(
+ currentEditor,
+ replyingTo
+ ? {
+ replyType: form.replyType(),
+ replyingTo,
+ }
+ : undefined
+ );
+ if (!prepared) {
+ cleanupWatermark();
+ return;
+ }
+
+ const processedMacroBody = prepareMacroBody(bodyMacro());
+
+ const currentDraftId = savedDraftId();
+
+ setSendPhase('sending');
+ const pendingSend = props.delivery.sendMessage({
+ message: {
+ db_id: currentDraftId,
+ bcc,
+ body_html: prepared.bodyHtml,
+ body_macro: processedMacroBody,
+ body_text: prepared.bodyText,
+ cc,
+ provider_id: draftSeed?.provider_id,
+ provider_thread_id: currentThread.provider_id,
+ replying_to_id: replyTarget?.db_id,
+ subject: form.subject(),
+ thread_db_id: currentThread.db_id,
+ to,
+ // Replies/forwards follow the inbox's "add to replies & forwards"
+ // setting on the backend; only signal an explicit per-reply dismiss.
+ include_signature: includeSignature() ? undefined : false,
+ },
+ inboxId,
+ completingThread: willMarkDone,
+ });
+
+ // Reset immediately while this task owns completion, including after unmount.
+ try {
+ resetState();
+ clearDraftState();
+ } catch (error) {
+ props.notices.reportError(error);
+ } finally {
+ cleanupWatermark();
+ }
+ let result;
+ try {
+ result = await pendingSend;
+ } catch (error) {
+ autosave.cancel();
+ if (mounted && currentDraftId) {
+ const snapshot = replyUndo.peek(currentDraftId);
+ if (snapshot) restoreMountedReply(snapshot);
+ }
+ props.notices.reportError(error);
+ props.notices.feedback.failure('Failed to send email');
+ return;
+ }
+ autosave.cancel();
+ const draftId = result.draftId;
+ if (draftId) endUndoSend(draftId);
+ // 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;
+ const undoSend = (draftId: string) =>
+ props.delivery.undoSend({
+ threadId: result.threadId,
+ draftId,
+ inboxId,
+ onUndone: async () => {
+ await restoreAfterUndoSend(draftId, result.threadId, inboxId);
+ const doneHandle = markDoneUndoHandle;
+ markDoneUndoHandle = undefined;
+ await doneHandle?.undo({
+ onError: () =>
+ props.notices.feedback.failure(
+ 'Failed to restore thread to inbox'
+ ),
+ });
+ },
+ });
+ try {
+ const toastId = props.notices.feedback.success('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);
+ }
+ for (const mention of prepared.mentions) {
+ try {
+ props.recordMention(sourceEntityId, mention.documentId);
+ } catch (error) {
+ props.notices.reportError(error);
+ }
+ }
+ if (willMarkDone) {
+ try {
+ props.onMarkDone?.({
+ silent: true,
+ onUndoHandle: (handle) => {
+ markDoneUndoHandle = handle;
+ },
+ nextEntityId,
+ });
+ } catch (error) {
+ props.notices.reportError(error);
+ props.notices.feedback.failure(
+ 'Email sent, but unable to mark thread done'
+ );
+ }
+ }
+ try {
+ // Presentation refresh must not keep a successfully sent or undone reply disabled.
+ void Promise.resolve(props.sideEffectOnSend?.(draftId ?? null)).catch(
+ props.notices.reportError
+ );
+ } catch (error) {
+ props.notices.reportError(error);
+ }
+ } catch (error) {
+ props.notices.reportError(error);
+ } finally {
+ setSendPhase('idle');
+ }
+ };
+
+ const resetState = () => {
+ clearEmailBody(editor());
+ setBodyMacro('');
+ setSavedDraft(undefined);
+ form.reset();
+ };
+
+ const clearDraftState = () => {
+ ctx.onDraftRemoved();
+ props.setShowReply?.(false);
+ };
+
+ const deleteDraftAndReset = async () => {
+ if (submitting() || pendingDeletion() || scheduling()) return;
+ // Keep Lexical's deferred reset notification from recreating a discarded draft.
+ setPendingDeletion(true);
+ autosave.cancel();
+ try {
+ await autosave.settled().catch(() => {});
+ const draftId = savedDraftId();
+ if (draftId) {
+ await props.drafts.deleteDraft({
+ draftId,
+ threadId: savedDraftThreadId(),
+ inboxId: activeInboxId(),
+ });
+ }
+ resetState();
+ form.setReplyAppended(false);
+ clearDraftState();
+ } finally {
+ // Yield past any sync/microtask save scheduling triggered by resetState,
+ // then cancel the resulting timer and re-enable autosave. Runs on both
+ // success and error paths so a failed delete doesn't leave the user
+ // unable to save further edits.
+ setTimeout(() => {
+ autosave.cancel();
+ setPendingDeletion(false);
+ }, 0);
+ }
+ };
+
+ const handleUserMention = (mention: UserMentionRecord) => {
+ if (recipients.disabled()) return;
+ addUserMentionToCc({
+ mention,
+ recipientOptions: ctx.recipientOptions(),
+ toRecipients: form.recipients().to,
+ ccRecipients: form.recipients().cc,
+ bccRecipients: form.recipients().bcc,
+ setCc: (next) => form.setRecipients('cc', next),
+ onRecipientAdded: (email) => {
+ props.notices.feedback.success(`${email} added to CC`);
+ },
+ });
+ };
+
+ const handleAddAttachments = (files: File[]) => {
+ const currentAttachments = form.attachments.list();
+
+ const attachmentsToAddByteSize = files.reduce((sum, f) => sum + f.size, 0);
+
+ if (attachmentsToAddByteSize >= MAX_ATTACHMENTS_BYTES_SIZE) {
+ props.notices.feedback.failure(
+ `${plural('Attachment', files.length)} exceed 18MB`
+ );
+ return;
+ }
+
+ const currentAttachmentsByteSize = currentAttachments.reduce(
+ (sum, a) => sum + (a.type === 'local' ? a.file.size : a.fileSize),
+ 0
+ );
+
+ if (
+ currentAttachmentsByteSize + attachmentsToAddByteSize >=
+ MAX_ATTACHMENTS_BYTES_SIZE
+ ) {
+ props.notices.feedback.failure("Can't add more attachments", {
+ subtext: 'Total attachments exceed 18MB limit',
+ });
+ return;
+ }
+
+ for (const file of files) {
+ form.attachments.add({
+ type: 'local',
+ file,
+ });
+ }
+
+ scheduleDraftSave();
+ };
+
+ const handleRemoveAttachment = attachmentPersistence.remove;
+
+ const schedule = createEmailSendSchedule({
+ delivery: props.delivery,
+ notices: props.notices,
+ draftId: savedDraftId,
+ saveDraft: executeSaveDraft,
+ threadId: savedDraftThreadId,
+ inboxId: activeInboxId,
+ sendTime: form.sendTime,
+ setSendTime: form.setSendTime,
+ recipientCount: () => {
+ const recipients = form.recipients();
+ return (
+ recipients.to.length + recipients.cc.length + recipients.bcc.length
+ );
+ },
+ });
+ const scheduling = schedule.pending;
+ const scheduleBlocked = () => pendingDeletion() || sending();
+ const handleSendTimeChange = (date: Date | null) =>
+ scheduleBlocked() ? Promise.resolve() : schedule.change(date);
+
+ const hasBodyText = () => bodyMacro().trim().length > 0;
+ const sendActionDisabled = () =>
+ pendingDeletion() ||
+ submitting() ||
+ scheduling() ||
+ attachmentPersistence.uploading() ||
+ !!form.sendTime();
+ const scheduleSendDisabled = () =>
+ scheduleBlocked() ||
+ scheduling() ||
+ (form.recipients().to.length === 0 &&
+ form.recipients().cc.length === 0 &&
+ form.recipients().bcc.length === 0);
+ const toggleQuotedText = () => {
+ const replyingTo = replyTarget;
+ if (!replyingTo) return;
+
+ const currentlyAppended = form.replyAppended();
+ form.setReplyAppended(!currentlyAppended);
+ // Explicitly showing quoted text via the toolbar reveals it uncollapsed
+ if (!currentlyAppended) setQuoteCollapsed(false);
+
+ editor()?.dispatchCommand(TOGGLE_APPEND_EMAIL_THREAD_COMMAND, {
+ replyingTo,
+ replyType: form.replyType(),
+ visible: !currentlyAppended,
+ isPersonal: ctx.isPersonalReply(),
+ });
+
+ editor()?.update(() => {
+ $getRoot().getFirstChild()?.selectStart();
+ });
+ };
+
+ return {
+ onContentChange: handleChange,
+ handleUserMention,
+ scrollContainer,
+ form,
+ activeInboxId,
+ activeInboxEmail,
+ replyType: form.replyType,
+ signatureHtml: replySignatureHtml,
+ setIncludeSignature,
+ setScrollContainer,
+ composerExpanded,
+ setComposerExpanded,
+ quoteCollapsed,
+ setQuoteCollapsed,
+ savedDraftId,
+ handleEditorConnect,
+ isSending: submitting,
+ recipients,
+ collectDraft,
+ scheduleDraftSave,
+ persistDraftOnSenderSwitch,
+ hasPaidAccess,
+ sendEmail,
+ deleteDraftAndReset,
+ handleAddAttachments,
+ handleRemoveAttachment,
+ handleSendTimeChange,
+ hasBodyText,
+ sendActionDisabled,
+ scheduleSendDisabled,
+ toggleQuotedText,
+ };
+}
diff --git a/apps/web/src/features/email-compose/primitives/reply-recipient-fields.test.ts b/apps/web/src/features/email-compose/primitives/reply-recipient-fields.test.ts
new file mode 100644
index 00000000000..14e81bfc5b2
--- /dev/null
+++ b/apps/web/src/features/email-compose/primitives/reply-recipient-fields.test.ts
@@ -0,0 +1,72 @@
+import { createRoot, createSignal } from 'solid-js';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type {
+ EmailFormRecipients,
+ EmailRecipient,
+} from '../core/email-recipient';
+import { createReplyRecipientFields } from './reply-recipient-fields';
+
+const alice: EmailRecipient = {
+ kind: 'custom',
+ id: 'alice',
+ data: { id: 'alice', email: 'alice@example.com', invalid: false },
+};
+const disposers: (() => void)[] = [];
+afterEach(() => {
+ disposers.splice(0).forEach((dispose) => dispose());
+ document.body.replaceChildren();
+});
+
+function setup(initial: EmailFormRecipients) {
+ const container = document.createElement('div');
+ document.body.append(container);
+ return createRoot((dispose) => {
+ disposers.push(dispose);
+ const [values, setValues] = createSignal(initial);
+ const onChange = vi.fn();
+ const fields = createReplyRecipientFields({
+ values,
+ container: () => container,
+ disabled: () => false,
+ onChange,
+ setValues: (field, recipients) =>
+ setValues((prev) => ({ ...prev, [field]: recipients })),
+ });
+ return { dispose, fields, values, onChange, container };
+ });
+}
+
+describe('reply recipient fields', () => {
+ it('moves recipients without duplicating the destination and schedules the change', () => {
+ const state = setup({ to: [alice], cc: [alice], bcc: [] });
+ state.fields.handleRecipientDrop('cc', alice, 'to');
+ expect(state.values()).toEqual({ to: [], cc: [alice], bcc: [] });
+ expect(state.fields.showCc()).toBe(true);
+ expect(state.onChange).toHaveBeenCalledOnce();
+ state.fields.setRecipients('bcc', [alice]);
+ expect(state.values().bcc).toEqual([alice]);
+ expect(state.onChange).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps the panel open for recipient popovers and releases its outside listener', () => {
+ const state = setup({ to: [alice], cc: [alice], bcc: [] });
+ const otherPopover = document.createElement('div');
+ otherPopover.dataset.popperPositioner = '';
+ document.body.append(otherPopover);
+ const popover = document.createElement('div');
+ popover.dataset.popperPositioner = '';
+ document.body.append(popover);
+ state.fields.setShowExpandedRecipients(true);
+ state.container.dispatchEvent(new Event('pointerdown', { bubbles: true }));
+ popover.dispatchEvent(new Event('pointerdown', { bubbles: true }));
+ expect(state.fields.showExpandedRecipients()).toBe(true);
+ document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }));
+ expect(state.fields.showExpandedRecipients()).toBe(false);
+ expect(state.fields.showCc()).toBe(true);
+ expect(state.fields.showBcc()).toBe(false);
+ state.dispose();
+ state.fields.setShowExpandedRecipients(true);
+ document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }));
+ expect(state.fields.showExpandedRecipients()).toBe(true);
+ });
+});
diff --git a/apps/web/src/features/email-compose/primitives/reply-recipient-fields.ts b/apps/web/src/features/email-compose/primitives/reply-recipient-fields.ts
new file mode 100644
index 00000000000..566c86f82be
--- /dev/null
+++ b/apps/web/src/features/email-compose/primitives/reply-recipient-fields.ts
@@ -0,0 +1,122 @@
+import { makeEventListener } from '@solid-primitives/event-listener';
+import { type Accessor, createSignal, onMount } from 'solid-js';
+import type { EmailRecipient, RecipientFieldId } from '../core/email-recipient';
+import type { EmailFormRecipients } from './email-form-state';
+
+/** Recipient field interaction, independent of drafts, sending and app services. */
+export function createReplyRecipientFields(options: {
+ values: Accessor;
+ setValues: (field: RecipientFieldId, values: EmailRecipient[]) => void;
+ onChange: () => void;
+ container: Accessor;
+ disabled: Accessor;
+}) {
+ const [showExpandedRecipients, setShowExpandedRecipients] =
+ createSignal(false);
+ const [toRef, setToRef] = createSignal();
+ const [ccRef, setCcRef] = createSignal();
+ const [bccRef, setBccRef] = createSignal();
+ const [showCc, setShowCc] = createSignal();
+ const [showBcc, setShowBcc] = createSignal();
+ const [recipientDragState, setRecipientDragState] = createSignal<{
+ recipient: EmailRecipient;
+ sourceField: 'to' | 'cc' | 'bcc';
+ } | null>(null);
+ const handleChipDragStart = (
+ field: 'to' | 'cc' | 'bcc',
+ recipient: EmailRecipient,
+ e: DragEvent
+ ) => {
+ if (options.disabled()) {
+ e.preventDefault();
+ return;
+ }
+ if (!e.dataTransfer) return;
+ setRecipientDragState({ recipient, sourceField: field });
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', '');
+ };
+
+ const handleChipDragEnd = () => {
+ setRecipientDragState(null);
+ };
+
+ const handleRecipientDrop = (
+ targetField: 'to' | 'cc' | 'bcc',
+ recipient: EmailRecipient,
+ sourceField: 'to' | 'cc' | 'bcc'
+ ) => {
+ if (options.disabled()) return;
+ const sourceList = options.values()[sourceField];
+ options.setValues(
+ sourceField,
+ sourceList.filter((r) => r.id !== recipient.id)
+ );
+ const targetList = options.values()[targetField];
+ if (!targetList.some((r) => r.id === recipient.id)) {
+ options.setValues(targetField, [...targetList, recipient]);
+ }
+ if (targetField === 'cc') setShowCc(true);
+ if (targetField === 'bcc') setShowBcc(true);
+ options.onChange();
+ };
+
+ // Keep expanded recipients open while composing; collapse only when leaving
+ // the composer or selecting outside its recipient popover.
+ const expandedPointerDownHandler = (e: PointerEvent) => {
+ if (showExpandedRecipients()) {
+ const target = e.target;
+ if (!(target instanceof Element)) return;
+ if (
+ !options.container()?.contains(target) &&
+ !target.closest('div[data-popper-positioner]')
+ ) {
+ setShowExpandedRecipients(false);
+ setShowCc(options.values().cc.length > 0);
+ setShowBcc(options.values().bcc.length > 0);
+ }
+ }
+ };
+
+ onMount(() => {
+ makeEventListener(document, 'pointerdown', expandedPointerDownHandler);
+ });
+
+ const mobileDrawerCcBccOpen = () =>
+ !!showCc() ||
+ !!showBcc() ||
+ options.values().cc.length > 0 ||
+ options.values().bcc.length > 0;
+ const toggleMobileDrawerCcBcc = () => {
+ const next = !mobileDrawerCcBccOpen();
+ setShowCc(next);
+ setShowBcc(next);
+ };
+
+ return {
+ disabled: options.disabled,
+ showExpandedRecipients,
+ setShowExpandedRecipients,
+ toRef,
+ setToRef,
+ ccRef,
+ setCcRef,
+ bccRef,
+ setBccRef,
+ showCc,
+ setShowCc,
+ showBcc,
+ setShowBcc,
+ recipientDragState,
+ handleChipDragStart,
+ handleChipDragEnd,
+ handleRecipientDrop,
+ mobileDrawerCcBccOpen,
+ toggleMobileDrawerCcBcc,
+ setRecipients(field: RecipientFieldId, values: EmailRecipient[]) {
+ if (options.disabled()) return;
+ options.setValues(field, values);
+ options.onChange();
+ },
+ };
+}
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
new file mode 100644
index 00000000000..10700bd28b3
--- /dev/null
+++ b/apps/web/src/features/email-compose/primitives/send-schedule.test.ts
@@ -0,0 +1,678 @@
+import { createMemo, createRoot, createSignal } from 'solid-js';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { message } from '../../email-message/tests/messages';
+import type {
+ EmailComposeContext,
+ PersistedEmailIdentity,
+} from '../context/compose-capabilities';
+import { decodeBase64Utf8 } from '../core/decode-base64';
+import { createComposeContext } from '../tests/capabilities';
+import { mountEmailComposer } from '../tests/composer';
+import { createEmailEditor, setEmailEditorText } from '../tests/editor';
+import { createEmailFormState } from './email-form-state';
+import {
+ createReplyComposer,
+ type ReplyComposerOptions,
+} from './reply-composer';
+
+function replyComposer(
+ composeContext: EmailComposeContext,
+ replyingTo = () => message('parent'),
+ callbacks: Pick = {}
+) {
+ return createRoot((dispose) => {
+ const editor = createEmailEditor('Ready to send');
+ const parent = replyingTo();
+ const form = createEmailFormState(
+ {
+ viewerEmail: composeContext.viewerEmail,
+ inboxes: composeContext.accounts.inboxes,
+ },
+ { type: 'replying_to', messageId: parent.db_id },
+ { getMessageById: () => parent, getDraftForMessageReply: () => undefined }
+ );
+ const state = createReplyComposer(
+ {
+ ...callbacks,
+ ...composeContext,
+ focusAfterReplyRequest: () => true,
+ sourceEntityId: 'thread',
+ replyingTo,
+ session: {
+ thread: () => ({
+ db_id: 'thread',
+ link_id: 'inbox',
+ inbox_visible: false,
+ }),
+ recipientOptions: () => [],
+ isPersonalReply: () => false,
+ onDraftRemoved() {},
+ exitToThread: () => false,
+ replyRequest: { replyType: () => undefined, clear() {} },
+ getMarkDoneNavigationTargetId: () => undefined,
+ },
+ },
+ () => editor,
+ { container: () => undefined, footer: () => undefined },
+ () => form
+ );
+ state.onContentChange('Ready to send');
+ return {
+ ...state,
+ sendActionDisabled: createMemo(state.sendActionDisabled),
+ dispose,
+ edit(text: string) {
+ setEmailEditorText(editor, text);
+ state.onContentChange(text);
+ },
+ };
+ });
+}
+
+function composer(
+ kind: 'standalone' | 'reply',
+ composeContext: EmailComposeContext
+) {
+ if (kind === 'reply') {
+ const state = replyComposer(composeContext);
+ return {
+ dispose: state.dispose,
+ send: () => state.sendEmail(),
+ schedule: state.handleSendTimeChange,
+ };
+ }
+ const root = mountEmailComposer(composeContext);
+ root.edit('Ready to send');
+ return {
+ dispose: root.dispose,
+ send: root.state.context.onSend,
+ schedule: root.state.context.onSendTimeChange!,
+ };
+}
+
+describe('send and schedule ordering', () => {
+ it('keeps reply recipients unchanged while a schedule is pending', async () => {
+ const context = createComposeContext();
+ const pending = Promise.withResolvers();
+ vi.mocked(context.delivery.schedule).mockReturnValueOnce(pending.promise);
+ const state = replyComposer(context);
+ const originalTo = [...state.form.recipients().to];
+ const schedule = state.handleSendTimeChange(
+ new Date('2026-12-01T12:00:00Z')
+ );
+ try {
+ await vi.advanceTimersByTimeAsync(0);
+ expect(context.delivery.schedule).toHaveBeenCalledOnce();
+ expect(state.sendActionDisabled()).toBe(true);
+ state.recipients.setRecipients('to', []);
+ state.recipients.handleRecipientDrop('cc', originalTo[0], 'to');
+ expect(state.form.recipients().to).toEqual(originalTo);
+ expect(state.form.recipients().cc).toEqual([]);
+ pending.resolve();
+ await schedule;
+ state.recipients.setRecipients('to', []);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(context.delivery.unschedule).toHaveBeenCalledOnce();
+ expect(state.form.sendTime()).toBeUndefined();
+ expect(state.sendActionDisabled()).toBe(false);
+ } finally {
+ pending.resolve();
+ await schedule;
+ state.dispose();
+ }
+ });
+
+ it('undoes only the mark-done belonging to the selected send', async () => {
+ const context = createComposeContext();
+ const first = {
+ draftId: 'first-send',
+ threadId: 'thread',
+ inboxId: 'inbox',
+ };
+ const second = { ...first, draftId: 'second-send' };
+ vi.mocked(context.drafts.saveDraft)
+ .mockResolvedValueOnce(first)
+ .mockResolvedValueOnce(second);
+ vi.mocked(context.delivery.sendMessage)
+ .mockResolvedValueOnce(first)
+ .mockResolvedValueOnce(second);
+ vi.mocked(context.delivery.undoSend).mockImplementation(
+ async ({ onUndone }) => {
+ await onUndone();
+ }
+ );
+ const undoFirst = vi.fn(async () => {});
+ const undoSecond = vi.fn(async () => {});
+ const onMarkDone = vi
+ .fn()
+ .mockImplementationOnce((options) =>
+ options.onUndoHandle({ id: 'first', undo: undoFirst, dispose() {} })
+ )
+ .mockImplementationOnce((options) =>
+ options.onUndoHandle({ id: 'second', undo: undoSecond, dispose() {} })
+ );
+ const state = replyComposer(context, undefined, { onMarkDone });
+ try {
+ await state.sendEmail(true);
+ const firstNotice = vi.mocked(context.notices.feedback.success).mock
+ .lastCall;
+ state.edit('Another reply');
+ await state.sendEmail(true);
+ expect(context.delivery.sendMessage).toHaveBeenCalledTimes(2);
+ firstNotice?.[1]?.actions?.[0].onClick();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(context.drafts.restoreDraft).toHaveBeenCalledWith(
+ expect.objectContaining({ draftId: first.draftId })
+ );
+ expect(undoFirst).toHaveBeenCalledOnce();
+ expect(undoSecond).not.toHaveBeenCalled();
+ } finally {
+ state.dispose();
+ }
+ });
+
+ it('undoes mark-done while the post-send refresh is still pending', async () => {
+ const composeContext = createComposeContext();
+ const { promise: refresh, resolve: finish } = Promise.withResolvers();
+ const undo = vi.fn(async () => {});
+ const onMarkDone = vi.fn((options) =>
+ options.onUndoHandle({ id: 'done', undo, dispose() {} })
+ );
+ vi.mocked(composeContext.delivery.undoSend).mockImplementation(
+ async ({ onUndone }) => {
+ await onUndone();
+ }
+ );
+ const state = replyComposer(composeContext, undefined, {
+ sideEffectOnSend: () => refresh,
+ onMarkDone,
+ });
+ 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');
+ expect(onMarkDone).toHaveBeenCalledOnce();
+ sentNotice?.[1]?.actions?.[0].onClick();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(undo).toHaveBeenCalledOnce();
+ expect(state.isSending()).toBe(false);
+ state.edit('Restored reply edited before refresh');
+ await vi.advanceTimersByTimeAsync(600);
+ expect(
+ decodeBase64Utf8(
+ vi.mocked(composeContext.drafts.saveDraft).mock.lastCall?.[0].draft
+ .body_html ?? ''
+ )
+ ).toContain('Restored reply edited before refresh');
+ finish();
+ await send;
+ expect(onMarkDone).toHaveBeenCalledOnce();
+ } finally {
+ finish();
+ state.dispose();
+ }
+ });
+
+ it('does not add a scheduling notice after persistence already failed', async () => {
+ const composeContext = createComposeContext();
+ const failure = new Error('Draft save failed');
+ vi.mocked(composeContext.drafts.saveDraft).mockImplementationOnce(
+ async () => {
+ composeContext.notices.feedback.failure('Failed to save draft');
+ throw failure;
+ }
+ );
+ const state = replyComposer(composeContext);
+ try {
+ await state.handleSendTimeChange(new Date('2026-12-01T12:00:00Z'));
+ expect(composeContext.delivery.schedule).not.toHaveBeenCalled();
+ expect(
+ composeContext.notices.feedback.failure
+ ).toHaveBeenCalledExactlyOnceWith('Failed to save draft');
+ expect(composeContext.notices.reportError).toHaveBeenCalledWith(failure);
+ } finally {
+ state.dispose();
+ }
+ });
+
+ it('does not overwrite a newly edited reply when an older unmounted send fails', async () => {
+ const composeContext = createComposeContext();
+ const { promise: sending, reject } =
+ Promise.withResolvers();
+ vi.mocked(composeContext.delivery.sendMessage).mockReturnValueOnce(sending);
+ const first = replyComposer(composeContext);
+ first.edit('Older reply');
+ const send = first.sendEmail();
+ await vi.advanceTimersByTimeAsync(0);
+ first.dispose();
+ const newer = replyComposer(composeContext);
+ try {
+ newer.form.setSubject('New subject');
+ newer.edit('Newer reply');
+ reject(new Error('Offline'));
+ await send;
+ expect(newer.form.subject()).toBe('New subject');
+ expect(decodeBase64Utf8(newer.collectDraft()?.body_html ?? '')).toContain(
+ 'Newer reply'
+ );
+ } finally {
+ newer.dispose();
+ }
+ });
+ it('completes reply mark-done when the post-send refresh fails', async () => {
+ const composeContext = createComposeContext();
+ const failure = new Error('Refresh failed');
+ const onMarkDone = vi.fn();
+ const state = replyComposer(composeContext, undefined, {
+ sideEffectOnSend: async () => {
+ throw failure;
+ },
+ onMarkDone,
+ });
+ try {
+ await state.sendEmail(true);
+ expect(composeContext.delivery.sendMessage).toHaveBeenCalledOnce();
+ expect(onMarkDone).toHaveBeenCalledOnce();
+ expect(composeContext.notices.reportError).toHaveBeenCalledWith(failure);
+ expect(composeContext.notices.feedback.failure).not.toHaveBeenCalled();
+ expect(state.isSending()).toBe(false);
+ } finally {
+ state.dispose();
+ }
+ });
+
+ it('restores a failed reply after optimistic reset without marking it done', async () => {
+ const composeContext = createComposeContext();
+ vi.mocked(composeContext.delivery.sendMessage).mockRejectedValueOnce(
+ new Error('Offline')
+ );
+ const onMarkDone = vi.fn();
+ const state = replyComposer(composeContext, undefined, { onMarkDone });
+ try {
+ state.edit('Keep my reply');
+ await state.sendEmail(true);
+ expect(
+ composeContext.notices.feedback.failure
+ ).toHaveBeenCalledExactlyOnceWith('Failed to send email');
+ expect(onMarkDone).not.toHaveBeenCalled();
+ expect(state.savedDraftId()).toBe('draft');
+ expect(decodeBase64Utf8(state.collectDraft()?.body_html ?? '')).toContain(
+ 'Keep my reply'
+ );
+ expect(state.isSending()).toBe(false);
+ } finally {
+ state.dispose();
+ }
+ });
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ it('serializes the last reply edit on disposal and reuses the ID from its first save', async () => {
+ const { promise: saving, resolve: finish } =
+ Promise.withResolvers();
+ const composeContext = createComposeContext();
+ vi.mocked(composeContext.drafts.saveDraft).mockReturnValueOnce(saving);
+ const state = replyComposer(composeContext);
+ state.edit('First version');
+ await vi.advanceTimersByTimeAsync(500);
+ state.edit('Final version');
+ state.dispose();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce();
+ finish({ draftId: 'saved-reply', threadId: 'thread', inboxId: 'inbox' });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(2);
+ const latest = vi.mocked(composeContext.drafts.saveDraft).mock.calls[1][0]
+ .draft;
+ expect(latest.db_id).toBe('saved-reply');
+ expect(latest.replying_to_id).toBe('parent');
+ expect(decodeBase64Utf8(latest.body_html!)).toContain('Final version');
+ });
+
+ it('flushes an unmounted editor only to its original reply target', async () => {
+ const [target, setTarget] = createSignal(message('original'));
+ const composeContext = createComposeContext();
+ const state = replyComposer(composeContext, target);
+ state.edit('Belongs to the original message');
+ // Solid updates keyed parent props before disposing the previous child.
+ setTarget(message('next'));
+ state.dispose();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce();
+ expect(
+ vi.mocked(composeContext.drafts.saveDraft).mock.calls[0][0].draft
+ ).toMatchObject({
+ replying_to_id: 'original',
+ });
+ });
+
+ it('waits for attachment persistence before scheduling and retains the selected inbox', async () => {
+ const { promise: uploading, resolve: finish } =
+ Promise.withResolvers();
+ const composeContext = createComposeContext();
+ vi.mocked(
+ composeContext.attachmentStorage.uploadAttachments
+ ).mockReturnValueOnce(uploading);
+ const state = replyComposer(composeContext);
+ try {
+ state.form.setSelectedInbox('secondary');
+ state.handleAddAttachments([new File(['attachment'], 'review.txt')]);
+ await vi.advanceTimersByTimeAsync(500);
+ const scheduling = state.handleSendTimeChange(
+ new Date('2026-10-01T12:00:00Z')
+ );
+ await vi.advanceTimersByTimeAsync(0);
+ state.form.setSelectedInbox('inbox');
+ expect(composeContext.delivery.schedule).not.toHaveBeenCalled();
+ finish();
+ await scheduling;
+ expect(composeContext.delivery.schedule).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ draftId: 'draft' }),
+ 'secondary'
+ );
+ } finally {
+ state.dispose();
+ }
+ });
+
+ it('discards an in-flight first reply save after an upload failure without leaving a draft', async () => {
+ const { promise: uploading, reject: fail } = Promise.withResolvers();
+ const composeContext = createComposeContext();
+ vi.mocked(
+ composeContext.attachmentStorage.uploadAttachments
+ ).mockReturnValueOnce(uploading);
+ const state = replyComposer(composeContext);
+ try {
+ state.handleAddAttachments([new File(['attachment'], 'review.txt')]);
+ await vi.advanceTimersByTimeAsync(500);
+ expect(state.savedDraftId()).toBe('draft');
+ const discarded = state.deleteDraftAndReset();
+ expect(composeContext.drafts.deleteDraft).not.toHaveBeenCalled();
+ fail(new Error('Upload failed'));
+ await discarded;
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(composeContext.drafts.deleteDraft).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ draftId: 'draft' })
+ );
+ expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce();
+ expect(state.savedDraftId()).toBeUndefined();
+ } finally {
+ state.dispose();
+ }
+ });
+
+ it.each(['send', 'discard'] as const)(
+ 'blocks scheduling and inbox changes during a pending reply %s',
+ async (operation) => {
+ const { promise: pending, resolve: finish } =
+ Promise.withResolvers();
+ const composeContext = createComposeContext();
+ if (operation === 'send')
+ vi.mocked(composeContext.delivery.sendMessage).mockImplementationOnce(
+ async () => {
+ await pending;
+ return { draftId: 'sent', threadId: 'thread', inboxId: 'inbox' };
+ }
+ );
+ else
+ vi.mocked(composeContext.drafts.deleteDraft).mockReturnValueOnce(
+ pending
+ );
+ const state = replyComposer(composeContext);
+ try {
+ state.edit('Ready');
+ await vi.advanceTimersByTimeAsync(500);
+ expect(state.sendActionDisabled()).toBe(false);
+ const completing =
+ operation === 'send'
+ ? state.sendEmail()
+ : state.deleteDraftAndReset();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(state.sendActionDisabled()).toBe(true);
+ const saves = vi.mocked(composeContext.drafts.saveDraft).mock.calls
+ .length;
+ await state.handleSendTimeChange(new Date('2026-10-01T12:00:00Z'));
+ state.persistDraftOnSenderSwitch('secondary');
+ await vi.advanceTimersByTimeAsync(500);
+ expect(composeContext.delivery.schedule).not.toHaveBeenCalled();
+ expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(saves);
+ expect(state.activeInboxId()).toBe('inbox');
+ finish();
+ await completing;
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(state.sendActionDisabled()).toBe(false);
+ expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(saves);
+ } finally {
+ finish();
+ state.dispose();
+ }
+ }
+ );
+
+ it('does not attach a forwarded file removed while the first draft save is pending', async () => {
+ const { promise: saving, resolve: finish } =
+ Promise.withResolvers();
+ const composeContext = createComposeContext();
+ vi.mocked(composeContext.drafts.saveDraft).mockReturnValueOnce(saving);
+ const state = replyComposer(composeContext);
+ try {
+ const attachment = {
+ type: 'forwarded' as const,
+ attachmentId: 'file',
+ fileName: 'review.txt',
+ mimeType: 'text/plain',
+ fileSize: 10,
+ };
+ state.form.attachments.add(attachment);
+ state.edit('Forwarding');
+ await vi.advanceTimersByTimeAsync(500);
+ state.handleRemoveAttachment(attachment);
+ finish({ draftId: 'draft', threadId: 'thread', inboxId: 'inbox' });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(
+ composeContext.attachmentStorage.addForwardedAttachments
+ ).not.toHaveBeenCalled();
+ } finally {
+ state.dispose();
+ }
+ });
+
+ it('waits for the first save, sends its returned draft ID once, and does not recreate the sent reply', async () => {
+ const { promise: saving, resolve: finish } =
+ Promise.withResolvers();
+ const composeContext = createComposeContext();
+ vi.mocked(composeContext.drafts.saveDraft).mockReturnValueOnce(saving);
+ const state = replyComposer(composeContext);
+ try {
+ const sending = state.sendEmail();
+ await vi.advanceTimersByTimeAsync(0);
+ await state.sendEmail();
+ expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce();
+ expect(composeContext.delivery.sendMessage).not.toHaveBeenCalled();
+ finish({ draftId: 'saved-reply', threadId: 'thread', inboxId: 'inbox' });
+ await sending;
+ expect(
+ vi.mocked(composeContext.delivery.sendMessage).mock.calls[0][0].message
+ .db_id
+ ).toBe('saved-reply');
+ state.dispose();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(composeContext.delivery.sendMessage).toHaveBeenCalledOnce();
+ expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce();
+ } finally {
+ state.dispose();
+ }
+ });
+
+ it.each([false, true])(
+ 'restores a cross-inbox reply and its envelope after undo (remount: %s)',
+ async (remount) => {
+ const composeContext = createComposeContext();
+ const persisted = {
+ draftId: 'cross-inbox-draft',
+ threadId: 'secondary-thread',
+ inboxId: 'secondary',
+ };
+ vi.mocked(composeContext.drafts.saveDraft).mockResolvedValue(persisted);
+ vi.mocked(composeContext.delivery.sendMessage).mockResolvedValue(
+ persisted
+ );
+ vi.mocked(composeContext.delivery.undoSend).mockImplementation(
+ async ({ onUndone }) => {
+ await onUndone();
+ }
+ );
+ const target = () => message(`cross-inbox-${remount}`);
+ let state = replyComposer(composeContext, target);
+ try {
+ state.form.setSelectedInbox('secondary');
+ state.form.setSubject('Custom reply subject');
+ state.form.setRecipients('cc', [
+ {
+ kind: 'custom',
+ id: 'reviewer@example.com',
+ data: {
+ id: 'reviewer@example.com',
+ email: 'reviewer@example.com',
+ invalid: false,
+ },
+ },
+ ]);
+ await state.sendEmail();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(composeContext.delivery.sendMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ inboxId: 'secondary' })
+ );
+ const notice = vi
+ .mocked(composeContext.notices.feedback.success)
+ .mock.calls.find(([text]) => text === 'Email sent');
+ if (remount) state.dispose();
+ notice?.[1]?.actions?.[0].onClick();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(composeContext.drafts.restoreDraft).toHaveBeenCalledWith(
+ expect.objectContaining({
+ inboxId: 'secondary',
+ threadId: 'secondary-thread',
+ draft: expect.objectContaining({
+ thread_db_id: 'secondary-thread',
+ }),
+ })
+ );
+ if (remount) state = replyComposer(composeContext, target);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(state.activeInboxId()).toBe('secondary');
+ state.edit('Continued after undo');
+ await vi.advanceTimersByTimeAsync(500);
+ expect(composeContext.drafts.saveDraft).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ inboxId: 'secondary',
+ previousThreadId: 'secondary-thread',
+ draft: expect.objectContaining({
+ db_id: 'cross-inbox-draft',
+ subject: 'Custom reply subject',
+ cc: [expect.objectContaining({ email: 'reviewer@example.com' })],
+ }),
+ })
+ );
+ await state.deleteDraftAndReset();
+ expect(composeContext.drafts.deleteDraft).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ threadId: 'secondary-thread',
+ inboxId: 'secondary',
+ })
+ );
+ } finally {
+ state.dispose();
+ }
+ }
+ );
+
+ it('reconciles each previous persisted thread when a reply moves between inboxes', async () => {
+ const composeContext = createComposeContext();
+ const { promise: saving, resolve: finish } =
+ Promise.withResolvers();
+ vi.mocked(composeContext.drafts.saveDraft)
+ .mockResolvedValue({
+ draftId: 'draft-c',
+ threadId: 'thread-c',
+ inboxId: 'c',
+ })
+ .mockReturnValueOnce(saving)
+ .mockResolvedValueOnce({
+ draftId: 'draft-b',
+ threadId: 'thread-b',
+ inboxId: 'b',
+ })
+ .mockResolvedValueOnce({
+ draftId: 'draft-c',
+ threadId: 'thread-c',
+ inboxId: 'c',
+ });
+ const state = replyComposer(composeContext);
+ try {
+ state.edit('Moving between inboxes');
+ await vi.advanceTimersByTimeAsync(500);
+ state.persistDraftOnSenderSwitch('b');
+ state.persistDraftOnSenderSwitch('c');
+ finish({ draftId: 'draft-a', threadId: 'thread-a', inboxId: 'inbox' });
+ await vi.advanceTimersByTimeAsync(0);
+ const inputs = vi
+ .mocked(composeContext.drafts.saveDraft)
+ .mock.calls.map(([input]) => input);
+ expect(inputs.map((input) => input.previousThreadId)).toEqual([
+ undefined,
+ 'thread-a',
+ 'thread-b',
+ ]);
+ expect(inputs.map((input) => input.draft.db_id)).toEqual([
+ undefined,
+ 'draft-a',
+ 'draft-b',
+ ]);
+ await state.handleSendTimeChange(new Date('2026-10-01T12:00:00Z'));
+ expect(composeContext.delivery.archive).toHaveBeenLastCalledWith(
+ { threadId: 'thread-c', value: true },
+ 'c'
+ );
+ } finally {
+ state.dispose();
+ }
+ });
+
+ it.each(['standalone', 'reply'] as const)(
+ '%s does not dispatch when scheduling starts during the pending draft save',
+ async (kind) => {
+ const { promise: saving, resolve: finishSaving } =
+ Promise.withResolvers();
+ const { promise: scheduled, resolve: finishScheduling } =
+ Promise.withResolvers();
+ const composeContext = createComposeContext();
+ vi.mocked(composeContext.delivery.schedule).mockReturnValue(scheduled);
+ vi.mocked(composeContext.drafts.saveDraft).mockImplementationOnce(
+ () => saving
+ );
+ const state = composer(kind, composeContext);
+ try {
+ state.send();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce();
+ const scheduling = state.schedule(new Date('2026-10-01T12:00:00Z'));
+ await vi.advanceTimersByTimeAsync(0);
+ finishSaving({
+ draftId: 'draft',
+ threadId: 'thread',
+ inboxId: 'inbox',
+ });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(composeContext.delivery.sendMessage).not.toHaveBeenCalled();
+ expect(composeContext.delivery.schedule).toHaveBeenCalledOnce();
+ finishScheduling();
+ await scheduling;
+ } finally {
+ state.dispose();
+ }
+ }
+ );
+});
diff --git a/apps/web/src/features/email-compose/primitives/undo-send-claim.ts b/apps/web/src/features/email-compose/primitives/undo-send-claim.ts
new file mode 100644
index 00000000000..879b4649e44
--- /dev/null
+++ b/apps/web/src/features/email-compose/primitives/undo-send-claim.ts
@@ -0,0 +1,13 @@
+const claimedDraftIds = new Set();
+
+/** Claim an undo for this draft. Returns false if one is in flight or done. */
+export function tryBeginUndoSend(draftId: string): boolean {
+ if (claimedDraftIds.has(draftId)) return false;
+ claimedDraftIds.add(draftId);
+ return true;
+}
+
+/** Release a claim — after a failed undo, or when the draft is sent again. */
+export function endUndoSend(draftId: string) {
+ claimedDraftIds.delete(draftId);
+}
diff --git a/apps/web/src/features/email-compose/primitives/undo-store.test.ts b/apps/web/src/features/email-compose/primitives/undo-store.test.ts
new file mode 100644
index 00000000000..d843315cdbc
--- /dev/null
+++ b/apps/web/src/features/email-compose/primitives/undo-store.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it, vi } from 'vitest';
+import { createEmailUndoStore } from './undo-store';
+
+describe('independent undo recovery', () => {
+ it('keeps concurrent drafts and live replies separate', () => {
+ const store = createEmailUndoStore<{ draftId: string; html: string }>();
+ const a = { draftId: 'a', html: 'First' };
+ const b = { draftId: 'b', html: 'Second' };
+ const first = vi.fn();
+ const second = vi.fn();
+ store.remember(a);
+ store.remember(b);
+ const cleanupA = store.register('thread-a:reply', first);
+ store.register('thread-b:reply', second);
+ store.restore('thread-a:reply', store.take('a')!);
+ expect(first).toHaveBeenCalledWith(a);
+ expect(second).not.toHaveBeenCalled();
+ cleanupA();
+ store.restore('thread-b:reply', store.take('b')!);
+ expect(second).toHaveBeenCalledWith(b);
+ });
+ it('queues remount recovery and does not let an older owner remove a new registration', () => {
+ const store = createEmailUndoStore<{ draftId: string }>();
+ const snapshot = { draftId: 'draft' };
+ store.restore('thread:reply', snapshot);
+ expect(store.takePending('unrelated')).toBeUndefined();
+ expect(store.takePending('thread:reply')).toBe(snapshot);
+ expect(store.takePending('thread:reply')).toBeUndefined();
+ const old = store.register('thread:reply', vi.fn());
+ const current = vi.fn();
+ store.register('thread:reply', current);
+ old();
+ store.restore('thread:reply', snapshot);
+ expect(current).toHaveBeenCalledWith(snapshot);
+ });
+});
diff --git a/apps/web/src/features/email-compose/primitives/undo-store.ts b/apps/web/src/features/email-compose/primitives/undo-store.ts
new file mode 100644
index 00000000000..0b1fed5530b
--- /dev/null
+++ b/apps/web/src/features/email-compose/primitives/undo-store.ts
@@ -0,0 +1,40 @@
+/** Draft-keyed snapshots and reply-keyed remount recovery. No reactive or application state. */
+export function createEmailUndoStore() {
+ const sent = new Map();
+ const pending = new Map();
+ const listeners = new Map void>();
+ const remember = (snapshot: Snapshot) => {
+ sent.set(snapshot.draftId, snapshot);
+ // Only the recent send toast offers Undo; keep a small bounded recovery history.
+ if (sent.size > 50) sent.delete(sent.keys().next().value!);
+ };
+ return {
+ remember,
+ peek: (draftId: string) => sent.get(draftId),
+ take(draftId: string) {
+ const snapshot = sent.get(draftId);
+ sent.delete(draftId);
+ return snapshot;
+ },
+ takePending(key: string) {
+ const snapshot = pending.get(key);
+ pending.delete(key);
+ return snapshot;
+ },
+ restore(key: string, snapshot: Snapshot) {
+ const listener = listeners.get(key);
+ if (listener) {
+ listener(snapshot);
+ return;
+ }
+ pending.set(key, snapshot);
+ if (pending.size > 50) pending.delete(pending.keys().next().value!);
+ },
+ register(key: string, listener: (snapshot: Snapshot) => void) {
+ listeners.set(key, listener);
+ return () => {
+ if (listeners.get(key) === listener) listeners.delete(key);
+ };
+ },
+ };
+}
diff --git a/apps/web/src/features/email-compose/queries/inbox-source.test.ts b/apps/web/src/features/email-compose/queries/inbox-source.test.ts
new file mode 100644
index 00000000000..be38addb1c8
--- /dev/null
+++ b/apps/web/src/features/email-compose/queries/inbox-source.test.ts
@@ -0,0 +1,82 @@
+import { batch, createRoot, createSignal } from 'solid-js';
+import { expect, it } from 'vitest';
+import { message } from '../../email-message/tests/messages';
+import { createEmailFormState } from '../primitives/email-form-state';
+import { createEmailInboxSource, type EmailInboxQuery } from './inbox-source';
+
+it.each(['pending', 'error'])(
+ 'keeps cached replies but clears inboxes when the owner changes during %s',
+ (nextStatus) =>
+ createRoot((dispose) => {
+ try {
+ const [owner, setOwner] = createSignal('me@example.com');
+ const [status, setStatus] = createSignal('success');
+ const data = {
+ links: [
+ {
+ id: 'secondary',
+ email_address: 'shared@example.com',
+ settings: { signature: 'Shared signature
' },
+ },
+ ],
+ };
+ const query = {
+ get isSuccess() {
+ return status() === 'success';
+ },
+ get isError() {
+ return status() === 'error';
+ },
+ get isPending() {
+ return status() === 'pending';
+ },
+ get data() {
+ if (status() === 'pending') throw new Error('suspending read');
+ return data;
+ },
+ } as unknown as EmailInboxQuery;
+ const source = createEmailInboxSource(
+ owner,
+ query,
+ () => 'Shared Inbox'
+ );
+ setStatus('error');
+ const parent = message('parent', {
+ link_id: 'secondary',
+ from: { email: 'shared@example.com' },
+ to: [{ email: 'colleague@example.com' }],
+ });
+ const form = createEmailFormState(
+ { viewerEmail: owner, inboxes: source.inboxes },
+ { type: 'replying_to', messageId: 'parent' },
+ {
+ getMessageById: () => parent,
+ getDraftForMessageReply: () => undefined,
+ }
+ );
+ expect(form.selectedInboxId()).toBe('secondary');
+ expect(form.recipients().to.map((item) => item.data.email)).toEqual([
+ 'colleague@example.com',
+ ]);
+ expect(source.inboxes()[0].settings.signature).toContain(
+ 'Shared signature'
+ );
+ const reopened = createEmailInboxSource(
+ owner,
+ query,
+ () => 'Shared Inbox'
+ );
+ expect(reopened.inboxes()[0].id).toBe('secondary');
+ batch(() => {
+ setOwner('other@example.com');
+ setStatus(nextStatus);
+ });
+ expect(source.inboxes()).toEqual([]);
+ setStatus('pending');
+ setStatus('error');
+ expect(source.inboxes()).toEqual([]);
+ } finally {
+ dispose();
+ }
+ })
+);
diff --git a/apps/web/src/features/email-compose/queries/inbox-source.ts b/apps/web/src/features/email-compose/queries/inbox-source.ts
new file mode 100644
index 00000000000..1e8d7f95b91
--- /dev/null
+++ b/apps/web/src/features/email-compose/queries/inbox-source.ts
@@ -0,0 +1,46 @@
+import type { useEmailLinksQuery } from '@queries/email/link';
+import { type Accessor, createMemo } from 'solid-js';
+import type { EmailInbox } from '../context/compose-capabilities';
+
+export type EmailInboxQuery = Pick<
+ ReturnType,
+ 'data' | 'isSuccess' | 'isError' | 'isPending'
+>;
+
+/** Expose available inbox metadata, including cached data after a failed refresh. */
+export function createEmailInboxSource(
+ owner: Accessor,
+ query: EmailInboxQuery,
+ displayName: (email: string) => string | undefined
+) {
+ const snapshot = createMemo<{
+ owner: string | undefined;
+ inboxes: EmailInbox[];
+ }>((previous) => {
+ const id = owner();
+ // A newly mounted source can use cached data after a failed refresh.
+ // Once mounted, failed reads may only retain the same owner's snapshot.
+ if (!query.isSuccess && (!query.isError || previous))
+ return {
+ owner: id,
+ inboxes: previous && previous.owner === id ? previous.inboxes : [],
+ };
+ const inboxes = (query.data?.links ?? []).map((inbox) => ({
+ id: inbox.id,
+ email_address: inbox.email_address,
+ photo_url: inbox.photo_url,
+ displayName: displayName(inbox.email_address),
+ settings: {
+ signature: inbox.settings.signature,
+ signature_on_replies_forwards:
+ inbox.settings.signature_on_replies_forwards,
+ },
+ }));
+ return { owner: id, inboxes };
+ });
+ return {
+ inboxes: () => snapshot().inboxes,
+ loading: () => query.isPending,
+ failed: () => query.isError,
+ };
+}
diff --git a/apps/web/src/features/email-compose/tests/capabilities.ts b/apps/web/src/features/email-compose/tests/capabilities.ts
new file mode 100644
index 00000000000..8178f24d53c
--- /dev/null
+++ b/apps/web/src/features/email-compose/tests/capabilities.ts
@@ -0,0 +1,69 @@
+import { vi } from 'vitest';
+import type { EmailComposeContext } from '../context/compose-capabilities';
+/** Fake capabilities: no production modules or app providers are needed by a controller. */
+export function createComposeContext(): EmailComposeContext {
+ return {
+ recipientName: (id) => id,
+ recordMention: vi.fn(),
+ accounts: {
+ inboxes: () => [
+ { id: 'inbox', email_address: 'me@example.com', settings: {} },
+ ],
+ loading: () => false,
+ failed: () => false,
+ primaryId: () => 'inbox',
+ },
+ viewerEmail: () => 'me@example.com',
+ recipients: () => [],
+ hasPaidAccess: () => true,
+ presentation: {
+ viewerLoading: () => false,
+ onUpgrade: vi.fn(),
+ prepareSignatureLinks: vi.fn(),
+ isTouch: () => false,
+ isMobile: () => false,
+ scheduleEnabled: true,
+ signaturesEnabled: () => false,
+ },
+ editorFiles: {
+ readDroppedFiles: vi.fn(),
+ makePublic: vi.fn(),
+ uploadEditorFiles: vi.fn(),
+ },
+ notices: {
+ feedback: {
+ success: vi.fn(),
+ failure: vi.fn(),
+ alert: vi.fn(),
+ dismiss: vi.fn(),
+ },
+ reportError: vi.fn(),
+ },
+ drafts: {
+ saveDraft: vi.fn(async () => ({
+ draftId: 'draft',
+ threadId: 'thread',
+ inboxId: 'inbox',
+ })),
+ deleteDraft: vi.fn(async () => {}),
+ restoreDraft: vi.fn(async () => {}),
+ },
+ delivery: {
+ sendMessage: vi.fn(async () => ({
+ draftId: 'sent',
+ threadId: 'thread',
+ inboxId: 'inbox',
+ })),
+ unschedule: vi.fn(async () => {}),
+ schedule: vi.fn(async () => {}),
+ archive: vi.fn(async () => {}),
+ undoSend: vi.fn(async () => {}),
+ },
+ attachmentStorage: {
+ uploadAttachments: vi.fn(async () => {}),
+ addForwardedAttachments: vi.fn(async () => {}),
+ removeAttachment: vi.fn(async () => {}),
+ removeForwardedAttachment: vi.fn(async () => {}),
+ },
+ };
+}
diff --git a/apps/web/src/features/email-compose/tests/composer.ts b/apps/web/src/features/email-compose/tests/composer.ts
new file mode 100644
index 00000000000..5dec24511fd
--- /dev/null
+++ b/apps/web/src/features/email-compose/tests/composer.ts
@@ -0,0 +1,33 @@
+import { createRoot } from 'solid-js';
+import type {
+ EmailComposeContext,
+ EmailComposeHost,
+} from '../context/compose-capabilities';
+import { createEmailComposer } from '../primitives/email-composer';
+import { createEmailEditor, setEmailEditorText } from './editor';
+
+/** A blank, addressed composer with a real editor and the normal initialization callback. */
+export function mountEmailComposer(
+ context: EmailComposeContext,
+ host?: EmailComposeHost
+) {
+ const root = createRoot((dispose) => ({
+ dispose,
+ state: createEmailComposer({
+ ...context,
+ initialTo: ['colleague@example.com'],
+ host,
+ }),
+ }));
+ const editor = createEmailEditor();
+ root.state.context.captureEditor(editor);
+ root.state.context.onContentChange('');
+ return {
+ ...root,
+ edit(text: string, subject = 'Review') {
+ setEmailEditorText(editor, text);
+ root.state.context.setSubject(subject);
+ root.state.context.onContentChange(text);
+ },
+ };
+}
diff --git a/apps/web/src/features/email-compose/tests/editor.ts b/apps/web/src/features/email-compose/tests/editor.ts
new file mode 100644
index 00000000000..d66f3295cc6
--- /dev/null
+++ b/apps/web/src/features/email-compose/tests/editor.ts
@@ -0,0 +1,30 @@
+import { SupportedNodeTypes } from '@macro-inc/lexical-core';
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ createEditor,
+ type LexicalEditor,
+} from 'lexical';
+
+export function createEmailEditor(text = '') {
+ const editor = createEditor({
+ nodes: SupportedNodeTypes,
+ onError(error) {
+ throw error;
+ },
+ });
+ setEmailEditorText(editor, text);
+ return editor;
+}
+
+export function setEmailEditorText(editor: LexicalEditor, text: string) {
+ editor.update(
+ () => {
+ $getRoot()
+ .clear()
+ .append($createParagraphNode().append($createTextNode(text)));
+ },
+ { discrete: true }
+ );
+}
diff --git a/apps/web/src/features/block-email/util/undoSend.ts b/apps/web/src/features/email-compose/undo-send.ts
similarity index 83%
rename from apps/web/src/features/block-email/util/undoSend.ts
rename to apps/web/src/features/email-compose/undo-send.ts
index 12fc4f6a228..8eb2585c00c 100644
--- a/apps/web/src/features/block-email/util/undoSend.ts
+++ b/apps/web/src/features/email-compose/undo-send.ts
@@ -1,11 +1,15 @@
import { toast } from '@core/component/Toast/Toast';
import { Telemetry } from '@macro-inc/observability';
import { queryClient } from '@queries/client';
+import {
+ restoreEmailDraft,
+ unscheduleEmailMessage,
+} from '@queries/email/integration';
import { emailKeys } from '@queries/email/keys';
import { invalidateSoupEntity } from '@queries/soup/cache';
-import { emailClient } from '@service-email/client';
import type { ApiDraftInput } from '@service-email/generated/schemas';
-import { prepareEmailBodyFromHtml } from './prepareEmailBody';
+import { prepareEmailBodyFromHtml } from './primitives/prepare-email-body';
+import { endUndoSend, tryBeginUndoSend } from './primitives/undo-send-claim';
/**
* Guards undo-send against duplicate invocations. The undo toast stays
@@ -17,34 +21,17 @@ import { prepareEmailBodyFromHtml } from './prepareEmailBody';
* failed undo releases it (retry allowed), and a new send of the same draft
* releases it to open the next undo cycle.
*/
-const claimedDraftIds = new Set();
-
-/** Claim an undo for this draft. Returns false if one is in flight or done. */
-export function tryBeginUndoSend(draftId: string): boolean {
- if (claimedDraftIds.has(draftId)) return false;
- claimedDraftIds.add(draftId);
- return true;
-}
-
-/** Release a claim — after a failed undo, or when the draft is sent again. */
-export function endUndoSend(draftId: string) {
- claimedDraftIds.delete(draftId);
-}
-
/**
* Unschedule with one retry on transient failures (network errors, 5xx from a
* redeploy or proxy blip). Retrying is safe: the endpoint treats an
* already-undone send as success. 400 (already sent — the undo window passed)
* and 404 (not found) are definitive and not retried.
*/
-export async function unscheduleWithRetry(
+async function unscheduleWithRetry(
draftId: string,
linkId: string | undefined
) {
- const first = await emailClient.unscheduleMessage(
- { draftID: draftId },
- linkId
- );
+ const first = await unscheduleEmailMessage({ draftID: draftId }, linkId);
if (first.isOk()) return first;
const definitive = first.error.some(
(e) =>
@@ -53,7 +40,7 @@ export async function unscheduleWithRetry(
);
if (definitive) return first;
await new Promise((resolve) => setTimeout(resolve, 500));
- return emailClient.unscheduleMessage({ draftID: draftId }, linkId);
+ return unscheduleEmailMessage({ draftID: draftId }, linkId);
}
/**
@@ -127,7 +114,7 @@ export async function restoreDraftBodyAfterUndo(
linkId: string | undefined
): Promise {
const prepared = prepareEmailBodyFromHtml(bodyHtml);
- const saveResult = await emailClient.createDraft(
+ const saveResult = await restoreEmailDraft(
{ draft: { ...draft, body_html: prepared.bodyHtml } },
linkId
);
diff --git a/apps/web/src/features/block-email/component/compose/ComposeBody.tsx b/apps/web/src/features/email-compose/views/compose-body.tsx
similarity index 72%
rename from apps/web/src/features/block-email/component/compose/ComposeBody.tsx
rename to apps/web/src/features/email-compose/views/compose-body.tsx
index 68b0be5b2ad..7aba8e44833 100644
--- a/apps/web/src/features/block-email/component/compose/ComposeBody.tsx
+++ b/apps/web/src/features/email-compose/views/compose-body.tsx
@@ -1,15 +1,8 @@
-import { EmailAttachmentPill } from '@block-email/component/AttachmentPill';
-import type { DraftFormAttachment } from '@block-email/component/createEmailFormState';
-import { MacroSignatureButton } from '@block-email/component/MacroSignatureButton';
-import { addUserMentionToCc } from '@block-email/util/mentionToCc';
-import { useSplitPanel } from '@components/app/split-layout/layoutUtils';
+import { EmailAttachmentPill } from '@app/features/email-message/components/attachment-pill';
import { FileDropOverlay } from '@core/component/FileDropOverlay';
import { MarkdownTextarea } from '@core/component/LexicalMarkdown/component/core/MarkdownTextarea';
-import { createFilesReadyHandler } from '@core/component/LexicalMarkdown/utils/fileUploadUtils';
import { fileFolderDrop } from '@core/directive/fileFolderDrop';
-import { handleFileFolderDrop } from '@core/util/upload';
import { Telemetry } from '@macro-inc/observability';
-
import { cn, Scroll } from '@ui';
import type { LexicalEditor } from 'lexical';
import {
@@ -23,10 +16,10 @@ import {
Show,
Switch,
} from 'solid-js';
-import type { FocusableElement } from 'tabbable';
-import { tabbable } from 'tabbable';
-import { makeAttachmentPublic } from '../../util/makeAttachmentPublic';
-import { useCompose } from './ComposeContext';
+import { MacroSignatureButton } from '../components/macro-signature-button';
+import { useCompose } from '../context/compose-context';
+import type { DraftFormAttachment } from '../primitives/email-form-state';
+import { addUserMentionToCc } from '../primitives/mention-to-cc';
false && fileFolderDrop;
@@ -37,54 +30,10 @@ export function ComposeBody(props: {
onAddFiles?: (files: File[]) => void;
}) {
const ctx = useCompose();
- const panel = useSplitPanel();
const [editor, setEditor] = createSignal();
const [isDragging, setIsDragging] = createSignal();
- const focusSibling = (direction: 'next' | 'prev') => {
- const panelRef = panel?.panelRef();
- if (!panelRef) return;
- const tabbableEls = tabbable(panelRef);
- const activeEl = document.activeElement;
- const activeElIndex = tabbableEls.indexOf(activeEl as FocusableElement);
- if (activeElIndex > -1) {
- const ndx = activeElIndex + (direction === 'next' ? 1 : -1);
- if (ndx < 0 || ndx >= tabbableEls.length) return false;
- const prevEl = tabbableEls[ndx];
- if (!prevEl) return false;
- prevEl.focus();
- return true;
- }
- tabbableEls.at(-1)?.focus();
- return true;
- };
-
- const onAddFilesAndDirs = (
- files: FileSystemFileEntry[],
- directories: FileSystemDirectoryEntry[]
- ) => {
- const editor_ = editor();
- if (!editor_) return;
-
- handleFileFolderDrop(
- files,
- directories,
- createFilesReadyHandler(
- editor_,
- undefined,
- undefined,
- undefined,
- (uploadedItemIds) => {
- uploadedItemIds.forEach((itemId) => {
- makeAttachmentPublic(itemId);
- });
- },
- { width: 542, height: 542 }
- )
- );
- };
-
let bodyDiv!: HTMLDivElement;
const logComposeBody = (event: string, details?: Record) => {
@@ -149,8 +98,8 @@ export function ComposeBody(props: {
onDragStart: (valid) => setIsDragging(valid),
onDragEnd: () => setIsDragging(false),
onDrop: (files, dirs) => {
- handleFileFolderDrop(files, dirs, (u) =>
- props.onAddFiles?.(u.map((f) => f.file))
+ ctx.bodyActions.readDroppedFiles(files, dirs, (files) =>
+ props.onAddFiles?.(files)
);
},
}}
@@ -165,6 +114,7 @@ export function ComposeBody(props: {
floatingFormatMenu
domRef={props.inputRef}
captureEditor={captureEditor}
+ onInitialized={ctx.onEditorInitialized}
scrollRef={props.mobileScrollRef}
initialHtml={ctx.initialHtml()}
initialValue={ctx.initialMarkdown?.()}
@@ -172,7 +122,12 @@ export function ComposeBody(props: {
editable={() => !ctx.disabled()}
placeholder="Use `@` to reference files"
watermark={
- !ctx.hasPaidAccess() ? : undefined
+ !ctx.hasPaidAccess() ? (
+
+ ) : undefined
}
onChange={ctx.onContentChange}
onUserMention={(mention) => {
@@ -182,19 +137,25 @@ export function ComposeBody(props: {
toRecipients: ctx.recipients().to,
ccRecipients: ctx.recipients().cc,
bccRecipients: ctx.recipients().bcc,
+ onRecipientAdded: ctx.bodyActions.recipientAdded,
setCc: (next) => ctx.setRecipients('cc', next),
});
}}
onFocusLeaveStart={(e) => {
+ if (!ctx.bodyActions.focusSibling) return;
e.preventDefault();
- focusSibling('prev');
+ ctx.bodyActions.focusSibling('prev');
}}
onFocusLeaveEnd={(e) => {
+ if (!ctx.bodyActions.focusSibling) return;
e.preventDefault();
- focusSibling('next');
+ ctx.bodyActions.focusSibling('next');
}}
portalScope="local"
- onPasteFilesAndDirs={onAddFilesAndDirs}
+ onPasteFilesAndDirs={(files, directories) => {
+ const ed = editor();
+ if (ed) ctx.bodyActions.pasteFiles(ed, files, directories);
+ }}
/>
diff --git a/apps/web/src/features/block-email/component/compose/ComposeLayout.tsx b/apps/web/src/features/email-compose/views/compose-layout.tsx
similarity index 92%
rename from apps/web/src/features/block-email/component/compose/ComposeLayout.tsx
rename to apps/web/src/features/email-compose/views/compose-layout.tsx
index c14d53ca40e..82d85f061cd 100644
--- a/apps/web/src/features/block-email/component/compose/ComposeLayout.tsx
+++ b/apps/web/src/features/email-compose/views/compose-layout.tsx
@@ -1,14 +1,14 @@
import { CircleSpinner } from '@core/component/CircleSpinner';
import { registerHotkey, useHotkeyDOMScope } from '@core/hotkey/hotkeys';
import { TOKENS } from '@core/hotkey/tokens';
-import { isMobile } from '@core/mobile/isMobile';
+
import { Button, cn } from '@ui';
import { createSignal, type JSX, onMount, Show, Suspense } from 'solid-js';
-import { FromInboxSelector } from '../FromInboxSelector';
-import { ComposeBody } from './ComposeBody';
-import { useCompose } from './ComposeContext';
-import { ComposeRecipients } from './ComposeRecipients';
-import { ComposeSubject } from './ComposeSubject';
+import { FromInboxSelector } from '../components/from-inbox-selector';
+import { useCompose } from '../context/compose-context';
+import { ComposeBody } from './compose-body';
+import { ComposeRecipients } from './compose-recipients';
+import { ComposeSubject } from './compose-subject';
type ComposeLayoutRefs = {
directRecipientsSelector: HTMLElement | undefined;
@@ -166,7 +166,7 @@ export function ComposeLayout(props: {
// Uncommitted text in either field also blocks the fold so it isn't
// silently discarded.
const collapseCcBccIfEmpty = () => {
- if (!isMobile()) return;
+ if (!ctx.isMobile()) return;
if (ctx.recipients().cc.length > 0 || ctx.recipients().bcc.length > 0)
return;
const hasPendingInput = [
@@ -191,7 +191,7 @@ export function ComposeLayout(props: {