diff --git a/CLAUDE.md b/CLAUDE.md index 12de36d589d..6b6be4cff98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,8 @@ This file is the shared entry point: `AGENTS.md` symlinks to `CLAUDE.md`. Edit | --- | --- | | Rust code, builds, or tests | [Rust development](docs/RUST_DEVELOPMENT.md) | | SQLx queries, migrations, DB tests, or cache errors | [Database development](docs/DATABASE_DEVELOPMENT.md) | -| Web frontend or email-rendering snapshots | [Web agent guide](apps/web/AGENTS.md) | +| Web frontend | [Web agent guide](apps/web/AGENTS.md) | +| Email body rendering or snapshots | [Standalone renderer](packages/email-renderer/README.md) | | Running the frontend or backend on a local machine | [Running locally](docs/RUNNING_LOCALLY.md) | | Working inside Cursor Cloud | [Cursor Cloud](docs/CURSOR_CLOUD.md) | | Driving the app through a browser | [App agent guide](docs/AGENT_GUIDE/README.md) | diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 0c2b08d565c..a1db1d29542 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -5,7 +5,7 @@ - `bun run lint`: lint with biome - `bun run format`: format changes with biome - `bun run knip`: to check for dead code -- Email rendering snapshots (Playwright HTML fixtures, not inbox e2e) live in `src/lib/core/email/tests`. Run `just test-email-rendering`. Add a fixture under `fixtures/` then `just test-email-rendering-update`. +- Email rendering is isolated in `packages/email-renderer` at the repository root. Run `just test-email-rendering` for its Node and Chromium suites. Add fixtures under `packages/email-renderer/tests/fixtures`, run `just test-email-rendering-update`, and review changed images. These are renderer tests, not inbox e2e. ## Verifying a change in a real browser @@ -79,7 +79,7 @@ Then trigger the interaction and read `window.__inst.log`. `'1,2,3' → '' → ' - Keep reusable components small, atomic, and decoupled from queries/complex state. Push data-fetching and mutations up to use-case-specific composed components. - Context should be scoped to a component subtree — Message.Content consuming a MessageContext is fine because the ownership boundary is clear. - Composed primitives must not depend on use-case-specific context — a RecipientsSelector should never require an EmailComposeContext. -- New features use the layered layout in docs/STYLE_GUIDE.md FE-33 (`core / queries / primitives / components / views` plus an injected `context/`). `src/features/activity` is the reference. +- New features and feature restructures use the layered layout in [docs/FRONTEND_FEATURE_ARCHITECTURE.md](../../docs/FRONTEND_FEATURE_ARCHITECTURE.md), summarized by FE-33 (`core / queries / primitives / components / views` plus an injected `context/`). Keep production wiring in an app-facing entry point and give reactive logic narrow feature-owned contracts. `src/features/activity` illustrates the layers but still has documented composition and contract migration gaps. ## Styling - Use semantic color tokens, not raw Tailwind color classes. diff --git a/apps/web/justfile b/apps/web/justfile index f65a7daa05f..e6b5b57acbb 100644 --- a/apps/web/justfile +++ b/apps/web/justfile @@ -112,10 +112,11 @@ test-watch: bunx --bun vitest test-email-rendering: - cd src/lib/core/email/tests && bunx playwright test + bun run --cwd ../../packages/email-renderer test + bun run --cwd ../../packages/email-renderer test:browser test-email-rendering-update: - cd src/lib/core/email/tests && bunx playwright test --update-snapshots + bun run --cwd ../../packages/email-renderer test:browser --update-snapshots preview-prod: MODE=production NODE_ENV=production bun run preview:prod @@ -165,10 +166,10 @@ fix-lock: # Analysis cycles: - bunx --bun biome lint --only=nursery/noImportCycles + bunx --bun biome lint --only=suspicious/noImportCycles cycles-ci: - bunx --bun biome lint --changed --only=nursery/noImportCycles + bunx --bun biome lint --changed --only=suspicious/noImportCycles # Build the GraphQL cache wasm module into src/lib/graphql-cache/wasm # (gitignored). Unconditional — CI app builds must never skip it (schema or diff --git a/apps/web/package.json b/apps/web/package.json index a6872735c31..01defc8589a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "knip": "bunx --bun knip" }, "dependencies": { + "@macro-inc/email-renderer": "workspace:*", "@aws-crypto/sha256-js": "^5.2.0", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", diff --git a/apps/web/src/components/app/mailtoComposerHandler.ts b/apps/web/src/components/app/mailtoComposerHandler.ts index 9422d40145e..2488e23f30a 100644 --- a/apps/web/src/components/app/mailtoComposerHandler.ts +++ b/apps/web/src/components/app/mailtoComposerHandler.ts @@ -1,5 +1,5 @@ +import { parseMailto } from '@app/features/email-compose/core/mailto'; import { globalSplitManager } from '@app/signal/splitLayout'; -import { parseMailto } from '@block-email/util/mailto'; import { registerExternalUrlInterceptor } from '@core/util/url'; /** diff --git a/apps/web/src/components/app/split-layout/componentRegistry.tsx b/apps/web/src/components/app/split-layout/componentRegistry.tsx index dfebfc94287..5837181be99 100644 --- a/apps/web/src/components/app/split-layout/componentRegistry.tsx +++ b/apps/web/src/components/app/split-layout/componentRegistry.tsx @@ -4,6 +4,7 @@ import { ComposeAgentSession } from '@app/features/block-agent/component/Compose import type { EventEditorInitialValues } from '@app/features/calendar/components/composer/event-form-model'; import type { CalendarEvent } from '@app/features/calendar/types'; import { ChannelsView } from '@app/features/channels-view/channels-view'; +import { EmailCompose } from '@app/features/email-compose/email-compose'; import { EmailView } from '@app/features/email-view/email-view'; import { GettingStarted } from '@app/features/getting-started'; import { Home } from '@app/features/home'; @@ -24,7 +25,6 @@ import { useFeatureFlag, usePosthog } from '@app/lib/analytics/posthog'; import { globalSplitManager } from '@app/signal/splitLayout'; import { EventComposerSplit } from '@block-calendar/components/EventComposerSplit'; import { ChannelCompose } from '@block-channel/component/Compose'; -import { EmailCompose } from '@block-email/component/compose/Compose'; import { ComposeSkill } from '@block-md/component/ComposeSkill'; import { ComposeTask } from '@block-md/component/ComposeTask'; import { @@ -691,7 +691,7 @@ registerComponent('email-compose', (params) => { .filter(Boolean); const draftID = typeof params.draftID === 'string' ? params.draftID : undefined; - return ; + return ; }); registerComponent('task-compose', (params) => { usePageViewTracking('task-compose'); diff --git a/apps/web/src/features/block-email/EmailBlockAdapter.tsx b/apps/web/src/features/block-email/EmailBlockAdapter.tsx new file mode 100644 index 00000000000..99406280df5 --- /dev/null +++ b/apps/web/src/features/block-email/EmailBlockAdapter.tsx @@ -0,0 +1,156 @@ +import { AskMacroButton } from '@app/features/chat/ChatWithAgentButton'; +import { useEmailThreadState } from '@app/features/email-thread/context/email-thread-state-context'; +import { URL_PARAMS } from '@app/features/email-thread/core/location'; +import { EmailThread } from '@app/features/email-thread/email-thread'; +import { SidePanel } from '@components/app/side-panel'; +import { useSplitLayout } from '@components/app/split-layout/layout'; +import { + useCanAutofocusSplitContent, + useSplitPanel, +} from '@components/app/split-layout/layoutUtils'; +import { TOKENS } from '@core/hotkey/tokens'; +import { registerScopeSignalHotkey } from '@core/hotkey/utils'; +import { isTouchDevice } from '@core/mobile/isTouchDevice'; +import { createMethodRegistration } from '@core/orchestrator'; +import { + blockElementSignal, + blockHotkeyScopeSignal, +} from '@core/signal/blockElement'; +import { blockHandleSignal } from '@core/signal/load'; +import { buildMentionMarkdownString } from '@macro-inc/lexical-core'; +import { useSearchParams } from '@solidjs/router'; +import { + type Accessor, + createEffect, + createSignal, + onCleanup, + Show, +} from 'solid-js'; +import { EmailTaskButton } from './component/EmailTaskButton'; +import { ModalsProvider } from './component/ModalsProvider'; +import { EmailSidePanelSections } from './component/sidepanel/EmailSidePanelSections'; +import { TopBar } from './component/TopBar'; +import { registerEmailHotkeys } from './util/emailHotkeys'; + +export function EmailBlockAdapter(props: { + title: string; + threadId: Accessor; +}) { + const [params] = useSearchParams(); + const rawTarget = params[URL_PARAMS.messageId]; + const [targetMessageId, setTargetMessageId] = createSignal( + Array.isArray(rawTarget) ? rawTarget[0] : rawTarget + ); + const split = useSplitPanel(); + const canAutofocus = useCanAutofocusSplitContent(); + const { popoverSplit } = useSplitLayout(); + const blockElement = blockElementSignal.get; + const hotkeyScope = blockHotkeyScopeSignal.get; + const focusContainer = () => blockElement()?.focus({ preventScroll: true }); + let targetTimer: ReturnType | undefined; + createMethodRegistration(blockHandleSignal.get, { + goToLocationFromParams: (params: Record) => { + const id = params[URL_PARAMS.messageId]; + if (typeof id !== 'string' || !id) return; + clearTimeout(targetTimer); + setTargetMessageId(undefined); + targetTimer = setTimeout(() => setTargetMessageId(id), 0); + }, + }); + onCleanup(() => clearTimeout(targetTimer)); + let focused = false; + createEffect(() => { + if (focused || !canAutofocus || isTouchDevice() || !blockElement()) return; + focusContainer(); + focused = true; + }); + const createTask = () => + popoverSplit({ + type: 'component', + id: 'task-compose', + params: { + initialTitle: + props.title.length > 70 + ? `${props.title.slice(0, 70)}...` + : props.title, + initialContent: buildMentionMarkdownString({ + type: 'document', + documentId: props.threadId(), + documentName: props.title, + blockName: 'email', + }), + }, + }); + return ( + split?.isPanelActive() !== false, + registerKeyboard: (handlers) => { + registerEmailHotkeys(hotkeyScope(), handlers); + registerScopeSignalHotkey(hotkeyScope, { + hotkey: 'enter', + description: 'Reply to message', + keyDownHandler: handlers.activate, + hotkeyToken: TOKENS.block.focus, + hide: true, + }); + registerScopeSignalHotkey(hotkeyScope, { + hotkey: 'escape', + description: 'Collapse or unselect message', + keyDownHandler: handlers.cancel, + hotkeyToken: TOKENS.email.cancelReply, + hide: true, + }); + }, + }} + header={ + + } + actions={} + frame={(content) => ( + + + {content()} + + + + )} + /> + ); +} + +function ThreadActions(props: { title: string; onCreateTask: () => void }) { + const context = useEmailThreadState(); + return ( + +
+ + {(id) => ( + + )} + + + + +
+
+ ); +} diff --git a/apps/web/src/features/block-email/component/BaseInput.tsx b/apps/web/src/features/block-email/component/BaseInput.tsx deleted file mode 100644 index 6603878d281..00000000000 --- a/apps/web/src/features/block-email/component/BaseInput.tsx +++ /dev/null @@ -1,2320 +0,0 @@ -import { useFeatureFlag } from '@app/lib/analytics/posthog'; -import { EmailAttachmentPill } from '@block-email/component/AttachmentPill'; -import type { DraftFormAttachment } from '@block-email/component/createEmailFormState'; -import { EmailDateSelector } from '@block-email/component/email-date-selector'; -import { MacroSignatureButton } from '@block-email/component/MacroSignatureButton'; -import { - MACRO_EMAIL_SIGNATURE, - MAX_ATTACHMENTS_BYTES_SIZE, -} from '@block-email/constants'; -import { addUserMentionToCc } from '@block-email/util/mentionToCc'; -import { useHasPaidAccess } from '@core/auth'; -import { useBlockId } from '@core/block'; -import { FileDropOverlay } from '@core/component/FileDropOverlay'; -import { buildConfig } from '@core/component/LexicalMarkdown/builder/MarkdownConfigBuilder'; -import { MarkdownShell } from '@core/component/LexicalMarkdown/builder/MarkdownShell'; -import { iosCursorScrollPlugin } from '@core/component/LexicalMarkdown/plugins/ios-cursor-scroll'; -import { setEditorStateFromHtml } from '@core/component/LexicalMarkdown/utils'; -import { - createFilesReadyHandler, - getDragDropPosition, -} from '@core/component/LexicalMarkdown/utils/fileUploadUtils'; -import type { UserMentionRecord } from '@core/component/LexicalMarkdown/utils/mentionsUtils'; -import { RecipientSelector } from '@core/component/RecipientSelector'; -import { toast } from '@core/component/Toast/Toast'; -import { - ENABLE_EMAIL_SCHEDULED_SEND, - enableEmailSignatures, - enableGraphqlSoup, - isFeatureEnabled, -} from '@core/constant/featureFlags'; -import { useEmail } from '@core/context/user'; -import { fileFolderDrop } from '@core/directive/fileFolderDrop'; -import { fileSelector } from '@core/directive/fileSelector'; -import { registerHotkey, useHotkeyDOMScope } from '@core/hotkey/hotkeys'; -import { TOKENS } from '@core/hotkey/tokens'; -import { isNativeMobilePlatform } from '@core/mobile/isNativeMobilePlatform'; -import { isTouchDevice } from '@core/mobile/isTouchDevice'; -import { useTouchOutsideToDismissKeyboard } from '@core/mobile/useTouchOutsideToDismissKeyboard'; -import { trackMention } from '@core/signal/mention'; -import { plural } from '@core/util/string'; -import { handleFileFolderDrop } from '@core/util/upload'; -import { ToggleButton as KToggleButton } from '@kobalte/core/toggle-button'; -import { $generateHtmlFromNodes } from '@lexical/html'; -import { - $appendWatermarkNodeToLast, - $removeAllWatermarkNodes, -} from '@macro-inc/lexical-core'; -import { Telemetry } from '@macro-inc/observability'; -import ChevronDown from '@phosphor/caret-down.svg'; -import CaretRight from '@phosphor/caret-right.svg'; -import DotsThree from '@phosphor/dots-three.svg'; -import Paperclip from '@phosphor/paperclip.svg'; -import Trash from '@phosphor/trash.svg'; -import ArrowCounterClockwise from '@phosphor-icons/core/regular/arrow-counter-clockwise.svg?component-solid'; -import { queryClient } from '@queries/client'; -import { - useAddForwardedAttachmentsMutation, - useRemoveDraftAttachmentMutation, - useRemoveForwardedAttachmentMutation, - useUploadDraftAttachmentsMutation, -} from '@queries/email/attachment'; -import { - useDeleteDraftMutation, - useSaveDraftMutation, -} from '@queries/email/draft'; -import { emailKeys } from '@queries/email/keys'; -import { - useEmailLinksQuery, - useEmailSignature, - useNonPrimaryEmailLinkIdHeader, - usePrimaryEmailLinkId, -} from '@queries/email/link'; -import { - fetchAndCacheThread, - useSendMessageMutation, - useUnscheduleMessageMutation, -} from '@queries/email/thread'; -import { refetchSoupEntity } from '@queries/soup/cache'; -import type { UndoHandle } from '@queries/undo'; -import { emailClient } from '@service-email/client'; -import type { - ApiDraftInput, - ApiDraftOutputDbId, - ApiMessage, -} from '@service-email/generated/schemas'; -import { isIOS } from '@solid-primitives/platform'; -import { Button, cn, Layer, SendButton, Surface, Tooltip } from '@ui'; -import { $addUpdateTag, $getRoot } from 'lexical'; -import { - type Accessor, - createEffect, - createMemo, - createSignal, - For, - type JSX, - Match, - on, - onCleanup, - onMount, - type Setter, - Show, - Switch, - untrack, -} from 'solid-js'; -import { isPersonalMessage } from '../util/isPersonalMessage'; -import { makeAttachmentPublic } from '../util/makeAttachmentPublic'; -import { getFirstName } from '../util/name'; -import { - clearEmailBody, - hasDraftContent, - prepareEmailBody, - prepareMacroBody, - registerToggleAppendedThread, - TOGGLE_APPEND_EMAIL_THREAD_COMMAND, -} from '../util/prepareEmailBody'; -import { convertEmailRecipientToContactInfo } from '../util/recipientConversion'; -import { getReplyTypeFromDraft } from '../util/replyType'; -import { - endUndoSend, - restoreDraftBodyAfterUndo, - runUndoSend, -} from '../util/undoSend'; -import { SignaturePreview } from './compose/SignaturePreview'; -import { - type EmailRecipient, - markThreadDraftSaved, - useEmailContext, -} from './EmailContext'; -import { getOrInitEmailFormContext } from './EmailFormContext'; -import { FromInboxSelector } from './FromInboxSelector'; - -false && fileFolderDrop; -false && fileSelector; - -const getRecipientDisplayName = (item: EmailRecipient): string => { - switch (item.kind) { - case 'user': - case 'contact': - return getFirstName(item.data.name) || item.data.email; - case 'custom': - return item.data.email; - } -}; - -type RecipientFieldId = 'to' | 'cc' | 'bcc'; - -function RecipientDropRow(props: { - field: RecipientFieldId; - class?: string; - children: JSX.Element; - dragState: Accessor<{ - recipient: EmailRecipient; - sourceField: RecipientFieldId; - } | null>; - onDrop: ( - targetField: RecipientFieldId, - recipient: EmailRecipient, - sourceField: RecipientFieldId - ) => void; -}) { - const [isDragOver, setIsDragOver] = createSignal(false); - - const handleDragOver = (e: DragEvent) => { - const drag = props.dragState(); - if (!drag || drag.sourceField === props.field) return; - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; - setIsDragOver(true); - }; - - const handleDragLeave = () => { - setIsDragOver(false); - }; - - const handleDrop = (e: DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - const drag = props.dragState(); - if (!drag || drag.sourceField === props.field) return; - props.onDrop(props.field, drag.recipient, drag.sourceField); - }; - - return ( -
- {props.children} -
- ); -} - -type UndoReplySnapshot = { - threadId: string; - 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: ApiDraftInput; -}; -// Set on send, persists across navigation, only consumed by undoSend. -let undoSendSnapshot: UndoReplySnapshot | null = null; -// Only set by undoSend for inline reply remount, consumed on mount. -let undoReplySnapshot: UndoReplySnapshot | null = null; -// Registered by the current BaseInput instance so stale undoSend closures -// from a previous mount can restore state into the live component. -let restoreUndoCallback: - | ((snapshot: UndoReplySnapshot, draftId: string) => void) - | null = null; - -type CreateConfiguredEmailMarkdownEditorOptions = { - namespace: string; - onChange?: (markdown: string) => void; - onUserMention?: (mention: UserMentionRecord) => void; - onDocumentMention?: (item: { id: string }) => void; - onPasteFilesAndDirs?: ( - files: FileSystemFileEntry[], - directories: FileSystemDirectoryEntry[] - ) => void; - scrollContainer?: Accessor; -}; - -function createConfiguredEmailMarkdownEditor( - options: CreateConfiguredEmailMarkdownEditorOptions -) { - const editor = buildConfig('markdown') - .namespace(options.namespace) - .withMentions({ - onUserMention: options.onUserMention, - onDocumentMention: options.onDocumentMention, - }) - .withEmojis() - .withLinks({ floatingMenu: true, autoLinkMatchMode: 'common-tlds' }) - .withHistory({ timeGap: 400 }) - .withMedia() - .withCode() - .withCheckboxToTask() - .withRestoreFocus() - .withSelectionData() - .withFloatingFormatMenu() - .use((editor) => registerToggleAppendedThread(editor)) - .onChange(options.onChange); - - if (options.onPasteFilesAndDirs) { - editor.withFilePaste({ - onPasteFilesAndDirs: options.onPasteFilesAndDirs, - }); - } - - if ((isIOS || isNativeMobilePlatform()) && options.scrollContainer) { - editor.use( - iosCursorScrollPlugin({ scrollContainer: options.scrollContainer }) - ); - } - - return editor; -} - -export function BaseInput(props: { - replyingTo: Accessor; - // TODO: Remove `newMessage` props. It's not used... - newMessage?: boolean; - isEditingExisting?: boolean; - draft?: ApiMessage; - preloadedBody?: string; - 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 EmailInput'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: ApiDraftOutputDbId | null) => void; - onMarkDone?: (opts?: { - silent?: boolean; - onUndoHandle?: (handle: UndoHandle) => void; - nextEntityId?: string; - }) => void; - setShowReply?: Setter; - markdownDomRef?: (ref: HTMLDivElement) => void | HTMLDivElement; - unframed?: boolean; - mobileDrawer?: { - onClose: () => void; - }; -}) { - const ctx = useEmailContext(); - const form = createMemo(() => { - const replyingTo = props.replyingTo(); - - // If neither `replyingTo` or `draft` exist, we'll have an empty - // initial state - if (!replyingTo && !props.draft) { - return getOrInitEmailFormContext(); - } - - // If we have `replyingTo`, we're going to be - // creating a reply to a message so we can derive our state - // from the `replyingTo` and a possible existing draft - if (replyingTo && replyingTo.db_id) { - return getOrInitEmailFormContext({ - type: 'replying_to', - messageID: replyingTo.db_id, - seed: props.formSeed, - }); - } - - // If we only have the draft available, then we're most likely - // editing a draft in a new thread with no other messages - if (props.draft && props.draft.db_id) { - return getOrInitEmailFormContext({ - type: 'draft', - messageID: props.draft.db_id, - seed: props.formSeed, - }); - } - - // Fallback to empty state - return getOrInitEmailFormContext(); - }); - const blockId = useBlockId(); - const emailLinksQuery = useEmailLinksQuery(); - const userEmail = useEmail(); - - const toHeaderLinkId = useNonPrimaryEmailLinkIdHeader(); - const primaryLinkId = usePrimaryEmailLinkId(); - // The inbox this input acts in: the open thread's inbox, else the primary - // inbox for a new message. Mutations send it as X-Email-Link-Id when it's a - // non-primary inbox so the draft/send targets the right account. - const activeLinkId = () => - form().selectedLinkId() ?? - ctx.thread()?.link_id ?? - props.draft?.link_id ?? - primaryLinkId() ?? - emailLinksQuery.data?.links[0]?.id; - const headerLinkId = () => toHeaderLinkId(activeLinkId()); - // The address of the inbox this input sends from, for the "from" display. - const activeInboxEmail = () => - emailLinksQuery.data?.links.find((l) => l.id === activeLinkId()) - ?.email_address ?? userEmail(); - - // The full Link object for the sending inbox (for its saved signature and the - // "add to replies & forwards" preference). - const sendingLink = createMemo(() => - emailLinksQuery.data?.links.find((l) => l.id === activeLinkId()) - ); - const signature = useEmailSignature(activeLinkId); - const emailSignaturesFlag = useFeatureFlag(enableEmailSignatures); - // 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 => - emailSignaturesFlag().enabled && - props.replyingTo() && - includeSignature() && - sendingLink()?.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 [showExpandedRecipients, setShowExpandedRecipients] = - createSignal(false); - const [isDragging, setIsDragging] = createSignal(); - 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); - // A pending undo-send restore that belongs to this thread (inline reply - // remount case). It carries a just-undone send. Consumed below. - const restoredSnapshot = - undoReplySnapshot?.threadId === ctx.thread()?.db_id - ? undoReplySnapshot - : null; - - // The draft row this composer upserts into: the server draft when one - // exists, else the one the undone send restores. - const [savedDraftId, setSavedDraftId] = createSignal< - ApiDraftOutputDbId | undefined - >(props.draft?.db_id ?? restoredSnapshot?.draftId ?? undefined); - - const editorConfig = createConfiguredEmailMarkdownEditor({ - namespace: 'email-base-input-markdown', - scrollContainer, - onChange: (markdown) => handleChange(markdown), - onUserMention: (mention) => handleUserMention(mention), - onDocumentMention: (item) => { - makeAttachmentPublic(item.id); - scheduleDraftSave(); - }, - onPasteFilesAndDirs: (files, directories) => { - handleFileFolderDrop( - files, - directories, - createFilesReadyHandler( - editor(), - blockId, - 'email', - undefined, - (uploadedItemIds) => { - uploadedItemIds.forEach((itemId) => { - makeAttachmentPublic(itemId); - }); - scheduleDraftSave(); - }, - { width: 542, height: 542 } - ) - ); - }, - }); - - const markdownHandle = editorConfig.buildHandle(); - const editor = () => markdownHandle.lexical; - - // 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. - if (restoredSnapshot) { - undoReplySnapshot = null; - 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. - restoreUndoCallback = (snapshot, draftId) => { - props.onEngaged?.(); - setSavedDraftId(draftId); - 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); - }; - onCleanup(() => { - restoreUndoCallback = null; - }); - - const initialHtml = () => restoredSnapshot?.bodyHtml ?? props.preloadedHtml; - const handleEditorConnect = () => { - const currentEditor = editor(); - form().setCapturedEditor(currentEditor); - const html = initialHtml(); - if (html) { - // Restore content without letting selection reconciliation grab focus - currentEditor.update(() => { - $addUpdateTag('skip-dom-selection'); - setEditorStateFromHtml(currentEditor, html, true); - }); - } - }; - - let pendingMentions: { documentId: string }[] = []; - const [shouldMarkDoneOnSuccess, setShouldMarkDoneOnSuccess] = - createSignal(false); - // Undo entry for the mark-done triggered by the latest send, so undo-send - // can reverse it. Cleared on each send: undo-send must only un-mark-done - // when this send did the marking. - let markDoneUndoHandle: UndoHandle | undefined; - let pendingMarkDoneNavigationTargetId: string | undefined; - - // 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, and reverse the send's mark-done. - const restoreAfterUndoSend = async ( - draftId: string, - linkId: string | undefined - ) => { - let snapshot: UndoReplySnapshot | null = null; - if (undoSendSnapshot?.draftId === draftId) { - snapshot = undoSendSnapshot; - undoSendSnapshot = null; - } - - // Remove the sent message from the thread cache so it disappears from - // the list. Prefer the snapshot's threadId — captured at send time, it - // survives navigation — while the context read covers snapshotless undos - // in a still-mounted thread. - const threadId = snapshot?.threadId ?? ctx.thread()?.db_id; - if (threadId && !isFeatureEnabled(enableGraphqlSoup)) { - queryClient.setQueryData( - emailKeys.threadMessages(threadId).queryKey, - (old: any) => { - if (!old?.pages) return old; - return { - ...old, - pages: old.pages.map((page: any) => ({ - ...page, - messages: page.messages.filter((m: any) => m.db_id !== draftId), - })), - }; - } - ); - // Wipe the thread cache on unmount so the next visit fetches fresh - // data (with the restored draft). Deferred to avoid Suspense DOM - // detach while the thread is open. - markThreadDraftSaved(threadId); - } - - // Overwrite the server-side draft with the pre-send content before - // anything loads it into a composer (thread revisit, refetch, next - // session). - if (snapshot) { - await restoreDraftBodyAfterUndo( - snapshot.draftRestore, - snapshot.bodyHtml, - linkId - ); - } - - // GraphQL mode renders the thread from the normalized cache, which the - // setQueryData surgery above can't reach — refetch through it instead. - // After the draft-body restore, so the single fetch returns the message - // as a draft with the pre-send content, dropping it from the message - // list and re-seeding the draft map in one pass. - if (threadId && isFeatureEnabled(enableGraphqlSoup)) { - void fetchAndCacheThread(threadId); - } - - if (snapshot && restoreUndoCallback) { - // A live BaseInput is mounted — restore after reactive updates from - // setQueryData have settled (form may have re-keyed). - const cb = restoreUndoCallback; - setTimeout(() => cb(snapshot, draftId), 0); - } else if (snapshot) { - // No live component (e.g. inline reply was unmounted). - // Stash for mount-time restore. - undoReplySnapshot = snapshot; - props.setShowReply?.(true); - } - - // Reverse the mark-done this send triggered (restores the soup rows, - // notification state, and unarchives), then refresh the thread's soup - // item the same way a send does so inbox views show the restored draft. - const doneHandle = markDoneUndoHandle; - markDoneUndoHandle = undefined; - if (doneHandle) { - await doneHandle.undo({ - onError: () => toast.failure('Failed to restore thread to inbox'), - }); - } - if (threadId) { - void refetchSoupEntity(threadId, 'emailThread'); - } - }; - - // linkId is the X-Email-Link-Id header value the send itself used, resolved - // at send time. Undo can fire after navigation has disposed this component's - // reactive state (mark-done navigates away). - const undoSend = (draftId: string, linkId: string | undefined) => - runUndoSend({ - draftId, - linkId, - onUndone: () => restoreAfterUndoSend(draftId, linkId), - }); - - const sendMutation = useSendMessageMutation({ - onSuccess: async ({ message }, vars) => { - // Cancel the post-reset save scheduled by sendEmail's resetState() and - // re-enable autosave for any future edits in this BaseInput instance - // (covers new-message flows where replyingTo never changes). - if (draftSaveTimer) window.clearTimeout(draftSaveTimer); - pendingSend = false; - const draftId = message.db_id; - // This send opens a fresh undo cycle for the draft id. - if (draftId) endUndoSend(draftId); - const sendLinkId = vars.linkId; - const toastId = toast.success('Email sent', { - actions: draftId - ? [ - { - label: 'Undo', - icon: ArrowCounterClockwise, - onClick: () => { - if (toastId != null) toast.dismiss(toastId); - void undoSend(draftId, sendLinkId); - }, - }, - ] - : undefined, - duration: 5_000, - }); - pendingMentions.forEach((mention) => { - trackMention(blockId, 'document', mention.documentId); - }); - pendingMentions = []; - refetchThreadMessages(); - props.sideEffectOnSend?.(message.db_id ?? null); - if (shouldMarkDoneOnSuccess()) { - // Silent: the "Email sent" toast is already up and the mark-done - // toast would replace it. - props.onMarkDone?.({ - silent: true, - onUndoHandle: (handle) => { - markDoneUndoHandle = handle; - }, - nextEntityId: pendingMarkDoneNavigationTargetId, - }); - pendingMarkDoneNavigationTargetId = undefined; - setShouldMarkDoneOnSuccess(false); - } - }, - onError: () => { - // Restore autosave so the user can keep editing after a failed send. - if (draftSaveTimer) window.clearTimeout(draftSaveTimer); - pendingSend = false; - pendingMarkDoneNavigationTargetId = undefined; - toast.failure('Failed to send email'); - }, - }); - - const uploadAttachmentMutation = useUploadDraftAttachmentsMutation(); - const addForwardedAttachmentsMutation = useAddForwardedAttachmentsMutation(); - const saveDraftMutation = useSaveDraftMutation(); - const deleteDraftMutation = useDeleteDraftMutation(); - - function refetchThreadMessages() { - const threadId = ctx.thread()?.db_id; - if (threadId) { - markThreadDraftSaved(threadId); - } - } - - // Lexical setup after the quote append (decorator mounts, mutation flushes) - // keeps flushing stale selections into the editor, yanking focus out of the - // To field. While armed, bounce those grabs back to To; any deliberate user - // interaction (pointer, Tab/Escape, focus leaving the composer) disarms it. - let bounceEditorFocusGrabs = false; - - onMount(() => { - const disarm = () => { - bounceEditorFocusGrabs = false; - }; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Tab' || e.key === 'Escape') disarm(); - }; - const onFocusIn = (e: FocusEvent) => { - if (!bounceEditorFocusGrabs) return; - const target = e.target as Node; - if (!composeContainerRef?.contains(target)) { - disarm(); - return; - } - if (scrollContainer()?.contains(target)) { - toRef()?.focus(); - } - }; - document.addEventListener('pointerdown', disarm, true); - document.addEventListener('keydown', onKeyDown, true); - document.addEventListener('focusin', onFocusIn, true); - onCleanup(() => { - document.removeEventListener('pointerdown', disarm, true); - document.removeEventListener('keydown', onKeyDown, true); - document.removeEventListener('focusin', onFocusIn, true); - }); - }); - - const focusForwardRecipients = () => { - setShowExpandedRecipients(true); - setTimeout(() => { - if (toRef()) { - bounceEditorFocusGrabs = true; - toRef()?.focus(); - } - // After the quoted thread is appended, keep the send bar in view - bottomBarRef?.scrollIntoView({ block: 'nearest' }); - }, 100); - }; - - // Attach side-effect handlers on mount; they replay against current state - onMount(() => { - form().setOnDirty(() => { - scheduleDraftSave(); - }); - - form().setOnReplyTypeApplied((rt) => { - setComposerExpanded(false); - if (rt === 'forward') { - setQuoteCollapsed(true); - focusForwardRecipients(); - } else if (rt === 'reply' || rt === 'reply-all') { - setTimeout(() => { - editor()?.focus(); - bottomBarRef?.scrollIntoView({ block: 'nearest' }); - }, 100); - } - }); - }); - - const effectiveReplyType = createMemo(() => { - return ( - form().replyType() ?? - getReplyTypeFromDraft(props.draft) ?? - ((props.replyingTo()?.to.length ?? 0) + - (props.replyingTo()?.cc.length ?? 0) > - 1 - ? 'reply-all' - : 'reply') - ); - }); - - let draftSaveTimer: number | undefined; - let pendingDeletion = false; - let pendingSend = false; - const DRAFT_DEBOUNCE_MS = 500; - - function collectDraft() { - $removeAllWatermarkNodes(editor()); - const prepared = prepareEmailBody(editor()); - if (!prepared) { - Telemetry.error( - 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: props.draft?.provider_id, - replying_to_id: props.replyingTo()?.db_id, - subject: form().subject(), - to: form().recipients().to.map(convertEmailRecipientToContactInfo), - }; - } - - // Content uploads still in flight, including ones started by earlier saves. - // attachmentID only proves the draft record exists, and the send path treats - // a resolved save as "attachments ready", so a save must not resolve while - // any of these are pending. - const inFlightAttachmentUploads = new Set>(); - - async function executeSaveDraft(skipSoupRefetch = false) { - if (sendMutation.isPending || pendingDeletion || pendingSend) { - return; - } - const draftToSave = collectDraft(); - if (!draftToSave) { - const draftId = savedDraftId(); - if (draftId) { - await deleteDraftMutation.mutateAsync({ - draftId, - threadId: ctx.thread()?.db_id, - linkId: headerLinkId(), - skipSoupRefetch, - }); - refetchThreadMessages(); - } - setSavedDraftId(undefined); - return; - } - const currentThread = ctx.thread(); - const newMessage = props.newMessage ?? false; - - if (!currentThread && !newMessage) { - Telemetry.error(new Error('Failed to save draft: thread not found')); - return; - } - - if (newMessage && currentThread) { - Telemetry.error( - new Error( - 'Failed to save draft: new message and current thread cannot be provided together' - ) - ); - return; - } - - const draftResponse = await saveDraftMutation.mutateAsync({ - draft: { - ...draftToSave, - db_id: savedDraftId(), - provider_thread_id: currentThread?.provider_id, - thread_db_id: currentThread?.db_id, - }, - linkId: headerLinkId(), - skipSoupRefetch, - }); - - const draftId = draftResponse.draft.db_id; - if (draftId) { - // If the email draft saved successfully, we want to upload the - // attachments as well. We should grab only the attachments that - // haven't been uploaded yet - const attachments = form() - .attachments.list() - .filter((a) => a.type === 'local' && !a.attachmentID) as Extract< - DraftFormAttachment, - { type: 'local' } - >[]; - - let uploadRun: Promise | undefined; - if (attachments.length) { - uploadRun = uploadAttachmentMutation.mutateAsync({ - draftID: draftId, - attachments: attachments.map((a) => a.file), - linkId: headerLinkId(), - onAttachmentAdded: (file, attachmentID) => - form().attachments.assignAttachmentID(file, attachmentID), - onAttachmentUploadFailed: (file) => - form().attachments.clearAttachmentID(file), - }); - const tracked = uploadRun.then( - () => undefined, - () => undefined - ); - inFlightAttachmentUploads.add(tracked); - tracked.then(() => inFlightAttachmentUploads.delete(tracked)); - } - - while (inFlightAttachmentUploads.size) { - await Promise.all([...inFlightAttachmentUploads]); - } - // Settled by the drain above, this only rethrows this save's own failure - if (uploadRun) await uploadRun; - - // Sync forwarded attachments - const forwardedAttachments = form() - .attachments.list() - .filter((a) => a.type === 'forwarded') as Extract< - DraftFormAttachment, - { type: 'forwarded' } - >[]; - - if (forwardedAttachments.length) { - await addForwardedAttachmentsMutation.mutateAsync({ - draftID: draftId, - attachments: forwardedAttachments.map((a) => ({ - attachmentID: a.attachmentID, - })), - linkId: headerLinkId(), - }); - } - - setSavedDraftId(draftId); - refetchThreadMessages(); - return draftId; - } - } - - // The reply target the pending debounced save was scheduled against. - // Captured at schedule time — a live interactive context — because the - // unmount flush below cannot trust props during disposal. - let pendingSaveReplyingToId: string | undefined; - - function scheduleDraftSave() { - props.onEngaged?.(); - pendingSaveReplyingToId = untrack(() => props.replyingTo()?.db_id); - if (draftSaveTimer) window.clearTimeout(draftSaveTimer); - draftSaveTimer = window.setTimeout(() => { - draftSaveTimer = undefined; - void executeSaveDraft(); - }, DRAFT_DEBOUNCE_MS); - } - - onCleanup(() => { - const flushPending = draftSaveTimer !== undefined; - if (draftSaveTimer) { - window.clearTimeout(draftSaveTimer); - draftSaveTimer = undefined; - } - // A send or discard already owns this composer's state; saving here - // would resurrect content those flows just cleared. - if (pendingSend || pendingDeletion) return; - - // Flush the pending debounced save so dismissal doesn't drop the last - // edits server-side; a failure surfaces through the mutation's toast. - if (flushPending) { - try { - // Only while the reply target still reads as the one the save was - // scheduled against — mid-disposal it can come back empty or stale, - // and a save without replying_to_id would unlink the server draft - // from its message. - if ( - untrack(() => props.replyingTo()?.db_id) === pendingSaveReplyingToId - ) { - // The mutation's own onError reports the failure (toast + console); - // this catch only keeps the post-disposal rejection from surfacing - // as unhandled. - executeSaveDraft().catch(() => {}); - } - } catch { - // Props already disposed; the next mount's autosave persists it. - } - } - }); - - // 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 = (linkId: string) => { - props.onEngaged?.(); - form().setSelectedFromLink(linkId); - if (draftSaveTimer) window.clearTimeout(draftSaveTimer); - void executeSaveDraft(); - }; - - // After a send, the bottom input stays mounted and its replyingTo flips to - // the just-sent message once the thread refetches. Cancel the inhibited - // post-send save and re-enable saves so a fresh edit under the new form - // context can be persisted. The memo gates on the db_id *value*: replyingTo - // is recreated on every thread/draft refetch (e.g. after a debounced draft - // save), and resetting on those would resurrect a dismissed signature. - const replyingToDbId = createMemo(() => props.replyingTo()?.db_id); - createEffect( - on( - replyingToDbId, - () => { - if (draftSaveTimer) window.clearTimeout(draftSaveTimer); - pendingSend = false; - // Each new reply starts with the signature included again. - setIncludeSignature(true); - }, - { defer: true } - ) - ); - - createEffect(() => { - const requestMessageId = ctx.replyRequest.messageId(); - const requestReplyType = ctx.replyRequest.replyType(); - const currentMessageId = replyingToDbId(); - - if ( - !requestMessageId || - !requestReplyType || - requestMessageId !== currentMessageId - ) { - 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 - focusForwardRecipients(); - } - // Forwards focus the To field; focusing the editor would steal it back - if (requestReplyType !== 'forward') { - form().setShouldFocusInput(true); - } - ctx.replyRequest.clear(); - }); - - const handleChipDragStart = ( - field: 'to' | 'cc' | 'bcc', - recipient: EmailRecipient, - e: DragEvent - ) => { - 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' - ) => { - const sourceList = form().recipients()[sourceField]; - form().setRecipients( - sourceField, - sourceList.filter((r) => r.id !== recipient.id) - ); - const targetList = form().recipients()[targetField]; - if (!targetList.some((r) => r.id === recipient.id)) { - form().setRecipients(targetField, [...targetList, recipient]); - } - if (targetField === 'cc') setShowCc(true); - if (targetField === 'bcc') setShowBcc(true); - scheduleDraftSave(); - }; - - const withDraftSave = - (setter: (v: T) => void) => - (v: T) => { - setter(v); - scheduleDraftSave(); - }; - - // 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); - }; - - // 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 as Node | null; - if (!target) return; - const combobox = document.querySelector('div[data-popper-positioner]'); - if ( - !composeContainerRef?.contains(target) && - !combobox?.contains(target) - ) { - setShowExpandedRecipients(false); - setShowCc(form().recipients().cc.length > 0); - setShowBcc(form().recipients().bcc.length > 0); - } - } - }; - - onMount(() => { - document.addEventListener('pointerdown', expandedPointerDownHandler); - - onCleanup(() => { - document.removeEventListener('pointerdown', expandedPointerDownHandler); - }); - }); - - const hasPaidAccess = useHasPaidAccess(); - - // Set up hotkey scope for the compose message component - const [attachComposeHotkeys, composeHotkeyScope] = - useHotkeyDOMScope('compose-message'); - let composeContainerRef: HTMLDivElement | undefined; - let bottomBarRef: HTMLDivElement | undefined; - useTouchOutsideToDismissKeyboard(() => composeContainerRef); - - const sendEmail = async (markDone = false) => { - if (sendMutation.isPending || uploadAttachmentMutation.isPending) 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) { - toast.failure('Email failed to send. No recipients provided'); - return; - } - - const currentThread = ctx.thread(); - const newMessage = props.newMessage ?? false; - - if (!currentThread && !newMessage) { - Telemetry.error(new Error("Can't send email, no email thread found")); - toast.failure('Email failed to send'); - return; - } - - if (newMessage && currentThread) { - toast.failure('Email failed to send'); - Telemetry.error('New message and thread cannot be provided together'); - return; - } - - let linkId: string | undefined = currentThread?.link_id; - if (newMessage || !linkId) { - if (emailLinksQuery.isPending) { - toast.alert('Loading email accounts...'); - return; - } - - if (emailLinksQuery.isError) { - toast.failure('Email failed to send: Could not load email accounts'); - Telemetry.error('Failed to load email links'); - return; - } - - const linksData = emailLinksQuery.data; - if (!linksData || linksData.links.length < 1) { - toast.failure('Email failed to send: No email account connected'); - Telemetry.error('No links found'); - return; - } - linkId = primaryLinkId() ?? linksData.links[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 ?? false); - pendingMarkDoneNavigationTargetId = willMarkDone - ? ctx.getMarkDoneNavigationTargetId() - : undefined; - - // Ensure draft is saved before sending so undo-send always has a draft to restore - if (draftSaveTimer) window.clearTimeout(draftSaveTimer); - await executeSaveDraft(willMarkDone); - - // Snapshot editor state before watermark so undo-send can restore it. - // Stored in undoSendSnapshot (not undoReplySnapshot) so it persists across - // navigation but isn't mistakenly auto-restored on next mount. - if (currentEditor) { - const snapshotHtml = currentEditor.read(() => - $generateHtmlFromNodes(currentEditor) - ); - const snapshotDraftId = savedDraftId(); - const snapshotThreadId = ctx.thread()?.db_id; - if (snapshotDraftId && snapshotThreadId) { - undoSendSnapshot = { - threadId: snapshotThreadId, - draftId: snapshotDraftId, - bodyHtml: snapshotHtml, - attachments: [...form().attachments.list()], - includeSignature: includeSignature(), - replyAppended: form().replyAppended(), - draftRestore: { - bcc, - cc, - db_id: snapshotDraftId, - provider_id: props.draft?.provider_id, - provider_thread_id: currentThread?.provider_id, - replying_to_id: props.replyingTo()?.db_id, - subject: form().subject(), - thread_db_id: currentThread?.db_id, - to, - }, - }; - } - } - - // Failsafe: don't send if a scheduled send time is set - if (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 = props.replyingTo(); - - const prepared = prepareEmailBody( - currentEditor, - replyingTo - ? { - replyType: effectiveReplyType(), - replyingTo, - } - : undefined - ); - if (!prepared) { - cleanupWatermark(); - return; - } - - pendingMentions = prepared.mentions; - setShouldMarkDoneOnSuccess(willMarkDone); - markDoneUndoHandle = undefined; - - const processedMacroBody = prepareMacroBody(bodyMacro()); - - const currentDraftID = savedDraftId(); - - sendMutation.mutate({ - message: { - db_id: currentDraftID, - bcc, - body_html: prepared.bodyHtml, - body_macro: processedMacroBody, - body_text: prepared.bodyText, - cc, - provider_id: props.draft?.provider_id, - provider_thread_id: currentThread?.provider_id, - replying_to_id: props.replyingTo()?.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, - }, - linkId: toHeaderLinkId(linkId), - skipSoupRefetch: willMarkDone, - }); - - // Block any save scheduled by reset side effects (form().reset() callDirty, - // clearEmailBody editor onChange firing on a microtask). Without this, the - // 500ms timer fires after the thread refetches, the form memo switches to - // the just-sent message's reply context, and we POST an empty draft - // replying to the message we just sent — flipping it back to is_draft=TRUE. - pendingSend = true; - resetState(); - clearDraftState(); - - cleanupWatermark(); - }; - - const resetState = () => { - clearEmailBody(editor()); - setBodyMacro(''); - setSavedDraftId(undefined); - form().reset(); - }; - - const clearDraftState = () => { - const replyingToId = props.replyingTo()?.db_id; - if (replyingToId) { - ctx.drafts.deleteDraftForMessage(replyingToId); - } - props.setShowReply?.(false); - }; - - const deleteDraftAndReset = async () => { - // Block any save scheduled by resetState's side effects (sync form.reset - // callDirty + async editor onChange listener). When clearDraftState() has - // a setShowReply, the BaseInput unmounts and the flag goes away with it; - // when it doesn't (e.g. the bottom-of-thread input), the component stays - // mounted and we must restore the flag so subsequent edits can autosave. - pendingDeletion = true; - if (draftSaveTimer) window.clearTimeout(draftSaveTimer); - const draftId = savedDraftId(); - try { - if (draftId) { - await deleteDraftMutation.mutateAsync({ - draftId, - threadId: ctx.thread()?.db_id, - linkId: headerLinkId(), - }); - refetchThreadMessages(); - } - 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(() => { - if (draftSaveTimer) window.clearTimeout(draftSaveTimer); - pendingDeletion = false; - }, 0); - } - }; - - const handleUserMention = (mention: UserMentionRecord) => { - addUserMentionToCc({ - mention, - recipientOptions: ctx.recipientOptions(), - toRecipients: form().recipients().to, - ccRecipients: form().recipients().cc, - bccRecipients: form().recipients().bcc, - setCc: (next) => form().setRecipients('cc', next), - }); - }; - - onMount(() => { - if (composeContainerRef) { - attachComposeHotkeys(composeContainerRef); - - registerHotkey({ - hotkey: 'cmd+enter', - scopeId: composeHotkeyScope, - description: 'Send email', - keyDownHandler: () => { - if (form().sendTime()) return false; - sendEmail(); - return true; - }, - runWithInputFocused: true, - hotkeyToken: TOKENS.email.send, - displayPriority: 9, - }); - - registerHotkey({ - hotkey: 'shift+cmd+enter', - scopeId: composeHotkeyScope, - description: 'Send and mark done', - keyDownHandler: () => { - if (form().sendTime()) return false; - sendEmail(true); - return true; - }, - runWithInputFocused: true, - hotkeyToken: TOKENS.email.sendAndMarkDone, - displayPriority: 10, - }); - - registerHotkey({ - hotkey: 'arrowup', - scopeId: composeHotkeyScope, - description: 'Select last message', - runWithInputFocused: true, - condition: () => { - const ed = editor(); - if (!ed) return false; - const rootEl = ed.getRootElement(); - if (!rootEl || !rootEl.contains(document.activeElement)) return false; - return ed.read(() => { - const text = $getRoot().getTextContent(); - return text.trim().length === 0; - }); - }, - keyDownHandler: () => { - const messages = ctx.messages.list(); - if (!messages?.length) return false; - const lastMsg = messages[messages.length - 1]; - if (!lastMsg?.db_id) return false; - editor()?.blur(); - ctx.messages.setFocused(lastMsg.db_id); - const msgEl = document.querySelector( - `[data-message-body-id="${lastMsg.db_id}"]` - ) as HTMLElement | null; - const focusable = msgEl?.closest( - '[tabindex="0"]' - ) as HTMLElement | null; - focusable?.focus(); - return true; - }, - hotkeyToken: TOKENS.email.previousMessage, - }); - - registerHotkey({ - hotkey: 'escape', - scopeId: composeHotkeyScope, - description: 'Close reply', - keyDownHandler: () => { - const draft = collectDraft(); - const isEmpty = draft === null; - - if (isEmpty) { - // Delete draft and close reply - deleteDraftAndReset(); - } else { - // Move focus back to the message - const focusedId = ctx.messages.focusedID(); - if (focusedId) { - const messageEl = document.querySelector( - `[data-message-body-id="${focusedId}"]` - ) as HTMLElement | null; - messageEl?.focus(); - } - } - return true; - }, - // Let editable fields handle Escape before closing the reply. - runWithInputFocused: false, - hotkeyToken: TOKENS.email.cancelReply, - displayPriority: 8, - }); - } - }); - - // Focus when external shouldFocus signal is set to true. The builder creates - // the Lexical editor immediately; requestAnimationFrame waits for the root to - // connect before focusing. - createEffect(() => { - if (!form().shouldFocusInput()) return; - if (isTouchDevice()) { - form().setShouldFocusInput(false); - return; - } - // Forwards focus the To field; a stale flag consumed here after the - // editor mounts would move the caret into the editor body instead. - if (effectiveReplyType() === 'forward') { - form().setShouldFocusInput(false); - return; - } - const ed = editor(); - if (!ed) return; - requestAnimationFrame(() => { - ed.focus(); - form().setShouldFocusInput(false); - }); - }); - - 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) { - toast.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 - ) { - toast.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 removeAttachmentMutation = useRemoveDraftAttachmentMutation(); - const removeForwardedAttachmentMutation = - useRemoveForwardedAttachmentMutation(); - - const handleRemoveAttachment = (attachment: DraftFormAttachment) => { - if (attachment.type === 'local') { - form().attachments.removeByFile(attachment.file); - } else if (attachment.type === 'forwarded') { - form().attachments.removeForwarded(attachment.attachmentID); - } else { - form().attachments.removeByID(attachment.attachmentID); - } - - const currentDraftID = savedDraftId(); - - if (!currentDraftID || !attachment.attachmentID) return; - - if (attachment.type === 'forwarded') { - removeForwardedAttachmentMutation.mutate({ - draftID: currentDraftID, - attachmentID: attachment.attachmentID, - linkId: headerLinkId(), - }); - } else { - removeAttachmentMutation.mutate({ - draftID: currentDraftID, - attachmentID: attachment.attachmentID, - linkId: headerLinkId(), - }); - } - }; - - const unscheduleMessageMutation = useUnscheduleMessageMutation({ - onSuccess: () => { - toast.success('Email unscheduled'); - }, - onError: () => { - toast.failure('Failed to unschedule email'); - }, - }); - - const handleSendTimeChange = async (date: Date | null) => { - const currentSendTime = form().sendTime(); - const currentDraft = savedDraftId(); - - // If we unset the send time, we need to unschedule the message - if (!date && currentSendTime && currentDraft) { - unscheduleMessageMutation.mutate({ - draftID: currentDraft, - linkId: headerLinkId(), - }); - form().setSendTime(date); - return; - } - - form().setSendTime(date); - - if (date) { - // Ensure draft is saved before scheduling - const draftID = currentDraft ?? (await executeSaveDraft()); - if (!draftID) { - toast.failure('Failed to schedule message', { - subtext: 'Draft required', - }); - return; - } - - await emailClient.scheduleMessage( - { - draftID, - send_time: date.toISOString(), - }, - headerLinkId() - ); - - // Mark the thread as done - const threadID = ctx.thread()?.db_id; - if (threadID) { - await emailClient.flagArchived( - { id: threadID, value: true }, - headerLinkId() - ); - } - } - }; - - // Unschedule when all recipients are removed - const totalRecipientCount = () => { - const r = form().recipients(); - return r.to.length + r.cc.length + r.bcc.length; - }; - createEffect( - on( - totalRecipientCount, - (count) => { - if (count === 0 && form().sendTime()) { - handleSendTimeChange(null); - } - }, - { defer: true } - ) - ); - - const hasBodyText = () => bodyMacro().trim().length > 0; - const isMobileDrawer = () => props.mobileDrawer !== undefined; - const composePortalScope = () => (isMobileDrawer() ? 'local' : undefined); - const sendActionHidden = () => - isTouchDevice() && - !hasBodyText() && - // Forwards carry the quoted thread as content, so send is available without typing anything. - effectiveReplyType() !== 'forward'; - const sendActionDisabled = () => - uploadAttachmentMutation.isPending || - sendMutation.isPending || - !!form().sendTime(); - const scheduleSendDisabled = () => - form().recipients().to.length === 0 && - form().recipients().cc.length === 0 && - form().recipients().bcc.length === 0; - const scrollAreaSignatureHtml = () => - isMobileDrawer() ? replySignatureHtml() : undefined; - const footerSignatureHtml = () => - isMobileDrawer() ? undefined : replySignatureHtml(); - const replyingToSummary = () => { - const recipients = [ - ...form().recipients().to, - ...form().recipients().cc, - ...form().recipients().bcc, - ]; - const firstRecipient = recipients[0]; - const action = - effectiveReplyType() === 'forward' ? 'Forwarding' : 'Replying to'; - if (!firstRecipient) return action; - - const remainingCount = recipients.length - 1; - const suffix = remainingCount > 0 ? ` + ${remainingCount}` : ''; - return `${action} ${getRecipientDisplayName(firstRecipient)}${suffix}`; - }; - const mobileRecipientSelectorClass = - 'min-w-0 flex-1 bg-transparent rounded-none! [&_input]:ml-0! [&_input]:min-w-0! [&_input]:text-[17px] [&_input]:leading-6 [&_input]:text-ink [&_input]:placeholder:text-ink-placeholder'; - const mobileDrawerRowClass = - 'w-full gap-2 min-h-16 border-b border-edge-muted/70 focus-within:border-accent'; - const mobileDrawerCcBccOpen = () => - !!showCc() || - !!showBcc() || - form().recipients().cc.length > 0 || - form().recipients().bcc.length > 0; - const toggleMobileDrawerCcBcc = () => { - const next = !mobileDrawerCcBccOpen(); - setShowCc(next); - setShowBcc(next); - }; - - const toggleQuotedText = () => { - const replyingTo = props.replyingTo(); - 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: effectiveReplyType(), - visible: !currentlyAppended, - isPersonal: isPersonalMessage( - replyingTo, - userEmail(), - ctx.messages.personalSenders() - ), - }); - - editor()?.update(() => { - $getRoot().getFirstChild()?.selectStart(); - }); - }; - - const AttachmentsRow = (rowProps?: { class?: string }) => ( - 0}> -
- - {(attachment) => ( - - - {(attachment) => ( - handleRemoveAttachment(attachment())} - /> - )} - - - {(attachment) => ( - handleRemoveAttachment(attachment())} - /> - )} - - - {(attachment) => ( - handleRemoveAttachment(attachment())} - /> - )} - - - )} - -
-
- ); - - const AttachButton = (buttonProps?: { - variant?: 'ghost' | 'outline'; - class?: string; - }) => ( - - ); - - return ( - { - composeContainerRef = el; - }} - depth={2} - solid - > - - -
-
- -
-
- - sendEmail()} - /> -
-
-
-
- -
- - -
- } - > -
-
-
-
- From -
- -
-
- - - - - - -
-
- - -
- To -
- - openOnFocus={false} - class="min-w-0 bg-transparent rounded-none! [&_input]:ml-0!" - inputRef={setToRef} - options={ctx.recipientOptions} - selfEmail={activeInboxEmail()} - selectedOptions={form().recipients().to} - setSelectedOptions={withDraftSave((v) => - form().setRecipients('to', v) - )} - triggerMode="input" - hideBorder - noPadding - onChipDragStart={(option, e) => - handleChipDragStart('to', option, e) - } - onChipDragEnd={handleChipDragEnd} - hideMenuOnEscape - /> -
- {/* Expanded CC */} - 0}> - -
- Cc -
- - openOnFocus={false} - class="min-w-0 bg-transparent rounded-none! [&_input]:ml-0!" - inputRef={setCcRef} - options={ctx.recipientOptions} - selfEmail={activeInboxEmail()} - selectedOptions={form().recipients().cc} - setSelectedOptions={withDraftSave((v) => - form().setRecipients('cc', v) - )} - triggerMode="input" - hideBorder - noPadding - onChipDragStart={(option, e) => - handleChipDragStart('cc', option, e) - } - onChipDragEnd={handleChipDragEnd} - hideMenuOnEscape - /> -
-
- {/* Expanded BCC */} - 0}> - -
- Bcc -
- - openOnFocus={false} - class="min-w-0 bg-transparent rounded-none! [&_input]:ml-0!" - inputRef={setBccRef} - options={ctx.recipientOptions} - selfEmail={activeInboxEmail()} - selectedOptions={form().recipients().bcc} - setSelectedOptions={withDraftSave((v) => - form().setRecipients('bcc', v) - )} - triggerMode="input" - hideBorder - noPadding - onChipDragStart={(option, e) => - handleChipDragStart('bcc', option, e) - } - onChipDragEnd={handleChipDragEnd} - hideMenuOnEscape - /> -
-
-
-
- -
-
Subject
- { - form().setSubject(e.currentTarget.value); - scheduleDraftSave(); - }} - onKeyDown={(e) => { - if (e.key !== 'Escape') return; - e.preventDefault(); - e.currentTarget.blur(); - }} - placeholder="Subject" - /> -
- - } - > -
- -
To:
- - openOnFocus={false} - class={mobileRecipientSelectorClass} - inputRef={setToRef} - options={ctx.recipientOptions} - selfEmail={activeInboxEmail()} - selectedOptions={form().recipients().to} - setSelectedOptions={withDraftSave((v) => - form().setRecipients('to', v) - )} - triggerMode="input" - portalScope={composePortalScope()} - hideBorder - noPadding - onChipDragStart={(option, e) => - handleChipDragStart('to', option, e) - } - onChipDragEnd={handleChipDragEnd} - hideMenuOnEscape - /> - -
- - 0}> - -
Cc:
- - openOnFocus={false} - class={mobileRecipientSelectorClass} - inputRef={setCcRef} - options={ctx.recipientOptions} - selfEmail={activeInboxEmail()} - selectedOptions={form().recipients().cc} - setSelectedOptions={withDraftSave((v) => - form().setRecipients('cc', v) - )} - triggerMode="input" - portalScope={composePortalScope()} - hideBorder - noPadding - onChipDragStart={(option, e) => - handleChipDragStart('cc', option, e) - } - onChipDragEnd={handleChipDragEnd} - hideMenuOnEscape - /> -
-
- - 0}> - -
Bcc:
- - openOnFocus={false} - class={mobileRecipientSelectorClass} - inputRef={setBccRef} - options={ctx.recipientOptions} - selfEmail={activeInboxEmail()} - selectedOptions={form().recipients().bcc} - setSelectedOptions={withDraftSave((v) => - form().setRecipients('bcc', v) - )} - triggerMode="input" - portalScope={composePortalScope()} - hideBorder - noPadding - onChipDragStart={(option, e) => - handleChipDragStart('bcc', option, e) - } - onChipDragEnd={handleChipDragEnd} - hideMenuOnEscape - /> -
-
- -
- From:  - -
- -
- { - form().setSubject(e.currentTarget.value); - scheduleDraftSave(); - }} - onKeyDown={(e) => { - if (e.key !== 'Escape') return; - e.preventDefault(); - e.currentTarget.blur(); - }} - placeholder="Subject:" - /> -
-
- -
-
{ - if (composerExpanded() || e.currentTarget.scrollTop <= 0) return; - setComposerExpanded(true); - // Keep the send bar pinned while the box grows - requestAnimationFrame(() => { - bottomBarRef?.scrollIntoView({ block: 'nearest' }); - }); - }} - onclick={() => { - editor()?.focus(); - }} - use:fileFolderDrop={{ - onDragStart: (valid) => setIsDragging(valid), - onDragEnd: () => setIsDragging(false), - onDrop: (fileEntries, folderEntries, e) => { - const editor_ = editor(); - if (!editor_ || !e) return; - handleFileFolderDrop( - fileEntries, - folderEntries, - createFilesReadyHandler( - editor_, - blockId, - 'email', - () => getDragDropPosition(editor_, e, true), - (uploadedItemIds) => { - setIsDragging(false); - uploadedItemIds.forEach((itemId) => { - makeAttachmentPublic(itemId); - }); - scheduleDraftSave(); - }, - { width: 542, height: 542 } - ) - ); - }, - }} - > -
- Drop file(s) to attach -
- props.markdownDomRef?.(el)} - onConnect={handleEditorConnect} - /> - -
- -
-
- - - - - {(html) => ( - { - // Dismissal is composer-local state worth keeping — latch - // the seed so a draft upgrade can't remount it away. - props.onEngaged?.(); - setIncludeSignature(false); - }} - /> - )} - -
- {/* Quoted-text controls live below the scroll area so they stay - anchored to the composer bottom instead of scrolling with (and - floating over) tall content. */} - -
- -
-
- -
e.stopPropagation()} - > - - - - - -
-
- - {/* Below the scroll area so quoted email content can never overlap it */} - - - {(html) => ( - { - // Dismissal is composer-local state worth keeping — latch - // the seed so a draft upgrade can't remount it away. - props.onEngaged?.(); - setIncludeSignature(false); - }} - /> - )} - - {/* No fixed height: the send button (size-7.5) is taller than the icon - buttons, and a fixed h-9 minus the vertical padding left it 4px short - — with items-end it bled upward over the signature bar above. */} -
-
-
- -
- - -
- -
- - - -
-
-
-
-
- ); -} diff --git a/apps/web/src/features/block-email/component/Block.tsx b/apps/web/src/features/block-email/component/Block.tsx index c8fee83e579..e0e44df5887 100644 --- a/apps/web/src/features/block-email/component/Block.tsx +++ b/apps/web/src/features/block-email/component/Block.tsx @@ -1,3 +1,4 @@ +import { displaySubject } from '@app/features/email-compose/core/subject-text'; import { useBlockEntityCommands } from '@app/features/next-soup/actions'; import { useGlobalNotificationSource } from '@components/app/GlobalAppState'; import { useSplitPanel } from '@components/app/split-layout/layoutUtils'; @@ -11,8 +12,7 @@ import { buildEntityData } from '@entity'; import { EmailDebouncedReadMarker } from '@notifications'; import { useThreadQuery } from '@queries/email/thread'; import { createMemo, Show, Suspense } from 'solid-js'; -import { displaySubject } from '../util/subjectText'; -import { EmailView } from './Email'; +import { EmailBlockAdapter } from '../EmailBlockAdapter'; export default function BlockEmail() { const blockId = useBlockId(); @@ -47,8 +47,12 @@ export default function BlockEmail() { // thread, and an offline load with nothing cached gates as the retryable // state. Loader-level errors (e.g. an invalid source) still reach // DocumentBlockContainer through blockErrorSignal. + const threadData = createMemo( + (previous: typeof threadQuery.data | undefined) => + threadQuery.isSuccess || threadQuery.isError ? threadQuery.data : previous + ); const threadLoadResult = { - data: () => threadQuery.data, + data: threadData, error: () => threadQuery.isError ? toEntityLoadError(threadQuery.error) : undefined, isPending: () => threadQuery.isLoading, @@ -60,7 +64,7 @@ export default function BlockEmail() { const isPreview = !!useSplitPanel()?.handle.isViewerSplit(); const title = () => { - const data = threadQuery.data; + const data = threadData(); if (!data || !data.thread || data.thread.messages.length === 0) return ''; return displaySubject(data.thread.messages[0].subject); }; @@ -80,11 +84,11 @@ export default function BlockEmail() { - + )} diff --git a/apps/web/src/features/block-email/component/Email.tsx b/apps/web/src/features/block-email/component/Email.tsx deleted file mode 100644 index 567c3ab4466..00000000000 --- a/apps/web/src/features/block-email/component/Email.tsx +++ /dev/null @@ -1,910 +0,0 @@ -import { AskMacroButton } from '@app/features/chat/ChatWithAgentButton'; -import { EmailCompose } from '@block-email/component/compose/Compose'; -import { - EmailProvider, - useEmailContext, -} from '@block-email/component/EmailContext'; -import { SidePanel } from '@components/app/side-panel'; -import { useSplitLayout } from '@components/app/split-layout/layout'; -import { - useCanAutofocusSplitContent, - useSplitPanel, -} from '@components/app/split-layout/layoutUtils'; -import { CustomScrollbar } from '@core/component/CustomScrollbar'; -import { useEmail, useUserContext } from '@core/context/user'; -import { TOKENS } from '@core/hotkey/tokens'; -import { registerScopeSignalHotkey } from '@core/hotkey/utils'; -import { isTouchDevice } from '@core/mobile/isTouchDevice'; -import { - blockElementSignal, - blockHotkeyScopeSignal, -} from '@core/signal/blockElement'; -import { AnimatedTaskIcon } from '@icon/wide-task'; -import { buildMentionMarkdownString } from '@macro-inc/lexical-core'; -import type { ApiMessage } from '@service-email/generated/schemas'; -import { createCallback } from '@solid-primitives/rootless'; -import { Button } from '@ui'; -import { - type Accessor, - createEffect, - createMemo, - createSignal, - Match, - on, - onCleanup, - onMount, - Show, - Switch, - untrack, -} from 'solid-js'; -import { match } from 'ts-pattern'; -import { isScrollingToMessage } from '../signal/scrollState'; -import { registerEmailHotkeys } from '../util/emailHotkeys'; -import { isPersonalMessage } from '../util/isPersonalMessage'; -import type { ReplyType } from '../util/replyType'; -import { - hiddenMessagesControl, - isTruncatedMiddleMessage, - isUnreadMessage, - keyboardRevealDelta, - leadingThrottle, - listScrollBehavior, - messageElement, - nearestDelta, - pageThenAdvanceDelta, - revealMessageAfterLayout, - type ScrollAlign, - scrollToListEndDelta, - scrollToListStartDelta, - scrollToMessage, - threadMessageIsExpanded, -} from '../util/scrollToMessage'; -import { - adjacentStop, - nextThreadStop, - shownStops, - type ThreadStop, - threadStopFromHover, -} from '../util/threadStops'; -import { BottomReplyButtons } from './BottomReplyButtons'; -import { EmailFormContextProvider } from './EmailFormContext'; -import { openEmailReplyComposerForMessage } from './emailReplyActions'; -import { MessageList } from './MessageList'; -import { MobileEmailComposeDrawer } from './MobileEmailComposeDrawer'; -import { ModalsProvider } from './ModalsProvider'; -import { EmailSidePanelSections } from './sidepanel/EmailSidePanelSections'; -import { TopBar } from './TopBar'; - -const TARGET_MESSAGE_HIGHLIGHT_MS = 800; -/** List navigation — keep within the 300ms UI motion budget (improve-animations). */ -const SCROLL_ANIMATION_MS = 250; -const KEYBOARD_SCROLL_MS = 250; - -type EmailViewProps = { - title: string; - threadId: Accessor; -}; - -export function EmailView(props: EmailViewProps) { - return ( - - - - - - - ); -} - -function EmailContent(props: EmailViewProps) { - const scopeId = blockHotkeyScopeSignal.get; - const { popoverSplit } = useSplitLayout(); - - const setIsScrollingToMessage = isScrollingToMessage.set; - const blockElement = blockElementSignal.get; - - const context = useEmailContext(); - const splitPanel = useSplitPanel(); - const canAutofocusSplitContent = useCanAutofocusSplitContent(); - const { isLoading: isUserLoading } = useUserContext(); - const userEmail = useEmail(); - - const openTaskCompose = () => { - const threadId = context.thread()?.db_id; - if (!threadId) return; - const title = - props.title.length > 70 ? `${props.title.slice(0, 70)}...` : props.title; - popoverSplit({ - type: 'component', - id: 'task-compose', - params: { - initialTitle: title, - initialContent: buildMentionMarkdownString({ - type: 'document', - documentId: threadId, - documentName: props.title, - blockName: 'email', - }), - }, - }); - }; - - /** - * Waits for the query to finish fetching - */ - const waitForQueryLoad = (): Promise => { - return new Promise((resolve) => { - const checkInterval = setInterval(() => { - if (!context.query.isFetching()) { - clearInterval(checkInterval); - resolve(); - } - }, 50); - }); - }; - - /** - * Loads messages until the target message is found or no more messages available - */ - const loadMessagesUntilFound = async ( - targetMessageId: string - ): Promise => { - while (true) { - const messages = context.messages.unfiltered(); - - // Check if message exists in current batch - const messageExists = messages.some( - (m: ApiMessage) => m.db_id === targetMessageId - ); - - if (messageExists) return true; - - // No more messages to load - if (!context.query.hasMore()) return false; - - // Load next batch and wait - context.query.fetchNextPage(); - await waitForQueryLoad(); - } - }; - - const fetchNextPage = async () => { - if (context.query.hasMore() && !context.query.isFetching()) { - context.query.fetchNextPage(); - await waitForQueryLoad(); - } - }; - - const canRunInitialEmailScroll = () => - !isTouchDevice() || splitPanel?.isPanelActive() !== false; - - const [keyboardSelecting, setKeyboardSelecting] = createSignal(false); - const [listAnchor, setListAnchor] = createSignal<'title' | 'composer'>(); - let lastPointer = { x: Number.NaN, y: Number.NaN }; - let armedPointer: { x: number; y: number } | undefined; - - // Hand list navigation back to the pointer: arrow keys resume from whatever - // the mouse is over. The selection itself survives, so a message reached with - // the keyboard stays selected once the mouse moves. - const releaseKeyboardPointer = () => { - armedPointer = undefined; - setKeyboardSelecting(false); - setListAnchor(undefined); - leaveHiddenChip(); - }; - - /** Escape drops the selection too, not just the keyboard's claim on it. */ - const clearSelection = () => { - releaseKeyboardPointer(); - context.messages.setFocused(undefined); - }; - - const armKeyboardPointer = () => { - if (isTouchDevice()) return; - setKeyboardSelecting(true); - armedPointer = { x: lastPointer.x, y: lastPointer.y }; - }; - - const leaveHiddenChip = () => { - context.messages.setHiddenChipFocused(false); - const list = untrack(context.messagesListRef); - const button = list ? hiddenMessagesControl(list) : undefined; - if (button && document.activeElement === button) { - button.blur(); - blockElement()?.focus({ preventScroll: true }); - } - }; - - /** - * Performs scrolling to a message and updates focus. - */ - const performScrollToMessage = ( - messageId: string, - opts: { - behavior?: ScrollBehavior; - focus?: boolean; - align?: ScrollAlign; - } = { - behavior: 'smooth', - focus: true, - } - ) => { - opts = { focus: true, behavior: 'smooth', align: 'nearest', ...opts }; - const messages = untrack(context.messages.list); - const container = untrack(context.messagesListRef); - - if (!messages || !container) return false; - - setIsScrollingToMessage(true); - - const success = scrollToMessage(messageId, messages, container, { - behavior: opts.behavior, - align: opts.align, - }); - - if (!success) { - setIsScrollingToMessage(false); - return false; - } - - if (opts.focus) { - leaveHiddenChip(); - context.messages.setFocused(messageId); - } - - if (context.messages.targetMessageID() === messageId) { - setTimeout(() => { - context.messages.setTargetMessageID(undefined); - }, TARGET_MESSAGE_HIGHLIGHT_MS); - } - - setTimeout(() => setIsScrollingToMessage(false), SCROLL_ANIMATION_MS); - - return true; - }; - - context.onInitialDataLoad(() => { - if (!canRunInitialEmailScroll()) return false; - if (!untrack(context.messagesListRef)) return false; - - const targetMessageId_ = context.messages.targetMessageID(); - if (targetMessageId_ && typeof targetMessageId_ !== 'string') return true; - if (typeof targetMessageId_ === 'string') { - void revealTargetMessage(targetMessageId_); - } - - return true; - }); - - async function revealTargetMessage(messageId: string) { - context.messages.setExpandedBodyId(messageId, true); - const messages = untrack(context.messages.list); - if (!messages) return; - - const initialIndex = messages.findIndex( - (message) => message.db_id === messageId - ); - - if (initialIndex < 0) { - try { - const found = await loadMessagesUntilFound(messageId); - if (!found) return; - await fetchNextPage(); - } catch (error) { - console.error('Error loading target message:', error); - return; - } - } else if (initialIndex === 0) { - await fetchNextPage(); - } - - requestAnimationFrame(() => { - performScrollToMessage(messageId, { - behavior: 'instant', - focus: true, - align: 'start', - }); - }); - } - - const [userOpenedMiddle, setUserOpenedMiddle] = createSignal(false); - createEffect( - on( - () => context.thread()?.db_id, - () => { - setUserOpenedMiddle(false); - leaveHiddenChip(); - } - ) - ); - - const showMiddleMessages = createMemo(() => { - if (userOpenedMiddle()) return true; - const messages = context.messages.list(); - const focus = context.messages.focusedID(); - const target = context.messages.targetMessageID(); - for (let i = 0; i < messages.length; i++) { - if (!isTruncatedMiddleMessage(i, messages.length)) continue; - const id = messages[i]?.db_id; - if (id && (id === focus || id === target)) return true; - if (isUnreadMessage(messages[i])) return true; - if (!isTouchDevice() && id && context.drafts.getDraftForMessage(id)) - return true; - } - return false; - }); - - let markdownDomRef!: HTMLDivElement; - const tryKeyboardListScroll = leadingThrottle(KEYBOARD_SCROLL_MS); - - const scrollListBy = ( - list: HTMLElement, - top: number, - animationMs = SCROLL_ANIMATION_MS - ) => { - if (top === 0) return false; - setIsScrollingToMessage(true); - setTimeout(() => setIsScrollingToMessage(false), animationMs); - list.scrollBy({ top, behavior: listScrollBehavior() }); - return true; - }; - - const keyboardScrollListBy = (list: HTMLElement, top: number) => { - if (top === 0) return false; - if (!tryKeyboardListScroll()) return true; - setIsScrollingToMessage(true); - setTimeout(() => setIsScrollingToMessage(false), KEYBOARD_SCROLL_MS); - list.scrollBy({ top, behavior: listScrollBehavior() }); - return true; - }; - - const focusHiddenMessages = () => { - const list = untrack(context.messagesListRef); - if (!list) return false; - const button = hiddenMessagesControl(list); - if (!button) return false; - context.messages.setFocused(undefined); - context.messages.setHiddenChipFocused(true); - scrollListBy(list, nearestDelta(list, button)); - return true; - }; - - const applyStop = ( - stop: ThreadStop | undefined, - messages: ApiMessage[], - list: HTMLElement - ) => { - if (!stop) return true; - return match(stop) - .with({ kind: 'title' }, () => { - armKeyboardPointer(); - setListAnchor('title'); - context.messages.setFocused(undefined); - const startDelta = scrollToListStartDelta(list); - if (startDelta !== 0) return scrollListBy(list, startDelta); - return true; - }) - .with({ kind: 'hidden-chip' }, () => { - armKeyboardPointer(); - setListAnchor(undefined); - return focusHiddenMessages(); - }) - .with({ kind: 'message' }, ({ index }) => { - const id = messages[index]?.db_id; - if (!id) return false; - armKeyboardPointer(); - setListAnchor(undefined); - return performScrollToMessage(id, { - behavior: 'smooth', - focus: true, - }); - }) - .with({ kind: 'composer' }, () => { - armKeyboardPointer(); - setListAnchor('composer'); - leaveHiddenChip(); - context.messages.setFocused(undefined); - markdownDomRef.focus(); - return true; - }) - .exhaustive(); - }; - - const navigateMessage = createCallback((dir: 'prev' | 'next') => { - const messages = context.messages.list(); - const list = context.messagesListRef(); - if (!messages?.length || !list) return false; - - const stops = shownStops({ - length: messages.length, - showMiddle: showMiddleMessages(), - hasComposer: Boolean(markdownDomRef), - }); - - const keyboard = (() => { - if (!keyboardSelecting()) return undefined; - if (context.messages.hiddenChipFocused()) - return { kind: 'hidden-chip' } as const; - const anchor = listAnchor(); - if (anchor === 'title' || anchor === 'composer') { - return { kind: anchor } as const; - } - const focusedId = context.messages.focusedID(); - if (!focusedId) return undefined; - const index = messages.findIndex( - (message) => message.db_id === focusedId - ); - return index >= 0 ? ({ kind: 'message', index } as const) : undefined; - })(); - - if (keyboard?.kind === 'message') { - const focusedId = messages[keyboard.index]?.db_id; - const focusedEl = focusedId - ? messageElement(list, messages, focusedId) - : undefined; - if (focusedEl) { - const revealDelta = keyboardRevealDelta(list, focusedEl, dir); - if (revealDelta !== 0) return keyboardScrollListBy(list, revealDelta); - - const pageDelta = pageThenAdvanceDelta(list, focusedEl, dir); - if (pageDelta !== 0) return keyboardScrollListBy(list, pageDelta); - } - - if ( - dir === 'next' && - keyboard.index === messages.length - 1 && - keyboardSelecting() - ) { - const nextStop = adjacentStop(stops, keyboard, 'next'); - if (!nextStop || nextStop.kind === 'composer') { - const endDelta = scrollToListEndDelta(list); - if (endDelta !== 0) return keyboardScrollListBy(list, endDelta); - return true; - } - } - } - - const messageIds = messages.map((message) => message.db_id); - const hover = threadStopFromHover(context.messages.hovered(), messageIds); - // The pointer leads while it is over the list. With the pointer elsewhere, - // arrows step off the selected card rather than re-entering at the end. - const selectedId = context.messages.focusedID(); - const selectedIndex = selectedId ? messageIds.indexOf(selectedId) : -1; - const cursor = - hover ?? - (selectedIndex >= 0 - ? ({ kind: 'message', index: selectedIndex } as const) - : undefined); - - return applyStop( - nextThreadStop({ stops, keyboard, hover: cursor, dir }), - messages, - list - ); - }); - - const navigateToPreviousMessage = () => navigateMessage('prev'); - const navigateToNextMessage = () => navigateMessage('next'); - - // Wait for the block element before claiming focus on initial mount. - let hasRun = false; - createEffect(() => { - if (hasRun) return; - if (!canAutofocusSplitContent) return; - // Focus the email block on mount - if (isTouchDevice()) return; - if (!blockElement()) return; - blockElement()?.focus({ preventScroll: true }); - hasRun = true; - }); - - const getHotkeyTarget = () => { - const messages = context.messages.list(); - if (messages.length === 0) return; - - const focusedId = context.messages.focusedID(); - const focusedMessage = focusedId - ? messages.find((message) => message.db_id === focusedId) - : undefined; - const message = focusedMessage ?? messages.at(-1); - if (!message?.db_id) return; - - return { - message, - isLastMessage: messages.at(-1)?.db_id === message.db_id, - }; - }; - - const isMessageRenderedExpanded = ( - target: NonNullable> - ) => { - const messageId = target.message.db_id; - if (!messageId) return false; - - const list = context.messages.list(); - const chronologicalIndex = list.findIndex( - (message) => message.db_id === messageId - ); - if (chronologicalIndex < 0) return false; - - return threadMessageIsExpanded({ - chronologicalIndex, - listLength: list.length, - expansionOverride: context.messages.expandedBodyIds[messageId], - isUnread: isUnreadMessage(target.message), - hasDraft: - !isTouchDevice() && !!context.drafts.getDraftForMessage(messageId), - }); - }; - - const openHotkeyTarget = (replyType: ReplyType) => { - const target = getHotkeyTarget(); - if (!target) return false; - - return openEmailReplyComposerForMessage({ - ctx: context, - message: target.message, - replyType, - isLastMessage: target.isLastMessage, - }); - }; - - onMount(() => { - if (!isTouchDevice()) { - const onMove = (event: PointerEvent) => { - lastPointer = { x: event.clientX, y: event.clientY }; - const armed = armedPointer; - if (!armed) return; - if (event.clientX === armed.x && event.clientY === armed.y) return; - releaseKeyboardPointer(); - }; - window.addEventListener('pointermove', onMove); - onCleanup(() => window.removeEventListener('pointermove', onMove)); - } - - registerEmailHotkeys(scopeId(), { - replyToFocusedMessage: () => openHotkeyTarget('reply-all'), - forwardFocusedMessage: () => openHotkeyTarget('forward'), - blockSender: context.blockSender, - markDone: context.archiveThread, - markNotDone: context.markThreadNotDone, - isThreadDone: context.isThreadDone, - canMarkNotDone: context.canMarkThreadNotDone, - markUnread: context.markThreadUnread, - markRead: context.markThreadRead, - isThreadMarkedUnread: context.isThreadMarkedUnread, - markSenderSignal: context.markSenderSignal, - markSenderNoise: context.markSenderNoise, - navigateToPreviousMessage, - navigateToNextMessage, - }); - }); - - registerScopeSignalHotkey(scopeId, { - hotkey: 'enter', - description: 'Reply to message', - keyDownHandler: () => { - if (context.messages.hiddenChipFocused()) { - const messages = untrack(context.messages.list); - const next = adjacentStop( - shownStops({ length: messages.length, showMiddle: true }), - { kind: 'message', index: 0 }, - 'next' - ); - const nextId = - next?.kind === 'message' ? messages[next.index]?.db_id : undefined; - setUserOpenedMiddle(true); - if (!nextId) { - leaveHiddenChip(); - return true; - } - context.messages.setFocused(nextId); - revealMessageAfterLayout( - nextId, - messages, - untrack(context.messagesListRef) - ); - return true; - } - - const focusedId = context.messages.focusedID(); - const target = getHotkeyTarget(); - - if (focusedId && target?.message.db_id === focusedId) { - if (!isMessageRenderedExpanded(target)) { - context.messages.setExpandedBodyId(focusedId, true); - revealMessageAfterLayout( - focusedId, - untrack(context.messages.list), - untrack(context.messagesListRef) - ); - return true; - } - - return openEmailReplyComposerForMessage({ - ctx: context, - message: target.message, - replyType: 'reply-all', - isLastMessage: target.isLastMessage, - }); - } - - // No message focused: reply to the latest message, same as 'r' - return openHotkeyTarget('reply-all'); - }, - hotkeyToken: TOKENS.block.focus, - hide: true, - }); - - registerScopeSignalHotkey(scopeId, { - hotkey: 'escape', - description: 'Collapse or unselect message', - keyDownHandler: () => { - // Skip if focus is in an editable area (compose input handles its own Escape) - const activeEl = document.activeElement; - if ( - activeEl?.tagName === 'INPUT' || - activeEl?.tagName === 'TEXTAREA' || - activeEl?.getAttribute('contenteditable') === 'true' - ) { - return false; - } - - if (context.messages.hiddenChipFocused()) { - clearSelection(); - return true; - } - - if (keyboardSelecting() && listAnchor()) { - clearSelection(); - return true; - } - - const focusedId = context.messages.focusedID(); - if (!focusedId) { - if (keyboardSelecting()) { - clearSelection(); - return true; - } - return false; - } - - // If there's an active reply, just clear it (don't collapse the message) - if (context.messages.replyingToMessageId() === focusedId) { - context.messages.setReplyingToMessageId(undefined); - return true; - } - - const target = getHotkeyTarget(); - if (target && isMessageRenderedExpanded(target)) { - context.messages.setExpandedBodyId(focusedId, false); - return true; - } - - clearSelection(); - if ( - activeEl instanceof HTMLElement && - activeEl.closest(`[data-message-body-id="${CSS.escape(focusedId)}"]`) - ) { - activeEl.blur(); - } - return true; - }, - hotkeyToken: TOKENS.email.cancelReply, - hide: true, - }); - - // On thread change: collapse the bottom reply, then re-evaluate auto-open - // for the current thread's last message. Single effect to avoid an - // ordering race between separate "reset on thread change" and "auto-open - // on draft" effects (Solid runs effects in declaration order on first - // mount, which can let the reset clobber the auto-open if both data - // sources are synchronously available). - let prevThreadId: string | undefined; - createEffect(() => { - const tid = props.threadId(); - if (prevThreadId !== tid) { - prevThreadId = tid; - context.messages.setBottomReplyOpen(false); - context.mobileReplyComposer.close(); - } - const filtered = context.messages.list(); - const lastMessage = filtered.at(-1); - if (!lastMessage?.db_id) return; - if (context.drafts.getDraftForMessage(lastMessage.db_id)) { - if (isTouchDevice()) { - context.mobileReplyComposer.openForMessage(lastMessage.db_id); - } else { - context.messages.setBottomReplyOpen(true); - } - } - }); - - const emailReplyInfo = createMemo(() => { - const filtered = context.messages.list(); - - // If there are non draft messages in this thread, the bottom input will - // be for sending a reply to the last message - if (filtered.length !== 0) { - const lastMessage = filtered.at(-1); - if (!lastMessage || !lastMessage.db_id) return; - return { - replyingTo: lastMessage, - draft: context.drafts.getDraftForMessage(lastMessage.db_id), - }; - } - - // Otherwise, if the other messages in the thread are drafts, - // the bottom input will be for editing and sending the latest/last draft - const unfiltered = context.messages.unfiltered(); - - if (unfiltered.length === 0) return; - - const latest = unfiltered.at(-1); - - if (!latest || !latest.is_draft) return; - - return { replyingTo: undefined, draft: latest }; - }); - - // The bottom reply area renders when the user can compose and there's a - // message to reply to or a draft to edit. Returns the reply info so it can - // drive the keyed around the reply area. - const replyArea = () => { - if (!context.permissions().isOwner) return; - if (!context.drafts.initialDraftsSettled()) return; - return emailReplyInfo(); - }; - - // The expanded compose input, as opposed to the collapsed reply buttons - // (which float in the mobile accessory region). - const replyInputOpen = () => - context.messages.bottomReplyOpen() || emailReplyInfo()?.replyingTo == null; - - // Whether the compose input is rendered in normal flow. - const replyInputInFlow = () => Boolean(replyArea() && replyInputOpen()); - - const mobileBottomReplyMessage = createMemo(() => { - if (context.mobileReplyComposer.open()) return; - return replyArea()?.replyingTo; - }); - - return ( - - - - - {(draft) => ( - // The email block is bottom-anchored (no default panel inset), - // so the compose branch pads around the chrome itself. -
- -
- )} -
- - - - context.messages.unfiltered().find((m) => m.db_id === id), - getDraftForMessageReply: context.drafts.getDraftForMessage, - onRecipientsChange: context.onRecipientsChange, - isPersonalMessage: (message) => - isPersonalMessage( - message, - userEmail(), - context.messages.personalSenders() - ), - }} - > - {/* Edge-to-edge on mobile/tablet: the message list carries its own - insets in-scroll and under-scrolls the floating chrome. */} -
- - -
- - {(threadId) => ( - - )} - - - - -
-
-
- { - markdownDomRef = el; - }} - title={props.title} - underScrollsBottom={!replyInputInFlow()} - showMiddleMessages={showMiddleMessages()} - hiddenChipFocused={context.messages.hiddenChipFocused()} - allowRowHover={!keyboardSelecting()} - onHiddenChipFocus={() => { - armKeyboardPointer(); - context.messages.setFocused(undefined); - context.messages.setHiddenChipFocused(true); - }} - onOpenMiddle={() => { - leaveHiddenChip(); - setUserOpenedMiddle(true); - }} - /> - -
- - {(lastMessage) => ( - - )} - - - { - markdownDomRef = el; - }} - /> - -
-
-
-
-
-
- ); -} - -function EmailTaskButton(props: { onClick: () => void }) { - const [hovering, setHovering] = createSignal(false); - - return ( - - ); -} diff --git a/apps/web/src/features/block-email/component/EmailContext.tsx b/apps/web/src/features/block-email/component/EmailContext.tsx deleted file mode 100644 index a15c948d400..00000000000 --- a/apps/web/src/features/block-email/component/EmailContext.tsx +++ /dev/null @@ -1,1028 +0,0 @@ -import { - makeMarkDoneAction, - makeMarkNotDoneAction, -} from '@app/features/next-soup/actions'; -import { useMaybeSoup } from '@app/features/next-soup/soup-context'; -import { openEntityInSplitFromUnifiedList } from '@app/features/next-soup/utils'; -import { URL_PARAMS } from '@block-email/constants'; -import { convertContactInfoToEmailRecipient } from '@block-email/util/recipientConversion'; -import { useGlobalNotificationSource } from '@components/app/GlobalAppState'; -import { useSplitPanel } from '@components/app/split-layout/layoutUtils'; -import { - getPermissions, - hasPermissions, - Permissions, -} from '@core/component/SharePermissions'; -import { toast } from '@core/component/Toast/Toast'; -import { useEmail, useUserId } from '@core/context/user'; -import { createMethodRegistration } from '@core/orchestrator'; -import { blockElementSignal } from '@core/signal/blockElement'; -import { blockHandleSignal } from '@core/signal/load'; -import { - recipientEntityMapper, - useContacts, - type WithCustomUserInput, -} from '@core/user'; -import { - compositeEntity, - createEffectOnEntityTypeNotification, - setDoneOverride, -} from '@notifications'; -import ArrowCounterClockwise from '@phosphor-icons/core/regular/arrow-counter-clockwise.svg?component-solid'; -import { queryClient } from '@queries/client'; -import { emailKeys } from '@queries/email/keys'; -import { useNonPrimaryEmailLinkIdHeader } from '@queries/email/link'; -import { - blockSenderWithToast, - markSenderNoiseWithToast, - markSenderSignalWithToast, - trackExternalThreadArchive, - useMarkThreadAsSeenMutation, - useMarkThreadAsUnreadMutation, - useThreadQuery, - useUndoableArchiveThreadMutation, -} from '@queries/email/thread'; -import { - bulkMarkNotificationsAsDone, - bulkMarkNotificationsAsUndone, - fetchDoneNotificationIdsByEventItemIds, -} from '@queries/notification/user-notifications'; -import { - getSoupEntityById, - invalidateAllSoup, - refetchSoupEntity, -} from '@queries/soup/cache'; -import { mapApiSoupItemToEntity } from '@queries/soup/transform-utils'; -import type { UndoHandle } from '@queries/undo'; -import type { - ApiMessage, - ApiThread, - ContactInfo, -} from '@service-email/generated/schemas'; -import { useSearchParams } from '@solidjs/router'; -import { - type Accessor, - createContext, - createEffect, - createMemo, - createSignal, - type FlowProps, - onCleanup, - Suspense, - untrack, - useContext, -} from 'solid-js'; -import { createStore } from 'solid-js/store'; -import type { ReplyType } from '../util/replyType'; -import { hiddenMessagesControl } from '../util/scrollToMessage'; -import type { HoveredThreadStop } from '../util/threadStops'; - -/** - * Tracks thread IDs that had a draft saved since the last query fetch. - * When the EmailProvider unmounts, threads in this set have their query - * cache cleared so the next visit fetches fresh data (with the draft). - * This avoids touching the active query during draft save, which would - * trigger Suspense DOM detach and reset scroll position. - */ -const draftSavedThreadIds = new Set(); -export function markThreadDraftSaved(threadId: string) { - draftSavedThreadIds.add(threadId); -} -export type EmailRecipient = WithCustomUserInput<'user' | 'contact'>; - -type ArchiveThreadOptions = { - silent?: boolean; - onUndoHandle?: (handle: UndoHandle) => void; - nextEntityId?: string; -}; - -type EmailContextValues = { - registerMessagesList: (list: HTMLElement) => void; - messagesListRef: Accessor; - registerMessagesContainer: (container: HTMLElement) => void; - messagesContainerRef: Accessor; - - recipientOptions: Accessor; - onRecipientsChange: (items: EmailRecipient[]) => void; - - drafts: { - getDraftForMessage: (messageDbID: string) => ApiMessage | undefined; - deleteDraftForMessage: (messageDbID: string) => void; - initialDraftsSettled: Accessor; - }; - - messages: { - unfiltered: Accessor; - list: Accessor; - targetMessageID: Accessor; - setTargetMessageID: (id: string | undefined) => void; - focusedID: Accessor; - setFocused: (messageID: string | undefined) => void; - hiddenChipFocused: Accessor; - setHiddenChipFocused: (focused: boolean) => void; - hovered: Accessor; - setHovered: (stop: HoveredThreadStop | undefined) => void; - expandedBodyIds: Record; - setExpandedBodyId: (id: string, expanded: boolean) => void; - isBodyExpanded: (id: string) => boolean; - replyingToMessageId: Accessor; - setReplyingToMessageId: (id: string | undefined) => void; - bottomReplyOpen: Accessor; - setBottomReplyOpen: (open: boolean) => void; - // Sender emails (lowercased) with a CATEGORY_PERSONAL message in the thread - personalSenders: Accessor>; - }; - mobileReplyComposer: { - open: Accessor; - messageId: Accessor; - setOpen: (open: boolean) => void; - openForMessage: (id: string) => void; - close: () => void; - }; - replyRequest: { - messageId: Accessor; - replyType: Accessor; - set: (messageId: string, replyType: ReplyType) => void; - clear: () => void; - }; - thread: Accessor; - permissions: Accessor<{ - type: Permissions; - isOwner: boolean; - }>; - - query: { - hasMore: Accessor; - isFetching: Accessor; - fetchNextPage: () => void; - refetch: () => void; - }; - - archiveThread: (opts?: ArchiveThreadOptions) => boolean; - /** True when the thread is archived, i.e. currently marked done. */ - isThreadDone: Accessor; - /** True when the done state can actually be reversed — see - * `markThreadNotDone`. */ - canMarkThreadNotDone: Accessor; - /** Unarchives a done thread and restores its notifications. */ - markThreadNotDone: () => boolean; - /** True when the user marked the open thread unread. Resets to false per - * thread — viewing marks it read, so the toggle starts at Mark Unread. */ - isThreadMarkedUnread: Accessor; - /** Marks the open thread unread; the toggle then offers Mark Read. */ - markThreadUnread: () => boolean; - /** Re-marks the thread read after a mark-unread. */ - markThreadRead: () => boolean; - getMarkDoneNavigationTargetId: () => string | undefined; - blockSender: () => boolean; - markSenderSignal: () => boolean; - markSenderNoise: () => boolean; - initialLoadComplete: Accessor; - onInitialDataLoad: (callback: () => boolean) => void; -}; - -const EmailContext = createContext(); - -export function EmailProvider(props: FlowProps<{ threadID: string }>) { - const threadQuery = useThreadQuery( - () => props.threadID, - () => ({ - select(data) { - const messages = data.pages.flatMap((t) => t.messages); - - // Sort all messages by recency - messages.sort((a, b) => { - if (a.internal_date_ts && b.internal_date_ts) { - return ( - new Date(a.internal_date_ts).getTime() - - new Date(b.internal_date_ts).getTime() - ); - } - // Below is fallback for when internal_date_ts is not set - else if (a.sent_at && b.sent_at) { - return ( - new Date(a.sent_at).getTime() - new Date(b.sent_at).getTime() - ); - } - return 0; - }); - - const filtered = []; - const messageDraftMap: Record = {}; - - for (const message of messages) { - if (!message.is_draft) { - filtered.push(message); - continue; - } - - if (message.body_html_sanitized?.trim().length === 0) { - continue; - } - - const replyingToId = message.replying_to_id; - - if (!replyingToId) continue; - - messageDraftMap[replyingToId] = message; - } - - return { - ...data.pages[0], - messages: messages, - filtered: filtered, - draftMap: messageDraftMap, - }; - }, - }) - ); - - const notificationSource = useGlobalNotificationSource(); - - createEffectOnEntityTypeNotification( - notificationSource, - 'email', - (notification) => { - const meta = notification.notification_metadata; - if (meta.tag !== 'new_email') return; - if (meta.content.threadId === threadQuery.data?.db_id) { - threadQuery.refetch(); - } - } - ); - - const [focusedMessageId, setFocusedMessageId] = createSignal(); - const [hiddenChipFocused, setHiddenChipFocused] = createSignal(false); - const [hoveredStop, setHoveredStop] = createSignal(); - const [replyingToMessageId, setReplyingToMessageId] = createSignal(); - const [bottomReplyOpen, setBottomReplyOpen] = createSignal(false); - const [mobileReplyComposerOpen, setMobileReplyComposerOpen] = - createSignal(false); - const [mobileReplyComposerMessageId, setMobileReplyComposerMessageId] = - createSignal(); - const [replyRequestMessageId, setReplyRequestMessageId] = - createSignal(); - const [replyRequestType, setReplyRequestType] = createSignal(); - const [expandedMessageBodyIds, setExpandedMessageBodyIds] = createStore< - Record - >({}); - const [searchParams] = useSearchParams(); - const searchParamsMessageId = () => { - const messageID = searchParams[URL_PARAMS.messageId]; - if (typeof messageID === 'string') { - return messageID; - } else if (Array.isArray(messageID)) { - return messageID[0]; - } - return undefined; - }; - const [targetMessageId, setTargetMessageId] = createSignal< - string | undefined - >(searchParamsMessageId()); - // Deep links (`?messageId=`) scroll to and expand a specific message after load. - - const [hasHandledTarget, setHasHandledTarget] = createSignal(false); - - const blockHandle = blockHandleSignal.get; - createMethodRegistration(blockHandle, { - goToLocationFromParams: (params: Record) => { - if (params[URL_PARAMS.messageId]) { - setTargetMessageId(undefined); - setTimeout(() => { - setTargetMessageId(params[URL_PARAMS.messageId]); - setHasHandledTarget(false); - }, 0); - } - }, - }); - - // The newest version of each reply draft seen across query snapshots, - // keyed by the replied-to message id. A cached snapshot populates this the - // moment it's available (the composer must not wait on the network), and a - // later fetch upgrades an entry only when its updated_at is newer — so the - // revalidation of a stale cache wins, but an out-of-order response can't - // downgrade a draft. Entries missing from a fetch are kept: deletes are - // handled locally below, and dropping one would collapse an open composer. - const serverDrafts = createMemo< - { threadDbId: string; map: Record } | undefined - >((prev) => { - const data = threadQuery.data; - if (!data) return undefined; - const next = data.draftMap; - if (!prev || prev.threadDbId !== data.db_id) { - return { threadDbId: data.db_id, map: next }; - } - const map: Record = { ...next }; - for (const [messageId, prevDraft] of Object.entries(prev.map)) { - const nextDraft = map[messageId]; - if ( - !nextDraft || - new Date(nextDraft.updated_at).getTime() < - new Date(prevDraft.updated_at).getTime() - ) { - map[messageId] = prevDraft; - } - } - return { threadDbId: data.db_id, map }; - }); - - // Drafts the user discarded this session. Kept apart from the server map so - // a fetch that still contains the deleted draft (delete propagation lag) - // can't resurrect it. - const [deletedDraftIds, setDeletedDraftIds] = createStore< - Record - >({}); - - const deleteDraftForMessage = (messageID: string) => { - setDeletedDraftIds(messageID, true); - }; - - const getDraftForMessage = (messageID: string) => { - if (deletedDraftIds[messageID]) return undefined; - return serverDrafts()?.map[messageID]; - }; - - // Drafts derive straight from the query, so "settled" is simply "we have a - // thread snapshot" — cached or fresh, revalidating or not. - const initialDraftsSettled = () => serverDrafts() !== undefined; - - const contacts = useContacts(); - - const [augmentedRecipients, setAugmentedRecipients] = createSignal< - EmailRecipient[] - >([]); - - function onRecipientsChange(items: EmailRecipient[]) { - const existing = augmentedRecipients(); - const existingEmails = new Set( - existing.map((r) => r.data.email).filter((e) => e.length > 0) - ); - - const uniques: EmailRecipient[] = []; - for (const r of items) { - const email = r.data.email; - if (email && !existingEmails.has(email)) { - existingEmails.add(email); - uniques.push(r); - } - } - - if (uniques.length === 0) return; - setAugmentedRecipients([...existing, ...uniques]); - } - - const getRecipientOptions = () => { - const optionsMap = new Map(); - - for (const contact of contacts()) { - const mapped = recipientEntityMapper('contact')({ - type: 'extracted', - email: contact.email, - id: contact.id, - name: contact.name, - }); - optionsMap.set(mapped.data.email, mapped); - } - - const thread = threadQuery.data; - if (thread) { - const seen = new Map(); - - const add = (c: ContactInfo) => { - const existing = seen.get(c.email); - if (!existing || (!existing.name && c.name)) seen.set(c.email, c); - }; - - thread.messages.forEach((m) => { - m.to.forEach(add); - m.cc.forEach(add); - m.bcc.forEach(add); - if (m.from?.email) - add({ - email: m.from.email, - name: m.from.name ?? undefined, - }); - }); - - for (const value of seen.values()) { - const mapped = convertContactInfoToEmailRecipient(value); - optionsMap.set(mapped.data.email, mapped); - } - } - - augmentedRecipients().forEach((r) => { - const email = r.data.email; - if (email && !optionsMap.has(email)) optionsMap.set(email, r); - }); - - return Array.from(optionsMap.values()); - }; - - const soup = useMaybeSoup(); - const splitPanel = useSplitPanel(); - - const userId = useUserId(); - - const markAsDoneAction = makeMarkDoneAction({ - notificationSource: () => notificationSource, - userId, - }); - - const markNotDoneAction = makeMarkNotDoneAction({ - notificationSource: () => notificationSource, - }); - - // Notification ids the mark-not-done fallback restored, per thread, so the - // undo/redo hooks below can re-mark them when the archive flip is replayed. - const restoredNotificationIds = new Map(); - - // Only the direct archive/unarchive fallbacks go through this mutation - // (the mark-done / mark-not-done action paths toast on their own). - const archiveMutation = useUndoableArchiveThreadMutation({ - onPushed: (handle, params) => { - params.onUndoHandle?.(handle); - const message = params.archive ? 'Marked as done' : 'Marked as not done'; - let toastId: number | undefined; - - const showToast = () => { - if (params.silent) return; - toastId = toast.success(message, { - actions: [ - { - label: 'Undo', - icon: ArrowCounterClockwise, - onClick: () => { - handle.undo({ - onError: () => toast.failure('Failed to undo'), - }); - }, - }, - ], - duration: 3_000, - stack: true, - hideOnMobile: true, - }); - }; - - showToast(); - - // Undo/redo replay only the /archived flip; mirror the fallback's - // notification and soup-list side effects for the resulting state. - const syncSideEffects = (nowArchived: boolean) => { - const ids = restoredNotificationIds.get(params.threadId) ?? []; - if (ids.length > 0) { - setDoneOverride(ids, nowArchived); - void ( - nowArchived - ? bulkMarkNotificationsAsDone(ids) - : bulkMarkNotificationsAsUndone(ids) - ).catch(() => setDoneOverride(ids, undefined)); - } - if (!nowArchived) { - void refetchSoupEntity(params.threadId, 'emailThread'); - } - invalidateAllSoup(); - }; - - return { - onUndone: () => { - if (toastId !== undefined) toast.dismiss(toastId); - syncSideEffects(!params.archive); - }, - onRedone: () => { - showToast(); - syncSideEffects(params.archive); - }, - }; - }, - onError: (params) => { - toast.failure( - params.archive ? 'Failed to mark as done' : 'Failed to mark as not done' - ); - }, - }); - - const toHeaderLinkId = useNonPrimaryEmailLinkIdHeader(); - - const getMarkDoneNavigationTargetId = () => { - if (!soup) return; - - const focusedId = soup.focus.id(); - const navigationOptions = { - wrapNavigation: false, - skipGroupHeaders: true, - skipLoadMore: true, - }; - const candidates = [ - soup.navigate.peekOffset(1, navigationOptions)?.row, - soup.navigate.peekOffset(-1, navigationOptions)?.row, - ]; - return candidates.find((row) => row && row.id !== focusedId)?.id; - }; - - const isThreadDone = () => { - const thread = threadQuery.data; - return thread ? !thread.inbox_visible : false; - }; - - // Doneness is derived, not stored: `inbox_visible` is recomputed from the - // thread's messages as "some message has INBOX and not SENT", and the inbox - // view additionally requires an inbound message. A thread with only sent - // messages can satisfy neither, so it is permanently done — unarchiving it - // reverts on the next recompute and meanwhile labels its sent messages - // INBOX, in Gmail too. Only offer the reversal when it can hold. - const canMarkThreadNotDone = () => { - const thread = threadQuery.data; - if (!thread) return false; - return !thread.inbox_visible && thread.latest_inbound_message_ts != null; - }; - - // Resolve a thread's soup representation for the mark-done / mark-not-done - // paths: the live list row when it's rendered, else the normalized - // soup-cache entity. Shared by markThreadNotDone and archiveThread. - const resolveThreadSoupLookup = (threadId: string) => { - const selectedRow = soup?.items.get(threadId); - const cachedItem = selectedRow ? undefined : getSoupEntityById(threadId); - return { selectedRow, cachedItem }; - }; - - const markThreadNotDone = () => { - const thread = threadQuery.data; - if (!thread?.db_id) return false; - - if (thread.inbox_visible) return false; - - if (!canMarkThreadNotDone()) return false; - - // Mark-not-done issues the /archived request itself (plus notification - // and soup-cache restore), so the path below skips archiveMutation and - // only mirrors its thread-cache handling via trackExternalThreadArchive. - const { selectedRow, cachedItem } = resolveThreadSoupLookup(thread.db_id); - - const entity = - selectedRow?.original ?? - (cachedItem && - cachedItem.tag !== 'channelThread' && - cachedItem.tag !== 'calendarEvent' - ? mapApiSoupItemToEntity(cachedItem) - : undefined); - - if (entity && markNotDoneAction.canExecute(entity)) { - void trackExternalThreadArchive( - thread.db_id, - markNotDoneAction.execute([entity]), - false - ); - } else { - // No soup entity to drive the action from — the mark-done removal - // evicted it from the soup caches (or its done state hasn't caught up - // with the thread's): unarchive directly, then refetch the thread's - // soup item to reinsert its rows and refetch the lists. - const threadId = thread.db_id; - // Snapshot the thread's notification ids now — the entity path restores - // them via executeMarkEntitiesUndone, so mirror that here or they stay - // done after the unarchive. - const notificationIds = ( - notificationSource.notificationsByEntity()[ - compositeEntity({ type: 'email_thread', id: threadId }) - ] ?? [] - ).map((n) => n.id); - archiveMutation.mutate( - { - threadId, - archive: false, - linkId: toHeaderLinkId(thread.link_id), - }, - { - onSuccess: async () => { - // The live notification stream only carries not-done - // notifications, so the thread's done ids may have aged out of - // the local cache — merge the server's view (best effort: the - // unarchive itself already succeeded). - const serverIds = await fetchDoneNotificationIdsByEventItemIds([ - threadId, - ]).catch(() => []); - const allIds = [...new Set([...notificationIds, ...serverIds])]; - // Record for the undo/redo hooks, which re-mark these when the - // archive flip is replayed. - restoredNotificationIds.set(threadId, allIds); - if (allIds.length > 0) { - setDoneOverride(allIds, false); - try { - await bulkMarkNotificationsAsUndone(allIds); - } catch { - // The unarchive itself succeeded, so keep that outcome and - // let the override fall back to the server's done state. - setDoneOverride(allIds, undefined); - toast.failure('Failed to mark as not done'); - } - } - void refetchSoupEntity(threadId, 'emailThread'); - invalidateAllSoup(); - }, - } - ); - } - - return true; - }; - - const archiveThread = (opts?: ArchiveThreadOptions) => { - const thread = threadQuery.data; - // `=== true` because callers may pass this straight to an event handler. - const markDoneOpts = { - silent: opts?.silent === true, - onUndoHandle: opts?.onUndoHandle, - nextEntityId: opts?.nextEntityId, - }; - - if (!thread?.db_id) return false; - - if (!thread.inbox_visible) return false; - - // Mark done issues the /archived request itself (with undo support), so - // the paths below skip archiveMutation and only mirror its thread-cache - // handling via trackExternalThreadArchive. - const { selectedRow, cachedItem } = resolveThreadSoupLookup(thread.db_id); - - if (soup && selectedRow) { - void trackExternalThreadArchive( - thread.db_id, - markAsDoneAction.executeWithSoup( - [selectedRow.original], - soup, - (nextEntity) => { - const splitHandle = splitPanel?.handle; - if (!splitHandle) return; - void openEntityInSplitFromUnifiedList(nextEntity, { - splitHandle, - mergeHistory: true, - referredFrom: splitHandle.referredFrom(), - }); - }, - markDoneOpts - ) - ); - } else if ( - cachedItem && - cachedItem.tag !== 'channelThread' && - cachedItem.tag !== 'calendarEvent' - ) { - // Not rendered inside a soup list (e.g. thread opened in a split): no - // row to drive the action from, so mark done via the cached soup entity - // so soup views drop the thread and its notifications settle. - void trackExternalThreadArchive( - thread.db_id, - markAsDoneAction.execute( - [mapApiSoupItemToEntity(cachedItem)], - undefined, - markDoneOpts - ) - ); - } else { - // No soup entity to drive mark-done from (e.g. the thread was opened - // directly, so no soup list or cache exists): archive directly, still - // honoring the caller's silent/undo-handle options — undo-send depends - // on the handle to reverse this archive. - archiveMutation.mutate({ - threadId: thread.db_id, - archive: true, - linkId: toHeaderLinkId(thread.link_id), - silent: markDoneOpts.silent, - onUndoHandle: markDoneOpts.onUndoHandle, - }); - } - - return true; - }; - - const markSeenMutation = useMarkThreadAsSeenMutation(); - const markUnreadMutation = useMarkThreadAsUnreadMutation(); - - // Viewing a thread marks it read (EmailDebouncedReadMarker), so each thread - // starts with the toggle offering Mark Unread. - const [threadMarkedUnread, setThreadMarkedUnread] = createSignal(false); - createEffect(() => { - void props.threadID; - setThreadMarkedUnread(false); - }); - - const markThreadUnread = () => { - const thread = threadQuery.data; - if (!thread?.db_id) return false; - if (threadMarkedUnread()) return false; - // A toggle mid-flight would race the pending request; ignore it. - if (markUnreadMutation.isPending || markSeenMutation.isPending) { - return false; - } - - const threadId = thread.db_id; - setThreadMarkedUnread(true); - markUnreadMutation.mutate( - { threadId, linkId: thread.link_id }, - { - onSuccess: () => { - toast.success('Marked as unread', { - duration: 3_000, - stack: true, - hideOnMobile: true, - }); - }, - onError: () => { - setThreadMarkedUnread(false); - toast.failure('Failed to mark as unread'); - void refetchSoupEntity(threadId, 'emailThread'); - }, - } - ); - return true; - }; - - const markThreadRead = () => { - const thread = threadQuery.data; - if (!thread?.db_id) return false; - if (!threadMarkedUnread()) return false; - // A toggle mid-flight would race the pending request; ignore it. - if (markUnreadMutation.isPending || markSeenMutation.isPending) { - return false; - } - - const threadId = thread.db_id; - setThreadMarkedUnread(false); - markSeenMutation.mutate( - { threadId, linkId: toHeaderLinkId(thread.link_id) }, - { - onSuccess: () => { - toast.success('Marked as read', { - duration: 3_000, - stack: true, - hideOnMobile: true, - }); - }, - onError: () => { - setThreadMarkedUnread(true); - toast.failure('Failed to mark as read'); - void refetchSoupEntity(threadId, 'emailThread'); - }, - } - ); - return true; - }; - - const currentUserEmail = useEmail(); - - const blockSender = () => { - const thread = threadQuery.data; - if (!thread?.messages?.length) return false; - - const userEmail = currentUserEmail()?.toLowerCase(); - const senderEmail = thread.messages.find( - (m) => - m.from?.email && - (!userEmail || m.from.email.toLowerCase() !== userEmail) - )?.from?.email; - - if (!senderEmail) return false; - - blockSenderWithToast(senderEmail, toHeaderLinkId(thread.link_id)); - return true; - }; - - const getSenderEmail = (): string | undefined => { - const thread = threadQuery.data; - if (!thread?.messages?.length) return undefined; - - const userEmail = currentUserEmail()?.toLowerCase(); - return thread.messages.find( - (m) => - m.from?.email && - (!userEmail || m.from.email.toLowerCase() !== userEmail) - )?.from?.email; - }; - - const markSenderSignal = () => { - const senderEmail = getSenderEmail(); - if (!senderEmail) return false; - markSenderSignalWithToast( - senderEmail, - toHeaderLinkId(threadQuery.data?.link_id) - ); - return true; - }; - - const markSenderNoise = () => { - const senderEmail = getSenderEmail(); - if (!senderEmail) return false; - markSenderNoiseWithToast( - senderEmail, - toHeaderLinkId(threadQuery.data?.link_id) - ); - return true; - }; - - const [messagesListRef, setMessagesListRef] = createSignal< - HTMLDivElement | undefined - >(undefined); - const [messagesContainerRef, setMessagesContainerRef] = createSignal< - HTMLDivElement | undefined - >(undefined); - - /** Selecting a message clears the hidden-chip stop explicitly (no createEffect). */ - const setFocused = (messageID: string | undefined) => { - if (messageID) { - setHiddenChipFocused(false); - const list = messagesListRef(); - const button = list ? hiddenMessagesControl(list) : undefined; - if (button && document.activeElement === button) { - button.blur(); - blockElementSignal.get()?.focus({ preventScroll: true }); - } - } - setFocusedMessageId(messageID); - }; - - const isContainerFilled = () => { - const messageList = messagesListRef(); - const containerRef = messagesContainerRef(); - - if ( - !messageList || - !containerRef || - !untrack(() => threadQuery.data)?.db_id || - threadQuery.isFetching - ) { - return false; - } - - // Older-page prefetch when the first batch does not overflow moved to - // MessageList (`listNeedsOlderPage` + `fetchOlderMessages`). - return true; - }; - - const onInitialDataLoad = (callback: () => boolean) => { - createEffect(() => { - if (hasHandledTarget()) return; - const fetching = threadQuery.isFetching; - if (fetching) return; - // Check if initial loading is complete - const isInitialLoadComplete = - (isContainerFilled() || threadQuery.hasNextPage === false) && - !threadQuery.isFetching; - - if (!isInitialLoadComplete) return; - - // Skip if basic requirements not met - if (!untrack(messagesListRef)) { - return; - } - - setHasHandledTarget(callback()); - }); - }; - - const onExpandMessageBody = (messageID: string, expanded: boolean) => { - setExpandedMessageBodyIds(messageID, expanded); - }; - - // When the provider unmounts (user navigates away), clear the thread query - // cache if a draft was saved during this session. This ensures the next visit - // fetches fresh data from the server (which includes the saved draft). - // We can't invalidate/refetch while mounted because any query state change - // triggers SolidQuery's createClientSubscriber → Resource.refetch() → Suspense - // DOM detach, which resets scroll position. - // `props.threadID` chains through the email block's non-keyed - // `` accessor, which is already stale during - // disposal; capture it while mounted instead of reading it in the cleanup. - createEffect(() => { - const threadID = props.threadID; - onCleanup(() => { - if (draftSavedThreadIds.has(threadID)) { - draftSavedThreadIds.delete(threadID); - queryClient.removeQueries({ - queryKey: emailKeys.threadMessages(threadID).queryKey, - }); - } - }); - }); - - return ( - - threadQuery.data), - recipientOptions: createMemo(getRecipientOptions), - onRecipientsChange, - archiveThread, - isThreadDone, - canMarkThreadNotDone, - markThreadNotDone, - isThreadMarkedUnread: threadMarkedUnread, - markThreadUnread, - markThreadRead, - getMarkDoneNavigationTargetId, - blockSender, - markSenderSignal, - markSenderNoise, - messagesContainerRef, - messagesListRef, - query: { - hasMore: () => threadQuery.hasNextPage ?? false, - fetchNextPage: threadQuery.fetchNextPage, - isFetching: () => - threadQuery.isLoading || threadQuery.isFetchingNextPage, - refetch: threadQuery.refetch, - }, - drafts: { - deleteDraftForMessage, - getDraftForMessage, - initialDraftsSettled, - }, - messages: { - focusedID: focusedMessageId, - setFocused, - hiddenChipFocused, - setHiddenChipFocused, - hovered: hoveredStop, - setHovered: setHoveredStop, - targetMessageID: targetMessageId, - setTargetMessageID: setTargetMessageId, - list: createMemo(() => threadQuery.data?.filtered ?? []), - unfiltered: createMemo(() => threadQuery.data?.messages ?? []), - // Google's CATEGORY_PERSONAL classification is inconsistent across - // identical messages, so promote it per-sender across the thread - personalSenders: createMemo(() => { - const senders = new Set(); - for (const message of threadQuery.data?.messages ?? []) { - const email = message.from?.email?.toLowerCase(); - if (!email) continue; - if ( - message.labels.some((l) => l.name === 'CATEGORY_PERSONAL') - ) { - senders.add(email); - } - } - return senders; - }), - expandedBodyIds: expandedMessageBodyIds, - setExpandedBodyId: onExpandMessageBody, - isBodyExpanded: (id: string) => expandedMessageBodyIds[id] ?? false, - replyingToMessageId, - setReplyingToMessageId, - bottomReplyOpen, - setBottomReplyOpen, - }, - mobileReplyComposer: { - open: mobileReplyComposerOpen, - messageId: mobileReplyComposerMessageId, - setOpen: setMobileReplyComposerOpen, - openForMessage: (id: string) => { - setMobileReplyComposerMessageId(id); - setMobileReplyComposerOpen(true); - }, - close: () => { - setMobileReplyComposerOpen(false); - setMobileReplyComposerMessageId(undefined); - }, - }, - replyRequest: { - messageId: replyRequestMessageId, - replyType: replyRequestType, - set: (messageId: string, replyType: ReplyType) => { - setReplyRequestMessageId(messageId); - setReplyRequestType(replyType); - }, - clear: () => { - setReplyRequestMessageId(undefined); - setReplyRequestType(undefined); - }, - }, - permissions: createMemo(() => { - const perms = getPermissions(threadQuery.data?.access_level); - return { - type: perms, - isOwner: hasPermissions(perms, Permissions.OWNER), - }; - }), - initialLoadComplete: hasHandledTarget, - onInitialDataLoad, - }} - > - {props.children} - - - ); -} - -export function useEmailContext() { - const ctx = useContext(EmailContext); - if (!ctx) { - throw new Error('useEmailContext must be used within an EmailProvider'); - } - return ctx; -} - -export function useMaybeEmailContext() { - return useContext(EmailContext); -} diff --git a/apps/web/src/features/block-email/component/EmailInput.tsx b/apps/web/src/features/block-email/component/EmailInput.tsx deleted file mode 100644 index c2b2436018b..00000000000 --- a/apps/web/src/features/block-email/component/EmailInput.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useEmailContext } from '@block-email/component/EmailContext'; -import { revealMessageAfterLayout } from '@block-email/util/scrollToMessage'; -import type { - ApiDraftOutputDbId, - ApiMessage, -} from '@service-email/generated/schemas'; -import { Layer } from '@ui'; -import { - type Accessor, - createMemo, - createSignal, - type Setter, - Show, -} from 'solid-js'; -import { decodeBase64Utf8 } from '../util/decodeBase64'; -import { plainTextToHtml } from '../util/plainTextToHtml'; -import { BaseInput } from './BaseInput'; - -interface EmailInputProps { - replyingTo: Accessor; - draft?: ApiMessage; - setShowReply?: Setter; - markdownDomRef?: (ref: HTMLDivElement) => void | HTMLDivElement; - unframed?: boolean; - mobileDrawer?: { - onClose: () => void; - }; -} - -export function EmailInput(props: EmailInputProps) { - const ctx = useEmailContext(); - - // The seed identity of this composer: which version of which draft it - // mounts from. When the server sends a newer save of that draft (a thread - // opened from a cached snapshot revalidates, or the draft was edited on - // another device), the key changes and the input remounts, seeding from - // the newer draft through the ordinary mount path — but only until the - // user engages with the composer. From then on the mounted instance is - // authoritative (later fetches are typically echoes of its own saves), so - // the key latches and the input never remounts underneath the user. - const [engaged, setEngaged] = createSignal(false); - const seedKey = createMemo((prev) => - engaged() && prev !== undefined - ? prev - : props.draft - ? `${props.draft.db_id}:${props.draft.updated_at}` - : 'no-draft' - ); - - const draftHTML = createMemo(() => { - const encoded = props.draft?.body_html_sanitized; - if (!encoded) { - const plainText = props.draft?.body_text; - if (!plainText) return ''; - return plainTextToHtml(plainText); - } - const decodedHtml = decodeBase64Utf8(encoded); - return decodedHtml; - }); - - async function afterSend(newMessageId: ApiDraftOutputDbId | null) { - // Collapse the input after sending (Gmail-style). - props.setShowReply?.(false); - - if (!newMessageId) return; - - ctx.messages.setFocused(newMessageId); - await ctx.query.refetch(); - revealMessageAfterLayout( - newMessageId, - ctx.messages.list(), - ctx.messagesListRef() - ); - } - - return ( - - - {(seed) => ( - - setEngaged(true)} - sideEffectOnSend={afterSend} - onMarkDone={ctx.archiveThread} - setShowReply={props.setShowReply} - markdownDomRef={props.markdownDomRef} - unframed={props.unframed} - mobileDrawer={props.mobileDrawer} - isEditingExisting={ - props.replyingTo() == null && props.draft != null - } - /> - - )} - - - ); -} diff --git a/apps/web/src/features/block-email/component/EmailMessageBody.tsx b/apps/web/src/features/block-email/component/EmailMessageBody.tsx deleted file mode 100644 index fecde97d9ac..00000000000 --- a/apps/web/src/features/block-email/component/EmailMessageBody.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import { StaticMarkdown } from '@core/component/LexicalMarkdown/component/core/StaticMarkdown'; -import { channelTheme } from '@core/component/LexicalMarkdown/theme'; -import { DEV_MODE_ENV } from '@core/constant/featureFlags'; -import { useEmail } from '@core/context/user'; -import { - parseEmailContent, - processEmailColors, - type ThemeColorParams, -} from '@core/email'; -import { interceptMailtoLinks } from '@core/util/interceptMailtoLinks'; -import DotsThree from '@phosphor/dots-three.svg'; -import type { ApiMessage } from '@service-email/generated/schemas'; -import { Button, cn } from '@ui'; -import { - type Accessor, - createEffect, - createMemo, - createSignal, - Match, - onCleanup, - Show, - Switch, - untrack, -} from 'solid-js'; -import { themeReactive } from '../../theme/signals/themeReactive'; -import { themeUpdate } from '../../theme/signals/themeSignals'; -import { EMAIL_BODY_CONTAINMENT_CSS } from '../util/emailBodyContainmentCss'; -import { fitToWidthZoom } from '../util/fitToWidthZoom'; -import { isPersonalMessage } from '../util/isPersonalMessage'; -import { - fetchImagesViaPlatform, - resolveCidImages, -} from '../util/resolveEmailImages'; - -interface EmailMessageBodyProps { - message: ApiMessage; - /** Sender emails (lowercased) with a CATEGORY_PERSONAL message in the thread */ - personalSenders: Accessor>; - isBodyExpanded: Accessor; - setExpandedMessageBody: (id: string) => void; - setFocusedMessageId: (messageID: string | undefined) => void; - isFirstMessageInThread: boolean; - isFocused: boolean; -} - -export function EmailMessageBody(props: EmailMessageBodyProps) { - const [showFullHTML, setShowFullHTML] = createSignal(false); - const userEmail = useEmail(); - - if (DEV_MODE_ENV) { - console.log( - 'labels', - props.message.labels.map((l) => l.name) - ); - } - - // If we don't have body replyless, it may be because it hasn't been generated yet. For instance, this is the case immediately after a message is sent. We can use the HTML to parse the message correctly. - const bodyReplyless = createMemo(() => { - let replyless = props.message.body_replyless ?? ''; - if (!replyless) { - if (props.message.body_html_sanitized) { - const parser = new DOMParser(); - const doc = parser.parseFromString( - props.message.body_html_sanitized.toString(), - 'text/html' - ); - const styleTags = Array.from(doc.head?.querySelectorAll('style') ?? []) - .map((style) => style.outerHTML) - .join('\n'); - const quoted = doc.body.querySelector('.macro_quote'); - if (quoted) { - quoted?.remove(); - return styleTags - ? `${styleTags}\n${doc.body.innerHTML}` - : doc.body.innerHTML; - } - } - } - return replyless; - }); - - const isPlaintext = () => !props.message.body_html_sanitized; - - const parsedBodyHtml = createMemo(() => { - return props.message.body_html_sanitized - ? parseEmailContent( - props.message.body_html_sanitized, - !showFullHTML(), - !showFullHTML() - ) - : undefined; - }); - - const parsedBodyReplyless = createMemo(() => { - const processed = bodyReplyless(); - return processed ? parseEmailContent(processed) : undefined; - }); - - const source = () => { - return showFullHTML() || props.isFirstMessageInThread - ? parsedBodyHtml() - : parsedBodyReplyless(); - }; - - // Sent-from-Macro messages strip the quoted thread from body_macro at send - // time, and the backend skips replyless trimming for "Fwd:" subjects — so a - // quote in the full html means there is hidden content regardless of - // body_replyless. - const bodyHtmlHasQuote = createMemo(() => { - const html = props.message.body_html_sanitized; - if (!html || !props.message.body_macro) return false; - const doc = new DOMParser().parseFromString(html.toString(), 'text/html'); - return doc.body.querySelector('.macro_quote') !== null; - }); - - const hasHiddenReplyStructure = () => { - return ( - !isPlaintext() && - (bodyHtmlHasQuote() || - (bodyReplyless() && - bodyReplyless().toString().replace(/\s+/g, '').length !== - props.message.body_html_sanitized?.toString().replace(/\s+/g, '') - .length) || - source()?.signature) - ); - }; - - // TODO it might be nice to do some additional checks here, e.g. check if this message was sent from a user that the user has sent a message to before. - const isPersonal = createMemo(() => - isPersonalMessage(props.message, userEmail(), props.personalSenders()) - ); - - const isMacroSender = createMemo(() => { - const senderEmail = props.message.from?.email?.toLowerCase(); - return senderEmail?.endsWith('@macro.com') ?? false; - }); - - const host = createMemo(() => { - themeUpdate(); - const hostContainer = document.createElement('div'); - const shadow = hostContainer.attachShadow({ mode: 'open' }); - // Style that uses a CSS variable to control image visibility - const styleEl = document.createElement('style'); - // Normalize font in email - const fontOverride = - isPersonal() && !isMacroSender() - ? `*:not(code):not(pre):not(code *):not(pre *):not([data-macro-btn]){font-family: system-ui, sans-serif !important; font-size: inherit !important; line-height: 1.5 !important;}` - : ''; - // Containment (images, signatures, quotes, pre/code) lives in - // EMAIL_BODY_CONTAINMENT_CSS so the snapshot harness stays in lockstep. - styleEl.textContent = `${EMAIL_BODY_CONTAINMENT_CSS}${fontOverride}`; - shadow.appendChild(styleEl); - const messageDiv = document.createElement('div'); - messageDiv.innerHTML = source()?.mainContent ?? ''; - // Mark button-like anchors so the font override doesn't break their sizing - for (const a of messageDiv.querySelectorAll( - 'a[style]' - )) { - if (a.style.backgroundColor) { - a.dataset.macroBtn = ''; - for (const child of a.querySelectorAll('*')) { - (child as HTMLElement).dataset.macroBtn = ''; - } - } - } - // Open links in a new tab instead of navigating the current one - for (const a of messageDiv.querySelectorAll('a[href]')) { - a.setAttribute('target', '_blank'); - a.setAttribute('rel', 'noopener noreferrer'); - } - // Raw mailto: anchors open the in-app composer instead of the OS mail client - interceptMailtoLinks(messageDiv); - messageDiv.style.userSelect = 'text'; - // Safari resolves only the -webkit- prefixed form of user-select - // (unprefixed shipped in Safari 26.4), and WebKit inherits the app-wide - // `user-select: none` through the shadow boundary — without the prefix, - // email text isn't selectable in Safari. - messageDiv.style.setProperty('-webkit-user-select', 'text'); - messageDiv.style.cursor = 'auto'; - shadow.appendChild(messageDiv); - return hostContainer; - }); - - // Resolve images in two sequential steps, resolving cid urls and then fetching images on tauri via plaformFetch - createEffect(() => { - const root = host().shadowRoot; - if (!root) return; - const attachments = props.message.attachments; - - const blobUrls: string[] = []; - let disposed = false; - onCleanup(() => { - disposed = true; - for (const url of blobUrls) URL.revokeObjectURL(url); - }); - - queueMicrotask(async () => { - if (disposed) return; - resolveCidImages(root, attachments); - if (disposed) return; - await fetchImagesViaPlatform(root, blobUrls, () => disposed); - }); - }); - - // Process the email colors when: the theme changes, or the source HTML changes. - createEffect(() => { - themeUpdate(); - showFullHTML(); - const root = host().shadowRoot; - if (root) { - if (isPersonal() || !source()?.hasTable) { - queueMicrotask(() => { - untrack(() => { - const theme: ThemeColorParams = { - inkL: themeReactive.c0.l[0](), - inkC: themeReactive.c0.c[0](), - inkH: themeReactive.c0.h[0](), - panelL: themeReactive.b1.l[0](), - accentL: themeReactive.a0.l[0](), - accentC: themeReactive.a0.c[0](), - accentH: themeReactive.a0.h[0](), - }; - processEmailColors(root, theme); - }); - }); - } else { - const contentWrapper = root.querySelector('div'); - if (contentWrapper instanceof HTMLElement) { - contentWrapper.style.setProperty( - 'background-color', - 'white', - 'important' - ); - // Some emails don't have a color set, so we need to set it to black to ensure text is readable againnst white background - contentWrapper.style.setProperty('color', 'black'); - } - } - } - }); - - // Hide images when the message body is not expanded (via CSS variable) - createEffect(() => { - const container = host(); - const shouldHide = !props.isBodyExpanded(); - container.style.setProperty( - '--macro-email-img-display', - shouldHide ? 'none' : 'initial' - ); - }); - - // After containment, shrink leftover wide canvases (newsletter tables) - // to the pane. Pathological width is floored so type stays readable. - createEffect(() => { - const container = host(); - // Re-run when source changes - source(); - - const clearScale = () => { - const root = container.shadowRoot; - if (!root) return; - const messageDiv = root.querySelector('div'); - if (messageDiv instanceof HTMLElement) { - messageDiv.style.zoom = ''; - messageDiv.style.overflow = ''; - messageDiv.style.overflowX = ''; - } - }; - - if (!props.isBodyExpanded()) { - clearScale(); - return; - } - - const applyScale = () => { - const root = container.shadowRoot; - if (!root) return; - const messageDiv = root.querySelector('div'); - if (!messageDiv || !(messageDiv instanceof HTMLElement)) return; - - // Reset any previous scaling before measuring. overflowX is a longhand - // and survives clearing the overflow shorthand. - messageDiv.style.zoom = ''; - messageDiv.style.overflow = ''; - messageDiv.style.overflowX = ''; - - const fit = fitToWidthZoom({ - containerWidth: container.clientWidth, - contentWidth: messageDiv.scrollWidth, - }); - if (!fit) { - // When content fits, leave overflow alone. overflow:auto on a fitting - // body turns hidden tracking-pixel divs into a message-height scrollbar. - return; - } - // Use zoom instead of transform: scale() so backgrounds, borders, and - // layout shrink together without clipping. The floor keeps leftover - // canvas overflow (a 600px newsletter on a skinny pane) readable. - messageDiv.style.zoom = `${fit.zoom}`; - if (fit.overflowsAfterZoom) { - messageDiv.style.overflowX = 'auto'; - } - }; - - // Re-run on container resize (e.g. orientation change, split resize) - const resizeObserver = new ResizeObserver(() => applyScale()); - resizeObserver.observe(container); - - // Re-run when images inside the shadow DOM finish loading - const root = container.shadowRoot; - const images = root ? Array.from(root.querySelectorAll('img')) : []; - const onImageLoad = () => applyScale(); - for (const img of images) { - if (!img.complete) { - img.addEventListener('load', onImageLoad); - } - } - - // Initial measurement after layout - requestAnimationFrame(() => applyScale()); - - onCleanup(() => { - resizeObserver.disconnect(); - for (const img of images) { - img.removeEventListener('load', onImageLoad); - } - }); - }); - - return ( -
{ - if (!props.isBodyExpanded() && props.message.db_id) { - props.setExpandedMessageBody(props.message.db_id); - props.setFocusedMessageId(props.message.db_id); - } else if (props.message.db_id) { - props.setFocusedMessageId(props.message.db_id); - } - }} - > -
- - {/* If available, we use body_macro to render "Macro-fied" email content in static markdown with, e.g. correctly styled document mentions. */} - - {(bodyMacro) => { - return ( - - ); - }} - - - - - {host()} - - -
- -
-
-
-
- ); -} diff --git a/apps/web/src/features/block-email/component/EmailTaskButton.tsx b/apps/web/src/features/block-email/component/EmailTaskButton.tsx new file mode 100644 index 00000000000..6c2fbe4cf13 --- /dev/null +++ b/apps/web/src/features/block-email/component/EmailTaskButton.tsx @@ -0,0 +1,22 @@ +import { AnimatedTaskIcon } from '@icon/wide-task'; +import { Button } from '@ui'; +import { createSignal } from 'solid-js'; +export function EmailTaskButton(props: { onClick: () => void }) { + const [hovering, setHovering] = createSignal(false); + + return ( + + ); +} diff --git a/apps/web/src/features/block-email/component/MessageContainer.tsx b/apps/web/src/features/block-email/component/MessageContainer.tsx deleted file mode 100644 index acaa07738e9..00000000000 --- a/apps/web/src/features/block-email/component/MessageContainer.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import { EmailAttachmentPill } from '@block-email/component/AttachmentPill'; -import { CollapsedMessage } from '@block-email/component/CollapsedMessage'; -import { useEmailContext } from '@block-email/component/EmailContext'; -import { EmailInput } from '@block-email/component/EmailInput'; -import { EmailMessageBody } from '@block-email/component/EmailMessageBody'; -import { EmailMessageTopBar } from '@block-email/component/EmailMessageTopBar'; -import { MessageCard } from '@block-email/component/MessageCard'; -import { getSenderMacroId } from '@block-email/util/emailUser'; -import { revealMessageAfterLayout } from '@block-email/util/scrollToMessage'; -import { useSplitLayout } from '@components/app/split-layout/layout'; -import { FloatingInputLoader } from '@core/component/FloatingInputLoader'; -import { ImageGalleryPreview } from '@core/component/ImageGalleryPreview'; -import { toast } from '@core/component/Toast/Toast'; -import { UserIcon, type UserIconProps } from '@core/component/UserIcon'; -import { VideoPreview } from '@core/component/VideoPreview'; -import { fileTypeToBlockName } from '@core/constant/allBlocks'; -import { isTouchDevice } from '@core/mobile/isTouchDevice'; -import { Telemetry } from '@macro-inc/observability'; -import { refetchSoupEntity } from '@queries/soup/cache'; -import { emailClient } from '@service-email/client'; -import type { ApiMessage, Attachment } from '@service-email/generated/schemas'; -import { storageServiceClient } from '@service-storage/client'; -import type { FileType } from '@service-storage/generated/schemas/fileType'; -import { createMemo, createSignal, For, Match, Show, Switch } from 'solid-js'; -import { BottomReplyButtons } from './BottomReplyButtons'; - -interface MessageContainerProps { - message: ApiMessage; - isFirstMessage: boolean; - isLastMessage: boolean; - isSelected: boolean; - allowHover: boolean; - isExpanded: boolean; - markdownDomRef?: (ref: HTMLDivElement) => void | HTMLDivElement; -} - -export function MessageContainer(props: MessageContainerProps) { - const context = useEmailContext(); - const draftChild = createMemo(() => { - if (!props.message.db_id) return undefined; - const draft = context.drafts.getDraftForMessage(props.message.db_id); - if (!draft) return undefined; - return draft; - }); - - const [expandedHeader, setExpandedHeader] = createSignal(false); - const [showReplyInternal, setShowReplyInternal] = - createSignal(false); - - const showReply = () => - showReplyInternal() || - context.messages.replyingToMessageId() === props.message.db_id; - - const showInlineReplyInput = createMemo(() => { - if (isTouchDevice()) return false; - if (!props.isLastMessage) return showReply() || !!draftChild(); - return context.messages.bottomReplyOpen() || !!draftChild(); - }); - - const showDesktopLastReplyControls = createMemo( - () => - props.isLastMessage && - !!props.message.db_id && - !isTouchDevice() && - context.drafts.initialDraftsSettled() - ); - - const showInlineReplyArea = createMemo( - () => - context.permissions().isOwner && - (showInlineReplyInput() || showDesktopLastReplyControls()) - ); - - const setShowReply = (value: boolean | ((prev: boolean) => boolean)) => { - const newValue = - typeof value === 'function' ? value(showReplyInternal()) : value; - setShowReplyInternal(newValue); - if ( - !newValue && - context.messages.replyingToMessageId() === props.message.db_id - ) { - context.messages.setReplyingToMessageId(undefined); - } - // Reply/Reply-All/Forward actions on the last message open the bottom - // reply input (the inline reply only renders for non-last messages). - if (props.isLastMessage) { - context.messages.setBottomReplyOpen(newValue); - } - }; - - const senderMacroId = createMemo(() => getSenderMacroId(props.message)); - - const senderIconProps = createMemo(() => { - const senderId = senderMacroId(); - const photoUrl = props.message.from?.photo_url ?? undefined; - if (senderId) return { id: senderId, photoUrl }; - return { email: props.message.from?.email ?? '', photoUrl }; - }); - - const isBodyExpanded = createMemo(() => { - return props.isExpanded; - }); - - // Hide attachments that are referenced in inline images - const inlineContentIds = createMemo(() => { - const set = new Set(); - const collectFromHtml = (html: string) => { - const regex = /src=["']cid:([^"']+)["']/gi; - let match = regex.exec(html); - while (match !== null) { - const raw = match[1]; - const normalized = raw.replace(/[<>]/g, '').trim(); - if (normalized) set.add(normalized); - match = regex.exec(html); - } - }; - collectFromHtml(props.message.body_html_sanitized ?? ''); - return set; - }); - - const visibleAttachments = createMemo(() => { - return props.message.attachments.filter((a) => { - if (!a.db_id) return false; - const contentId = a.content_id?.toString(); - if (!contentId) return true; - const normalized = contentId.replace(/[<>]/g, '').trim(); - return !inlineContentIds().has(normalized); - }); - }); - - const imageAttachmentsWithSfs = createMemo(() => { - return visibleAttachments().filter( - (a) => a.mime_type?.startsWith('image/') && a.sfs_id - ); - }); - - const videoAttachmentsWithSfs = createMemo(() => { - return visibleAttachments().filter( - (a) => a.mime_type?.startsWith('video/') && a.sfs_id - ); - }); - - const otherAttachments = createMemo(() => { - return visibleAttachments().filter( - (a) => - !a.sfs_id || - (!a.mime_type?.startsWith('image/') && - !a.mime_type?.startsWith('video/')) - ); - }); - - const { openWithSplit } = useSplitLayout(); - const draftAttachments = createMemo(() => { - return props.message.attachments_draft ?? []; - }); - - const forwardedAttachments = createMemo(() => { - return props.message.attachments_forwarded ?? []; - }); - - const onClickAttachment = async ( - attachment: Attachment, - fileType: FileType | undefined - ) => { - const dbId = attachment.db_id; - if (!dbId) return; - const response = await emailClient.getOrCreateAttachmentDocumentId({ - id: dbId, - }); - if (response.isErr()) { - toast.failure('Failed to get attachment. Please try again.'); - return Telemetry.error( - new Error( - 'Failed to get or create attachment document id: ' + response.error - ) - ); - } - const { document_id } = response.value; - - const maybeDocumentMetadata = - await storageServiceClient.getDocumentMetadata({ - documentId: document_id, - }); - if (maybeDocumentMetadata.isErr()) { - toast.failure('Failed to get attachment. Please try again.'); - return Telemetry.error( - new Error( - 'Failed to get or create attachment document metadata: ' + - maybeDocumentMetadata.error - ) - ); - } - - refetchSoupEntity(document_id, 'document'); - - const blockName = fileType ? fileTypeToBlockName(fileType) : 'unknown'; - openWithSplit( - { type: blockName, id: document_id }, - { preferNewSplit: true } - ); - }; - - // The card selects itself; expanding is the collapsed row's extra behaviour. - const handleExpand = () => { - const messageId = props.message.db_id; - if (!messageId) return; - context.messages.setExpandedBodyId(messageId, true); - revealMessageAfterLayout( - messageId, - context.messages.list(), - context.messagesListRef() - ); - }; - - return ( - - } - > -
- - -
- } - /> -
- - context.messages.setExpandedBodyId(id, true) - } - setFocusedMessageId={context.messages.setFocused} - isFirstMessageInThread={props.isFirstMessage} - isFocused={props.isSelected} - /> -
- {/* Image attachments */} - 0}> -
- ({ - id: a.sfs_id!, - }))} - variant="small" - attachmentIds={imageAttachmentsWithSfs().map((a) => a.db_id!)} - /> -
-
- - {/* Video attachments */} - 0}> - - {(attachment) => ( - - )} - - - - {/* Other attachments (non-media or without sfs_id) */} - 0}> -
- - {(attachment) => ( - - onClickAttachment(attachment, fileType) - } - /> - )} - -
-
- - {/* Draft attachments */} - 0 || forwardedAttachments().length > 0 - } - > -
- - {(attachment) => ( - - )} - - - {(attachment) => ( - - )} - -
-
- - -
- - - -
- - - props.message} - setShowReply={setShowReply} - draft={draftChild()} - markdownDomRef={ - props.isLastMessage ? props.markdownDomRef : undefined - } - unframed - /> - - - - - -
-
-
-
-
- ); -} diff --git a/apps/web/src/features/block-email/component/ModalsProvider.tsx b/apps/web/src/features/block-email/component/ModalsProvider.tsx index ebb1ec8a6d0..a24b3e7b095 100644 --- a/apps/web/src/features/block-email/component/ModalsProvider.tsx +++ b/apps/web/src/features/block-email/component/ModalsProvider.tsx @@ -1,13 +1,14 @@ +import { useEmailThreadState } from '@app/features/email-thread/context/email-thread-state-context'; +import { getPermissions } from '@core/component/SharePermissions'; import { ShareBlockModal, ShareDialogContext, } from '@core/component/TopBar/ShareButton'; import { ENABLE_EMAIL_SHARING } from '@core/constant/featureFlags'; import { createSignal, type ParentProps, Show } from 'solid-js'; -import { useEmailContext } from './EmailContext'; export function ModalsProvider(props: ParentProps<{ subject?: string }>) { - const email = useEmailContext(); + const email = useEmailThreadState(); const [shareOpen, setShareOpen] = createSignal(false); return ( ) { diff --git a/apps/web/src/features/block-email/component/TopBar.tsx b/apps/web/src/features/block-email/component/TopBar.tsx index 1dabab2e9cd..f33d4fe5187 100644 --- a/apps/web/src/features/block-email/component/TopBar.tsx +++ b/apps/web/src/features/block-email/component/TopBar.tsx @@ -3,6 +3,7 @@ import { ChatWithAgentIcon, openChatWithAgent, } from '@app/features/chat/ChatWithAgentButton'; +import { useEmailThreadState } from '@app/features/email-thread/context/email-thread-state-context'; import { makeMoveToProjectAction } from '@app/features/next-soup/actions'; import { useMaybeSoup } from '@app/features/next-soup/soup-context'; import { @@ -47,7 +48,6 @@ import ArrowCounterClockwise from '@phosphor-icons/core/regular/arrow-counter-cl import { useEmailLinksQuery } from '@queries/email/link'; import { Button } from '@ui'; import { onCleanup, Show } from 'solid-js'; -import { useEmailContext } from './EmailContext'; export function TopBar(props: { id: string; @@ -57,7 +57,7 @@ export function TopBar(props: { }) { const splitPanel = useSplitPanel(); const shareCtx = useShareDialogContext(); - const emailCtx = useEmailContext(); + const emailCtx = useEmailThreadState(); const soup = useMaybeSoup(); const linksQuery = useEmailLinksQuery(); const sidePanel = useSidePanel(); diff --git a/apps/web/src/features/block-email/component/compose/Compose.tsx b/apps/web/src/features/block-email/component/compose/Compose.tsx deleted file mode 100644 index 1efb5932404..00000000000 --- a/apps/web/src/features/block-email/component/compose/Compose.tsx +++ /dev/null @@ -1,964 +0,0 @@ -import { useFeatureFlag } from '@app/lib/analytics/posthog'; -import type { EmailFormRecipients } from '@block-email/component/createEmailFormState'; -import { - createEmailFormState, - type DraftFormAttachment, -} from '@block-email/component/createEmailFormState'; -import { - markThreadDraftSaved, - useMaybeEmailContext, -} from '@block-email/component/EmailContext'; -import { MACRO_EMAIL_SIGNATURE } from '@block-email/constants'; -import { decodeBase64Utf8 } from '@block-email/util/decodeBase64'; -import { plainTextToHtml } from '@block-email/util/plainTextToHtml'; -import { - clearEmailBody, - hasDraftContent, - prepareEmailBody, -} from '@block-email/util/prepareEmailBody'; -import { convertEmailRecipientToContactInfo } from '@block-email/util/recipientConversion'; -import { - endUndoSend, - restoreDraftBodyAfterUndo, - runUndoSend, -} from '@block-email/util/undoSend'; -import { MobileDrawer } from '@components/app/mobile/MobileDrawer'; -import { useSplitBackInterceptor } from '@components/app/split-layout/back-interceptor'; -import { SplitHeaderLeft } from '@components/app/split-layout/components/SplitHeader'; -import { - SplitHeaderBadge, - StaticSplitLabel, -} from '@components/app/split-layout/components/SplitLabel'; -import { SplitPanelContext } from '@components/app/split-layout/context'; -import { useSplitLayout } from '@components/app/split-layout/layout'; -import { useHasPaidAccess } from '@core/auth'; -import { EmailPermissionsBanner } from '@core/component/EmailPermissionsBanner'; -import { toast } from '@core/component/Toast/Toast'; -import { - enableEmailSignatures, - enableGraphqlSoup, - isFeatureEnabled, -} from '@core/constant/featureFlags'; -import { isMobile } from '@core/mobile/isMobile'; -import { WrapUnlessMobile } from '@core/mobile/WrapUnlessMobile'; -import { useCombinedRecipients } from '@core/signal/useCombinedRecipient'; -import { - type ContactInfo, - emailToId, - getDisplayName, - recipientEntityMapper, - tryMacroId, - type WithCustomUserInput, -} from '@core/user'; -import { $generateHtmlFromNodes } from '@lexical/html'; -import { - $appendWatermarkNodeToLast, - $removeAllWatermarkNodes, -} from '@macro-inc/lexical-core'; -import { Telemetry } from '@macro-inc/observability'; - -import ArrowCounterClockwise from '@phosphor-icons/core/regular/arrow-counter-clockwise.svg?component-solid'; -import { - useRemoveDraftAttachmentMutation, - useRemoveForwardedAttachmentMutation, - useUploadDraftAttachmentsMutation, -} from '@queries/email/attachment'; -import { - useDeleteDraftMutation, - useSaveDraftMutation, -} from '@queries/email/draft'; -import { - useEmailLinksQuery, - useEmailSignature, - useNonPrimaryEmailLinkIdHeader, - usePrimaryEmailLinkId, -} from '@queries/email/link'; -import { - fetchAndCacheThread, - useSendMessageMutation, - useUnscheduleMessageMutation, -} from '@queries/email/thread'; -import { invalidateSoupEntity, refetchSoupEntity } from '@queries/soup/cache'; -import { emailClient } from '@service-email/client'; -import { debounce } from '@solid-primitives/scheduled'; -import { Surface } from '@ui'; - -import * as EmailValidator from 'email-validator'; -import type { LexicalEditor } from 'lexical'; -import { - createEffect, - createMemo, - createSignal, - on, - Show, - useContext, -} from 'solid-js'; -import { unwrap } from 'solid-js/store'; -import { - type ComposeContextValue, - ComposeProvider, - type ComposeValidationError, -} from './ComposeContext'; -import { ComposeLayout } from './ComposeLayout'; -import { EmailComposeToolbar } from './ComposeToolbar'; -import { SignaturePreview } from './SignaturePreview'; - -const DRAFT_DEBOUNCE_MS = 500; - -type UndoComposeSnapshot = { - draftId: string; - recipients: EmailFormRecipients; - subject: string; - bodyHtml: string; - attachments: DraftFormAttachment[]; - includeSignature: boolean; -}; - -let undoComposeSnapshot: UndoComposeSnapshot | null = null; - -type EmailComposeProps = { - draftID?: string; - /** Prefill for the To field (e.g. from an intercepted mailto: link). Ignored when editing an existing draft. */ - initialTo?: string[]; -}; - -export function EmailCompose(props: EmailComposeProps) { - const hasPaidAccess = useHasPaidAccess(); - const emailLinksQuery = useEmailLinksQuery(); - const uploadAttachmentMutation = useUploadDraftAttachmentsMutation(); - const saveDraftMutation = useSaveDraftMutation(); - const deleteDraftMutation = useDeleteDraftMutation(); - const emailContext = useMaybeEmailContext(); - - const form = createEmailFormState( - props.draftID - ? { - type: 'draft', - messageID: props.draftID, - } - : undefined, - emailContext - ? { - getMessageByID: (id) => - emailContext.messages.unfiltered().find((m) => m.db_id === id), - getDraftForMessageReply: emailContext.drafts.getDraftForMessage, - onRecipientsChange: emailContext.onRecipientsChange, - } - : undefined - ); - - const primaryLinkId = usePrimaryEmailLinkId(); - const link = createMemo(() => { - const data = emailLinksQuery.data; - if (!data || data.links.length === 0) return undefined; - // Send from the inbox the user picked, else the inbox that owns the draft - // being edited, else the primary inbox — not whichever inbox sorts first. - const draftLinkId = props.draftID - ? emailContext?.messages - .unfiltered() - .find((m) => m.db_id === props.draftID)?.link_id - : undefined; - const targetId = form.selectedLinkId() ?? draftLinkId ?? primaryLinkId(); - return data.links.find((l) => l.id === targetId) ?? data.links[0]; - }); - - const toHeaderLinkId = useNonPrimaryEmailLinkIdHeader(); - // Scope writes to the inbox this compose sends from (its X-Email-Link-Id - // header), so a non-primary "from" inbox drafts/sends from the right account. - const headerLinkId = () => toHeaderLinkId(link()?.id); - - // The sending inbox's saved signature (empty for inboxes without one). New - // emails include it by default; the preview's dismiss drops it for this one - // message. The backend injects it on send (see include_signature below); the - // FE only renders the preview and signals an explicit dismiss. - const signature = useEmailSignature(() => link()?.id); - const emailSignaturesFlag = useFeatureFlag(enableEmailSignatures); - const [includeSignature, setIncludeSignature] = createSignal(true); - - const hasLinkError = createMemo(() => { - if (emailLinksQuery.isPending) return false; - return ( - emailLinksQuery.isError || - (emailLinksQuery.data && emailLinksQuery.data.links.length === 0) - ); - }); - - const { users: destinationOptions } = useCombinedRecipients(); - - const [editor, setEditor] = createSignal(); - const [content, setContent] = createSignal(''); - const [currentDraftID, setCurrentDraftID] = createSignal( - props.draftID - ); - - // Thread the draft currently lives under; switching the sending inbox - // re-homes the draft server-side, so the previous thread's soup row must - // be dropped after the save. - const [currentThreadID, setCurrentThreadID] = createSignal< - string | undefined - >( - props.draftID - ? emailContext?.messages - .unfiltered() - .find((m) => m.db_id === props.draftID)?.thread_db_id - : undefined - ); - - // Restore form state from undo-send snapshot if available - const restoredSnapshot = - undoComposeSnapshot?.draftId === props.draftID ? undoComposeSnapshot : null; - - if (restoredSnapshot) { - form.setRecipients('to', restoredSnapshot.recipients.to); - form.setRecipients('cc', restoredSnapshot.recipients.cc); - form.setRecipients('bcc', restoredSnapshot.recipients.bcc); - form.setSubject(restoredSnapshot.subject); - for (const attachment of restoredSnapshot.attachments) { - form.attachments.add(attachment); - } - setIncludeSignature(restoredSnapshot.includeSignature); - undoComposeSnapshot = null; - } - - if (!props.draftID && props.initialTo?.length) { - form.setRecipients( - 'to', - props.initialTo.map((email) => - recipientEntityMapper('custom')({ - id: emailToId(email), - email, - invalid: !EmailValidator.validate(email), - }) - ) - ); - } - - // --- Draft persistence --- - - function collectDraft() { - $removeAllWatermarkNodes(editor()); - const prepared = prepareEmailBody(editor()); - if (!prepared) { - Telemetry.error( - new Error('Unable to prepare email body for draft collection.') - ); - return null; - } - if ( - !hasDraftContent( - prepared.bodyText, - form.subject(), - form.attachments.list().length, - form.recipients().to.length + - form.recipients().cc.length + - form.recipients().bcc.length - ) - ) { - return null; - } - return { - bcc: form.recipients().bcc.map(convertEmailRecipientToContactInfo), - body_html: prepared.bodyHtml, - cc: form.recipients().cc.map(convertEmailRecipientToContactInfo), - subject: form.subject(), - to: form.recipients().to.map(convertEmailRecipientToContactInfo), - }; - } - - // Content uploads still in flight, including ones started by earlier saves. - // attachmentID only proves the draft record exists, and the send path treats - // a resolved save as "attachments ready", so a save must not resolve while - // any of these are pending. - const inFlightAttachmentUploads = new Set>(); - - async function executeSaveDraft() { - if (sendMutation.isPending) { - return; - } - const draftToSave = collectDraft(); - if (!draftToSave) { - const draftID = currentDraftID(); - if (draftID) { - await deleteDraftMutation.mutateAsync({ - draftId: draftID, - threadId: currentThreadID(), - linkId: headerLinkId(), - }); - } - setCurrentDraftID(undefined); - return; - } - - const previousThreadID = currentThreadID(); - const draftResponse = await saveDraftMutation.mutateAsync({ - draft: { - ...draftToSave, - db_id: currentDraftID(), - }, - linkId: headerLinkId(), - }); - - const newThreadID = draftResponse.draft.thread_db_id ?? undefined; - if (previousThreadID && previousThreadID !== newThreadID) { - invalidateSoupEntity(previousThreadID); - refetchSoupEntity(previousThreadID, 'emailThread'); - } - setCurrentThreadID(newThreadID); - - const draftId = draftResponse.draft.db_id; - if (draftId) { - const attachments = form.attachments - .list() - .filter((a) => a.type === 'local' && !a.attachmentID) as Extract< - DraftFormAttachment, - { type: 'local' } - >[]; - - let uploadRun: Promise | undefined; - if (attachments.length) { - uploadRun = uploadAttachmentMutation.mutateAsync({ - draftID: draftId, - attachments: attachments.map((a) => a.file), - linkId: headerLinkId(), - onAttachmentAdded: form.attachments.assignAttachmentID, - onAttachmentUploadFailed: form.attachments.clearAttachmentID, - }); - const tracked = uploadRun.then( - () => undefined, - () => undefined - ); - inFlightAttachmentUploads.add(tracked); - tracked.then(() => inFlightAttachmentUploads.delete(tracked)); - } - - while (inFlightAttachmentUploads.size) { - await Promise.all([...inFlightAttachmentUploads]); - } - // Settled by the drain above, this only rethrows this save's own failure - if (uploadRun) await uploadRun; - - setCurrentDraftID(draftId); - return draftId; - } - } - - // Edits since the composer opened; an untouched existing draft can be - // left without the keep-or-delete prompt. - const [draftDirty, setDraftDirty] = createSignal(false); - - const scheduleDraftSave = debounce(() => { - void executeSaveDraft(); - }, DRAFT_DEBOUNCE_MS); - - const markDirtyAndScheduleSave = () => { - setDraftDirty(true); - scheduleDraftSave(); - }; - - // --- Attachment handling --- - - const removeAttachmentMutation = useRemoveDraftAttachmentMutation(); - const removeForwardedAttachmentMutation = - useRemoveForwardedAttachmentMutation(); - - const handleAddAttachments = (attachments: DraftFormAttachment[]) => { - for (const attachment of attachments) { - form.attachments.add(attachment); - } - markDirtyAndScheduleSave(); - }; - - const handleRemoveAttachment = (attachment: DraftFormAttachment) => { - setDraftDirty(true); - if (attachment.type === 'local') { - form.attachments.removeByFile(attachment.file); - } else if (attachment.type === 'forwarded') { - form.attachments.removeForwarded(attachment.attachmentID); - } else { - form.attachments.removeByID(attachment.attachmentID); - } - - const savedDraftID = currentDraftID(); - if (!savedDraftID || !attachment.attachmentID) return; - - if (attachment.type === 'forwarded') { - removeForwardedAttachmentMutation.mutate({ - draftID: savedDraftID, - attachmentID: attachment.attachmentID, - linkId: headerLinkId(), - }); - } else { - removeAttachmentMutation.mutate({ - draftID: savedDraftID, - attachmentID: attachment.attachmentID, - linkId: headerLinkId(), - }); - } - }; - - // --- Content change --- - - let firstChangeConsumed = false; - const onContentChange = (newContent: string) => { - setContent(newContent); - if (!firstChangeConsumed) { - firstChangeConsumed = true; - return; - } - markDirtyAndScheduleSave(); - }; - - // --- Send --- - - const { replaceSplit } = useSplitLayout(); - - const [validationError, setValidationError] = - createSignal(null); - - // Everything that follows a successful unschedule: scrub the new thread's - // cache, restore the server-side draft, and remount the compose view so it - // restores the form from the undo snapshot. - const restoreAfterUndoSend = async ( - draftId: string, - threadId: string | undefined, - linkId: string | undefined - ) => { - // Wipe the new thread's cache when its view unmounts (replaceSplit - // below) so the next visit fetches fresh data without the sent message. - if (threadId && !isFeatureEnabled(enableGraphqlSoup)) - markThreadDraftSaved(threadId); - - // Overwrite the server-side draft with the pre-send content. The - // snapshot itself stays for the compose remount below to restore the - // form from. - const snapshot = - undoComposeSnapshot?.draftId === draftId ? undoComposeSnapshot : null; - if (snapshot) { - await restoreDraftBodyAfterUndo( - { - bcc: snapshot.recipients.bcc.map(convertEmailRecipientToContactInfo), - cc: snapshot.recipients.cc.map(convertEmailRecipientToContactInfo), - db_id: draftId, - subject: snapshot.subject, - to: snapshot.recipients.to.map(convertEmailRecipientToContactInfo), - }, - snapshot.bodyHtml, - linkId - ); - } - - // GraphQL mode renders threads from the normalized cache, which - // markThreadDraftSaved's TanStack cleanup can't reach — refetch through - // it (after the draft-body restore) so a revisit doesn't replay the - // undone message from cache. - if (threadId && isFeatureEnabled(enableGraphqlSoup)) { - void fetchAndCacheThread(threadId); - } - - replaceSplit({ - content: { - type: 'component', - id: 'email-compose', - params: { draftID: draftId }, - // reattach() strips params by default; keep draftID so the compose - // remount can restore the undo snapshot. - preserveParams: true, - }, - }); - }; - - // `linkId` is the X-Email-Link-Id header value the send itself used, resolved - // at send time. - const undoSend = ( - draftId: string, - threadId: string | undefined, - linkId: string | undefined - ) => - runUndoSend({ - draftId, - linkId, - onUndone: () => restoreAfterUndoSend(draftId, threadId, linkId), - }); - - const sendMutation = useSendMessageMutation({ - onSuccess: (data, vars) => { - const draftId = data.message.db_id; - const threadId = data.message.thread_db_id; - // This send opens a fresh undo cycle for the draft id. - if (draftId) endUndoSend(draftId); - const sendLinkId = vars.linkId; - const toastId = toast.success('Email sent', { - actions: draftId - ? [ - { - label: 'Undo', - icon: ArrowCounterClockwise, - onClick: () => { - if (toastId != null) toast.dismiss(toastId); - void undoSend(draftId, threadId ?? undefined, sendLinkId); - }, - }, - ] - : undefined, - duration: 5_000, - }); - if (data.message.thread_db_id) { - replaceSplit({ - content: { type: 'email', id: data.message.thread_db_id }, - mergeHistory: true, - }); - } - }, - onError: () => { - toast.failure('Failed to send email'); - }, - }); - - const onSubmit = async () => { - setValidationError(null); - - const currentEditor = editor(); - const currentLink = link(); - const recipients = form.recipients(); - - if (!recipients.to.length) { - setValidationError({ - type: 'no_recipient', - message: 'Please select at least one recipient', - }); - return; - } - - if (!content().trim()) { - setValidationError({ - type: 'no_message', - message: 'Please enter a message', - }); - return; - } - - if (!form.subject()?.trim()) { - setValidationError({ - type: 'no_subject', - message: 'Please enter a subject', - }); - return; - } - - if (!currentLink) { - setValidationError({ - type: 'no_link', - message: 'Unable to find linked email account', - }); - return; - } - - // Failsafe: don't send if a scheduled send time is set - if (form.sendTime()) { - return; - } - - // Ensure the draft is saved before sending so undo-send always has a - // draft id to snapshot and restore (the send reuses the draft's db_id). - scheduleDraftSave.clear(); - try { - await executeSaveDraft(); - } catch { - // Draft save is best-effort; the send still works without one. - } - - // Snapshot editor state before watermark so undo-send can restore it - if (currentEditor) { - const snapshotHtml = currentEditor.read(() => - $generateHtmlFromNodes(currentEditor) - ); - const draftId = currentDraftID(); - if (draftId) { - undoComposeSnapshot = { - draftId, - recipients: structuredClone(unwrap(form.recipients())), - subject: form.subject(), - bodyHtml: snapshotHtml, - attachments: [...form.attachments.list()], - includeSignature: includeSignature(), - }; - } - } - - // 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 prepared = prepareEmailBody(currentEditor); - if (!prepared) { - cleanupWatermark(); - return; - } - - const bodyMacro = content(); - - sendMutation.mutate({ - message: { - to: convertToContactInfoArray(recipients.to), - cc: - recipients.cc.length > 0 - ? convertToContactInfoArray(recipients.cc) - : [], - bcc: - recipients.bcc.length > 0 - ? convertToContactInfoArray(recipients.bcc) - : [], - subject: form.subject(), - body_text: prepared.bodyText, - body_html: prepared.bodyHtml, - body_macro: bodyMacro, - db_id: currentDraftID(), - // Backend includes the signature by default for new emails; only signal - // an explicit dismiss. Omitting it falls through to the backend default. - include_signature: includeSignature() ? undefined : false, - }, - linkId: headerLinkId(), - }); - - cleanupWatermark(); - }; - - // --- Schedule --- - - const unscheduleMessageMutation = useUnscheduleMessageMutation({ - onSuccess: (_data, vars) => { - toast.success('Email unscheduled'); - invalidateSoupEntity(vars.draftID); - }, - onError: () => { - toast.failure('Failed to unschedule email'); - }, - }); - - const handleSendTimeChange = async (date: Date | null) => { - setDraftDirty(true); - const currentSendTime = form.sendTime(); - const currentDraft = currentDraftID(); - - if (!date && currentSendTime && currentDraft) { - unscheduleMessageMutation.mutate({ - draftID: currentDraft, - linkId: headerLinkId(), - }); - form.setSendTime(date); - return; - } - - form.setSendTime(date); - - if (date) { - const draftID = currentDraft ?? (await executeSaveDraft()); - if (!draftID) { - toast.failure('Failed to schedule message', { - subtext: 'Draft required', - }); - return; - } - - await emailClient.scheduleMessage( - { - draftID, - send_time: date.toISOString(), - }, - headerLinkId() - ); - - const threadID = saveDraftMutation.data?.draft.thread_db_id; - if (threadID) { - await emailClient.flagArchived( - { id: threadID, value: true }, - headerLinkId() - ); - } - } - }; - - // Unschedule when all recipients are removed - const totalRecipientCount = () => { - const r = form.recipients(); - return r.to.length + r.cc.length + r.bcc.length; - }; - createEffect( - on( - totalRecipientCount, - (count) => { - if (count === 0 && form.sendTime()) { - handleSendTimeChange(null); - } - }, - { defer: true } - ) - ); - - // --- Reset / delete --- - - const resetState = () => { - clearEmailBody(editor()); - setContent(''); - setCurrentDraftID(undefined); - form.clear(); - }; - - const deleteDraftAndReset = async () => { - const draftId = currentDraftID(); - if (draftId) { - await deleteDraftMutation.mutateAsync({ - draftId, - threadId: currentThreadID(), - linkId: headerLinkId(), - }); - } - resetState(); - }; - - // --- Derived state --- - - const initialHtml = () => { - if (restoredSnapshot) { - return restoredSnapshot.bodyHtml; - } - - const draft = form.draft; - if (!draft) return; - - if (draft.body_html_sanitized) { - return decodeBase64Utf8(draft.body_html_sanitized); - } - - if (draft.body_text) { - return plainTextToHtml(draft.body_text); - } - }; - - const getRecipientOptions = () => { - const fromDraft = emailContext?.recipientOptions(); - return fromDraft ?? destinationOptions(); - }; - - const previewName = createMemo(() => { - const recipients = form.recipients().to; - if (recipients.length === 0) { - return 'Draft email'; - } - - if (recipients.length === 1) { - let recipientName = recipients[0].data.email; - - if (recipients[0].kind === 'user') { - recipientName = getDisplayName(tryMacroId(recipients[0].data.id)); - } - - return recipientName ? `Email to ${recipientName}` : 'Draft email'; - } - - const names = recipients - .slice(0, 2) - .map((r) => { - if (r.kind === 'user') { - return getDisplayName(tryMacroId(r.data.id)); - } - return r.data.email || 'Unknown'; - }) - .filter(Boolean); - - if (recipients.length > 2) { - return `Email to ${names.join(', ')}, and others`; - } - - return `Email to ${names.join(' and ')}`; - }); - - // --- Context value --- - - const ctxValue: ComposeContextValue = { - // Form state (read) - recipients: form.recipients, - subject: form.subject, - attachments: form.attachments.list, - sendTime: form.sendTime, - initialHtml, - - // Form state (write) - setRecipients: (field, value) => { - form.setRecipients(field, value); - markDirtyAndScheduleSave(); - }, - setSubject: (value) => { - form.setSubject(value); - markDirtyAndScheduleSave(); - }, - onContentChange, - onAddAttachments: handleAddAttachments, - onRemoveAttachment: handleRemoveAttachment, - - // Editor - captureEditor: setEditor, - - // Actions - onSend: () => void onSubmit(), - onDelete: () => void deleteDraftAndReset(), - onSendTimeChange: handleSendTimeChange, - - // Status - disabled: () => hasLinkError() || sendMutation.isPending, - isSending: () => sendMutation.isPending, - hasDraft: () => currentDraftID() != null, - - // Validation - validationError: (type) => { - const error = validationError(); - if (error?.type === type) return error; - return undefined; - }, - - // Recipients - recipientOptions: getRecipientOptions, - focusRecipientsOnMount: !hasLinkError(), - - // Schedule send - scheduleSendDisabled: () => totalRecipientCount() === 0, - - // Display - fromAddress: () => link()?.email_address, - fromInboxes: () => emailLinksQuery.data?.links ?? [], - selectedFromLinkId: () => link()?.id, - // Persist immediately on a sender switch so the draft moves to the new - // inbox even without a text edit. - onSelectFromLink: (linkId) => { - form.setSelectedFromLink(linkId); - setDraftDirty(true); - scheduleDraftSave.clear(); - void executeSaveDraft(); - }, - hasPaidAccess, - - // Read-only preview of the signature appended on send, with a per-message - // dismiss. Shown only when the sending inbox has a signature and it hasn't - // been dismissed. - signaturePreview: () => ( - - {(html) => ( - setIncludeSignature(false)} - /> - )} - - ), - }; - - const panel = useContext(SplitPanelContext); - const [draftBackMenuOpen, setDraftBackMenuOpen] = createSignal(false); - - if (isMobile()) { - // Backing out of a compose that has a draft asks whether to keep it. - useSplitBackInterceptor(() => { - if (!ctxValue.hasDraft() || !draftDirty()) return false; - setDraftBackMenuOpen(true); - return true; - }); - } - - const leaveCompose = () => { - setDraftBackMenuOpen(false); - panel?.handle.goBack(); - }; - - return ( - - - - , - ]} - /> - - -
-
- ( - - {children} - - )} - > - } - notice={hasLinkError() ? : undefined} - class="size-full p-4 bg-surface max-h-full touch:max-h-none overflow-hidden flex flex-col min-h-0 touch:min-h-full" - /> - -
-
- - - - - - - - - - - - - - -
- ); -} - -function convertToContactInfoArray( - recipients: WithCustomUserInput<'user' | 'contact'>[] -): ContactInfo[] { - return recipients.map((recipient) => ({ - email: recipient.data.email, - name: - 'name' in recipient.data ? recipient.data.name || undefined : undefined, - })); -} diff --git a/apps/web/src/features/block-email/component/compose/index.ts b/apps/web/src/features/block-email/component/compose/index.ts deleted file mode 100644 index d861d8f91bb..00000000000 --- a/apps/web/src/features/block-email/component/compose/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './ComposeContext'; -export { ComposeLayout } from './ComposeLayout'; - -export { EmailComposeToolbar } from './ComposeToolbar'; diff --git a/apps/web/src/features/block-email/component/createEmailFormState.ts b/apps/web/src/features/block-email/component/createEmailFormState.ts deleted file mode 100644 index 4c307c73467..00000000000 --- a/apps/web/src/features/block-email/component/createEmailFormState.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { useEmail } from '@core/context/user'; -import { useEmailLinksQuery } from '@queries/email/link'; -import type { ApiMessage } from '@service-email/generated/schemas'; -import type { LexicalEditor } from 'lexical'; -import { createSignal, type Setter } from 'solid-js'; -import { createStore, reconcile, unwrap } from 'solid-js/store'; -import { decodeBase64Utf8 } from '../util/decodeBase64'; -import { TOGGLE_APPEND_EMAIL_THREAD_COMMAND } from '../util/prepareEmailBody'; -import { - convertContactInfoToEmailRecipient, - getReplyAllRecipients, - getReplyRecipientsFromParent, -} from '../util/recipientConversion'; -import type { ReplyType } from '../util/replyType'; -import { getSubjectText } from '../util/subjectText'; -import type { EmailRecipient } from './EmailContext'; - -export type EmailFormRecipients = { - to: EmailRecipient[]; - cc: EmailRecipient[]; - bcc: EmailRecipient[]; -}; - -export type DraftFormAttachment = - | { - type: 'local'; - file: File; - attachmentID?: string; - } - | { - type: 'remote'; - url: string; - fileName: string; - contentType: string; - attachmentID: string; - fileSize: number; - } - | { - type: 'forwarded'; - attachmentID: string; - fileName: string; - mimeType: string; - fileSize: number; - }; - -export interface EmailFormStateOptions { - getMessageByID: (id: string) => ApiMessage | undefined; - getDraftForMessageReply: (id: string) => ApiMessage | undefined; - onRecipientsChange?: (next: EmailRecipient[]) => void; - /** Whether a message is personal (see isPersonalMessage); drives - * theme-adapted rendering of quoted html in the composer */ - isPersonalMessage?: (message: ApiMessage) => boolean; -} - -type EmailFormState = { - recipients: { - to: EmailRecipient[]; - cc: EmailRecipient[]; - bcc: EmailRecipient[]; - }; - replyType: ReplyType; - withQuotedText: boolean; - subject: string; - markdownBody: string; - sendTime?: Date; -}; - -const EMPTY_FORM_STATE: EmailFormState = { - recipients: { - to: [], - cc: [], - bcc: [], - }, - replyType: 'reply-all', - withQuotedText: false, - subject: '', - markdownBody: '', -}; - -/** - * Creates a state object for the email form. - * @param purpose - The purpose of the form. Are we managing the state of a draft reply or just a draft message - * @param options - Required options for the initial state to be calculated from - * @returns A state object for the email form. - */ -export function createEmailFormState( - purpose?: - | { type: 'replying_to'; messageID: string } - | { type: 'draft'; messageID: string }, - - options?: EmailFormStateOptions -) { - const userEmail = useEmail(); - - let replyingTo: ApiMessage | undefined; - - if (purpose?.type === 'replying_to') { - replyingTo = options?.getMessageByID?.(purpose.messageID); - } - - let draft: ApiMessage | undefined; - - if (purpose?.type === 'draft') { - draft = options?.getMessageByID(purpose.messageID); - } else if (purpose?.type === 'replying_to') { - draft = options?.getDraftForMessageReply(purpose?.messageID); - } - - const linksQuery = useEmailLinksQuery(); - // The inbox this compose sends from. Defaults to the inbox that owns the - // thread/draft; the user can change it via the "from" selector. - const [selectedLinkId, setSelectedLinkId] = createSignal( - (draft ?? replyingTo)?.link_id ?? undefined - ); - // Reply logic ("did I send this?") must be judged against the inbox the - // message is sent from, not the account's primary email — otherwise replying - // from a secondary or delegated inbox misclassifies the sender and picks the - // wrong recipients. - const inboxEmail = () => { - const linkId = selectedLinkId() ?? (draft ?? replyingTo)?.link_id; - const ownerEmail = linkId - ? linksQuery.data?.links.find((l) => l.id === linkId)?.email_address - : undefined; - return ownerEmail ?? userEmail() ?? ''; - }; - - const draftContainsAppendedReply = () => { - const encoded = draft?.body_html_sanitized; - if (!encoded) return false; - const decodedHtml = decodeBase64Utf8(encoded); - if (!decodedHtml) return false; - const parsed = new DOMParser().parseFromString(decodedHtml, 'text/html'); - - return parsed.body.querySelector('div.macro_quote') !== null; - }; - - const getInitialState = () => { - const replyType = - (replyingTo?.to.length ?? 0) + (replyingTo?.cc.length ?? 0) > 1 - ? 'reply-all' - : 'reply'; - - let initialSubject = draft?.subject; - - if (initialSubject == null) { - initialSubject = getSubjectText(replyingTo, replyType); - } - - let initialRecipients: EmailFormRecipients = { to: [], cc: [], bcc: [] }; - - if (draft) { - initialRecipients = { - to: draft.to.map(convertContactInfoToEmailRecipient) ?? [], - cc: draft.cc.map(convertContactInfoToEmailRecipient) ?? [], - bcc: draft.bcc.map(convertContactInfoToEmailRecipient) ?? [], - }; - } else if (replyingTo) { - initialRecipients = - replyType === 'reply-all' - ? getReplyAllRecipients(replyingTo, inboxEmail()) - : getReplyRecipientsFromParent(replyingTo, inboxEmail()); - } - - return { - recipients: initialRecipients, - replyType, - withQuotedText: draftContainsAppendedReply(), - subject: initialSubject, - markdownBody: '', - sendTime: draft?.scheduled_send_time - ? new Date(draft.scheduled_send_time) - : undefined, - } satisfies EmailFormState; - }; - - const [state, setState] = createStore({ - ...getInitialState(), - }); - - const [onDirtyCb, setOnDirtyCb] = createSignal<(() => void) | undefined>(); - - const [onReplyTypeAppliedCb, setOnReplyTypeAppliedCb] = createSignal< - ((rt: ReplyType | undefined) => void) | undefined - >(); - - const [capturedEditor, setCapturedEditor] = createSignal(); - // If setReplyType('forward') is called before the Lexical editor mounts - // (e.g. user clicks Forward while the bottom reply input is collapsed), - // we stash the dispatch and replay it once the editor is captured. - let pendingForwardAppend = false; - - // We track the last reply type applied to replay against the current state when setOnReplyTypeApplied is attached - const [lastReplyTypeApplied, setLastReplyTypeApplied] = createSignal< - ReplyType | undefined - >(undefined); - - const [shouldFocusInput, setShouldFocusInput] = createSignal(false); - - // TODO: Replace this signal with a memo deriving the attachments from the draft data - // and a temporary queue to track attachments to be uploaded on draft save - const [attachments, setAttachments] = createSignal([ - ...(draft?.attachments_draft.map((a) => ({ - type: 'remote' as const, - attachmentID: a.id, - contentType: a.content_type, - fileName: a.file_name, - url: a.s3_key, - fileSize: a.size, - })) ?? []), - ...(draft?.attachments_forwarded.map((a) => ({ - type: 'forwarded' as const, - attachmentID: a.attachment_id, - fileName: a.filename ?? 'attachment', - mimeType: a.mime_type ?? 'application/octet-stream', - fileSize: a.size_bytes ?? 0, - })) ?? []), - ]); - - const setRecipients = ( - field: keyof EmailFormRecipients, - value: EmailRecipient[] - ) => { - setState('recipients', field, value); - callDirty(); - const recipients = state.recipients; - const all = [...recipients.to, ...recipients.cc, ...recipients.bcc]; - options?.onRecipientsChange?.(unwrap(all)); - }; - - const setSubject: Setter = (value) => { - const result = setState('subject', value); - callDirty(); - return result; - }; - - const setReplyType = (next: ReplyType) => { - setState('replyType', next); - const rt = state.replyType; - const msg = replyingTo; - - // Clear forwarded attachments when switching away from forward - setAttachments((prev) => prev.filter((a) => a.type !== 'forwarded')); - - if (msg) { - let calculated: EmailFormRecipients = { to: [], cc: [], bcc: [] }; - - switch (rt) { - case 'reply-all': { - calculated = getReplyAllRecipients(msg, inboxEmail()); - break; - } - case 'reply': { - calculated = getReplyRecipientsFromParent(msg, inboxEmail()); - } - } - - setRecipients('to', calculated.to ?? []); - setRecipients('cc', calculated.cc ?? []); - setRecipients('bcc', calculated.bcc ?? []); - - setSubject(getSubjectText(msg, rt)); - - if (rt === 'forward') { - setState('withQuotedText', true); - const editor = capturedEditor(); - // The captured editor can be a stale one from an unmounted composer - // (this state outlives the component); dispatching into it is a no-op, - // so defer the append to the next editor capture instead. - if (editor?.getRootElement()?.isConnected) { - editor.dispatchCommand(TOGGLE_APPEND_EMAIL_THREAD_COMMAND, { - replyingTo: replyingTo, - replyType: rt, - visible: true, - isPersonal: options?.isPersonalMessage?.(msg), - }); - } else { - pendingForwardAppend = true; - } - - // Populate forwarded attachments from original message (skip inline images) - const fwdAttachments: DraftFormAttachment[] = (msg.attachments ?? []) - .filter((a) => !a.content_id) - .map((a) => ({ - type: 'forwarded' as const, - attachmentID: a.db_id, - fileName: a.filename ?? 'attachment', - mimeType: a.mime_type ?? 'application/octet-stream', - fileSize: a.size_bytes ?? 0, - })); - setAttachments((prev) => [...prev, ...fwdAttachments]); - } - } - - callDirty(); - setLastReplyTypeApplied(rt); - onReplyTypeAppliedCb()?.(rt); - return rt; - }; - - // Change the inbox this compose sends from. For an active reply, re-derive the - // recipients against the newly selected inbox (the sender comparison changes). - const setSelectedFromLink = (linkId: string | undefined) => { - setSelectedLinkId(linkId); - if (!replyingTo || draft || state.replyType === 'forward') return; - const recalculated = - state.replyType === 'reply-all' - ? getReplyAllRecipients(replyingTo, inboxEmail()) - : getReplyRecipientsFromParent(replyingTo, inboxEmail()); - setRecipients('to', recalculated.to ?? []); - setRecipients('cc', recalculated.cc ?? []); - setRecipients('bcc', recalculated.bcc ?? []); - }; - - const setSendTime = (date: Date | null) => { - setState('sendTime', date ?? undefined); - }; - - const callDirty = () => { - onDirtyCb()?.(); - }; - - const reset = () => { - setState(reconcile({ ...getInitialState() })); - const recipients = state.recipients; - - // Notify context of the full recipient list after reset - const all = [...recipients.to, ...recipients.cc, ...recipients.bcc]; - options?.onRecipientsChange?.(unwrap(all)); - - setShouldFocusInput(false); - - setAttachments([]); - - // Mark as dirty to propagate change - callDirty(); - }; - - const clear = () => { - setState(reconcile({ ...EMPTY_FORM_STATE })); - const recipients = state.recipients; - - // Notify context of the full recipient list after reset - const all = [...recipients.to, ...recipients.cc, ...recipients.bcc]; - options?.onRecipientsChange?.(unwrap(all)); - - setShouldFocusInput(false); - - setAttachments([]); - - // Mark as dirty to propagate change - callDirty(); - }; - - const value = { - draft, - replyAppended: () => state.withQuotedText, - setReplyAppended: (next: boolean) => setState('withQuotedText', next), - recipients: () => state.recipients, - setRecipients, - subject: () => state.subject, - setSubject, - replyType: () => state.replyType, - setReplyType, - selectedLinkId: () => selectedLinkId(), - setSelectedFromLink, - shouldFocusInput, - setShouldFocusInput, - sendTime: () => state.sendTime, - setSendTime, - reset, - clear, - setOnDirty: (cb?: () => void) => { - setOnDirtyCb(() => cb); - }, - setOnReplyTypeApplied: (cb?: (rt: ReplyType | undefined) => void) => { - setOnReplyTypeAppliedCb(() => cb); - const rt = lastReplyTypeApplied() ?? state.replyType; - if (cb && rt !== undefined) queueMicrotask(() => cb(rt)); - }, - setCapturedEditor: (editor: LexicalEditor) => { - setCapturedEditor(editor); - if (pendingForwardAppend && replyingTo) { - pendingForwardAppend = false; - // Defer past the current Solid batch / microtask queue so that - // registerToggleAppendedThread (registered via lazyRegister, which - // uses createEffect) has actually attached the command handler - // before we dispatch. queueMicrotask runs too early. - setTimeout(() => { - editor.dispatchCommand(TOGGLE_APPEND_EMAIL_THREAD_COMMAND, { - replyingTo, - replyType: 'forward', - visible: true, - isPersonal: replyingTo - ? options?.isPersonalMessage?.(replyingTo) - : undefined, - }); - }, 0); - } - }, - attachments: { - list: attachments, - add: (attachment: DraftFormAttachment) => { - setAttachments((p) => [...p, attachment]); - }, - assignAttachmentID: (file: File, attachmentID: string) => { - setAttachments((p) => - p.map((a) => - a.type === 'local' && a.file === file ? { ...a, attachmentID } : a - ) - ); - }, - clearAttachmentID: (file: File) => { - setAttachments((p) => - p.map((a) => - a.type === 'local' && a.file === file - ? { ...a, attachmentID: undefined } - : a - ) - ); - }, - removeByFile: (file: File) => { - setAttachments((p) => - p.filter((a) => a.type !== 'local' || a.file !== file) - ); - }, - removeByID: (attachmentID: string) => { - setAttachments((p) => - p.filter( - (a) => a.type !== 'remote' || a.attachmentID !== attachmentID - ) - ); - }, - removeForwarded: (attachmentID: string) => { - setAttachments((p) => - p.filter( - (a) => a.type !== 'forwarded' || a.attachmentID !== attachmentID - ) - ); - }, - }, - }; - - return value; -} diff --git a/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx b/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx index 193a02ece30..b811ff94d96 100644 --- a/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx +++ b/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx @@ -1,4 +1,5 @@ import { EntityActivitySectionConditional } from '@app/features/activity/views/entity-activity-section'; +import { useEmailThreadState } from '@app/features/email-thread/context/email-thread-state-context'; import { EntityPropertiesSection, EntityTagsSection, @@ -7,7 +8,6 @@ import { SidePanel } from '@components/app/side-panel'; import { References } from '@core/component/References'; import { useAttachmentReferencesQuery } from '@queries/storage/attachment-references'; import { Show, Suspense } from 'solid-js'; -import { useEmailContext } from '../EmailContext'; interface EmailSidePanelSectionsProps { threadId: string; @@ -15,7 +15,7 @@ interface EmailSidePanelSectionsProps { } export function EmailSidePanelSections(props: EmailSidePanelSectionsProps) { - const emailCtx = useEmailContext(); + const emailCtx = useEmailThreadState(); const canEdit = () => emailCtx.permissions().isOwner; return ( diff --git a/apps/web/src/features/block-email/signal/scrollState.ts b/apps/web/src/features/block-email/signal/scrollState.ts deleted file mode 100644 index 2559102713b..00000000000 --- a/apps/web/src/features/block-email/signal/scrollState.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createBlockSignal } from '@core/block'; - -export const isScrollingToMessage = createBlockSignal(false); diff --git a/apps/web/src/features/block-email/util/appendedReplyRoundTrip.test.ts b/apps/web/src/features/block-email/util/appendedReplyRoundTrip.test.ts deleted file mode 100644 index a5f2ed14e45..00000000000 --- a/apps/web/src/features/block-email/util/appendedReplyRoundTrip.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -// @vitest-environment jsdom -import { $generateNodesFromDOM } from '@lexical/html'; -import { - $isClassedBlockNode, - SupportedNodeTypes, -} from '@macro-inc/lexical-core'; -import type { ApiMessage } from '@service-email/generated/schemas'; -import { - $createParagraphNode, - $createTextNode, - $getRoot, - createEditor, - type LexicalNode, -} from 'lexical'; -import { describe, expect, it } from 'vitest'; -import { - prepareEmailBody, - registerToggleAppendedThread, - TOGGLE_APPEND_EMAIL_THREAD_COMMAND, -} from './prepareEmailBody'; - -function decodeBodyHtml(encoded: string) { - const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); - return decodeURIComponent(escape(atob(base64))); -} - -const replyingTo = { - replying_to_id: 'parent-message-id', - from: { name: 'Ada Lovelace', email: 'ada@example.com' }, - to: [], - cc: [], - bcc: [], - subject: 'Numbers', - body_text: 'original message text', - internal_date_ts: '2026-08-01T12:00:00Z', - attachments: [], -} as unknown as ApiMessage; - -function makeEditor() { - const editor = createEditor({ - nodes: SupportedNodeTypes, - onError: (e) => { - throw e; - }, - }); - registerToggleAppendedThread(editor); - return editor; -} - -function $collectMacroQuotes(): LexicalNode[] { - const found: LexicalNode[] = []; - const visit = (node: LexicalNode) => { - if ($isClassedBlockNode(node) && node.__classes.includes('macro_quote')) { - found.push(node); - } - if ('getChildren' in node) { - for (const child of (node as any).getChildren()) visit(child); - } - }; - for (const child of $getRoot().getChildren()) visit(child); - return found; -} - -function appendQuote(editor: ReturnType) { - editor.update(() => { - const p = $createParagraphNode(); - p.append($createTextNode('my reply')); - $getRoot().append(p); - }); - editor.dispatchCommand(TOGGLE_APPEND_EMAIL_THREAD_COMMAND, { - replyingTo, - replyType: 'reply', - visible: true, - }); -} - -function hideQuote(editor: ReturnType) { - editor.dispatchCommand(TOGGLE_APPEND_EMAIL_THREAD_COMMAND, { - replyingTo, - replyType: 'reply', - visible: false, - }); -} - -describe('appended reply draft round trip', () => { - it('hide removes a live-appended quote (control)', () => { - const editor = makeEditor(); - appendQuote(editor); - editor.read(() => { - expect($collectMacroQuotes()).toHaveLength(1); - }); - hideQuote(editor); - editor.read(() => { - expect($collectMacroQuotes()).toHaveLength(0); - }); - }); - - it('the draft-save export keeps the classed-block marker', () => { - const editor = makeEditor(); - appendQuote(editor); - // Draft saves run prepareEmailBody without appendReply (collectDraft). - const prepared = prepareEmailBody(editor); - expect(prepared).not.toBeNull(); - const html = decodeBodyHtml(prepared!.bodyHtml); - const body = new DOMParser().parseFromString(html, 'text/html').body; - const quote = body.querySelector('.macro_quote'); - expect(quote).not.toBeNull(); - // ClassedBlockNode.importDOM only claims elements carrying this marker; - // the authored sanitizer keeps data-* attributes, so if it's present here - // it survives to body_html_sanitized. - expect(quote!.getAttribute('data-classed-block')).toBe('true'); - }); - - it('reloading the saved draft keeps the quote removable (reload case)', () => { - const editorA = makeEditor(); - appendQuote(editorA); - const prepared = prepareEmailBody(editorA); - const html = decodeBodyHtml(prepared!.bodyHtml); - - // Reload path: setEditorStateFromHtml -> $generateNodesFromDOM. - const editorB = makeEditor(); - editorB.update(() => { - const dom = new DOMParser().parseFromString(html, 'text/html'); - const nodes = $generateNodesFromDOM(editorB, dom); - const root = $getRoot(); - root.clear(); - root.append(...nodes); - }); - - editorB.read(() => { - expect($collectMacroQuotes()).toHaveLength(1); - }); - hideQuote(editorB); - editorB.read(() => { - expect($collectMacroQuotes()).toHaveLength(0); - }); - }); -}); diff --git a/apps/web/src/features/block-email/util/emailHotkeys.ts b/apps/web/src/features/block-email/util/emailHotkeys.ts index 8e46c020d23..f4fe903d0f2 100644 --- a/apps/web/src/features/block-email/util/emailHotkeys.ts +++ b/apps/web/src/features/block-email/util/emailHotkeys.ts @@ -1,32 +1,10 @@ +import type { EmailThreadKeyboardHandlers } from '@app/features/email-thread/core/thread-keyboard'; import { registerHotkey } from '@core/hotkey/hotkeys'; import { TOKENS } from '@core/hotkey/tokens'; -interface EmailHotkeyHandlers { - replyToFocusedMessage: () => boolean; - replyAllToFocusedMessage?: () => boolean; - forwardFocusedMessage: () => boolean; - blockSender: () => boolean; - markDone: () => boolean; - markNotDone: () => boolean; - /** Gates which of Mark done / Mark as not done is active. */ - isThreadDone: () => boolean; - /** Whether the done state can be reversed at all — false for threads that - * are structurally done (no inbound message), where Mark as not done is a - * no-op. */ - canMarkNotDone: () => boolean; - markUnread: () => boolean; - markRead: () => boolean; - /** Gates which of Mark as unread / Mark as read is active. */ - isThreadMarkedUnread: () => boolean; - markSenderSignal: () => boolean; - markSenderNoise: () => boolean; - navigateToPreviousMessage: () => boolean; - navigateToNextMessage: () => boolean; -} - export function registerEmailHotkeys( scopeId: string, - handlers: EmailHotkeyHandlers + handlers: EmailThreadKeyboardHandlers ) { if (handlers.replyAllToFocusedMessage) { registerHotkey({ diff --git a/apps/web/src/features/block-email/util/flattenConsecutiveParagraphs.ts b/apps/web/src/features/block-email/util/flattenConsecutiveParagraphs.ts deleted file mode 100644 index 444b07dcbb5..00000000000 --- a/apps/web/src/features/block-email/util/flattenConsecutiveParagraphs.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Flattens runs of consecutive `

` siblings into a single `

` 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) { - const paragraphs = container.querySelectorAll('p'); - const groups = []; - let currentGroup: Element[] = []; - - for (let i = 0; i < paragraphs.length; i++) { - if (i === 0) { - currentGroup.push(paragraphs[i]); - continue; - } - - // Check if this paragraph immediately follows the previous one - const prev = paragraphs[i - 1]; - if (prev.nextElementSibling === paragraphs[i]) { - currentGroup.push(paragraphs[i]); - } else { - // Start a new group - groups.push(currentGroup); - currentGroup = [paragraphs[i]]; - } - } - - // Don't forget the last group - if (currentGroup.length > 0) { - groups.push(currentGroup); - } - - // Combine each group and replace in the DOM - for (let i = 0; i < groups.length; i++) { - const group = groups[i]; - const div = document.createElement('div'); - - for (let j = 0; j < group.length; j++) { - const p = group[j]; - - const isEmpty = - !p.textContent?.trim() && - !p.querySelector('img, video, iframe, canvas'); - - if (p.childNodes.length) { - div.append(...p.childNodes); - } - - if (j < group.length - 1 && !isEmpty) { - // Paragraph break = one blank line for the recipient - div.appendChild(document.createElement('br')); - div.appendChild(document.createElement('br')); - } - } - - // Replace the first paragraph with the combined div - group[0]?.parentNode?.replaceChild(div, group[0]); - - // Remove the rest of the paragraphs in this group - for (let j = 1; j < group.length; j++) { - group[j].remove(); - } - } -} diff --git a/apps/web/src/features/block-email/util/plainTextToHtml.test.ts b/apps/web/src/features/block-email/util/plainTextToHtml.test.ts deleted file mode 100644 index 7741b028f07..00000000000 --- a/apps/web/src/features/block-email/util/plainTextToHtml.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { plainTextToHtml } from './plainTextToHtml'; - -const span = (text: string) => - `${text}`; - -describe('plainTextToHtml', () => { - it('returns a br for empty string', () => { - expect(plainTextToHtml('')).toBe('


'); - }); - - it('wraps a single line in a span', () => { - expect(plainTextToHtml('hello')).toBe(`
${span('hello')}
`); - }); - - it('splits newlines with br separators', () => { - expect(plainTextToHtml('line1\nline2')).toBe( - `
${span('line1')}
${span('line2')}
` - ); - }); - - it('handles consecutive newlines as double br', () => { - expect(plainTextToHtml('above\n\nbelow')).toBe( - `
${span('above')}


${span('below')}
` - ); - }); - - it('handles only newlines', () => { - // '\n\n' splits into ['', '', ''] →
joined by
= 5
s - expect(plainTextToHtml('\n\n')).toBe('





'); - }); - - describe('html escaping', () => { - it('escapes ampersands', () => { - expect(plainTextToHtml('a & b')).toBe(`
${span('a & b')}
`); - }); - - it('escapes angle brackets', () => { - expect(plainTextToHtml('')).toBe( - `
${span('<script>alert("xss")</script>')}
` - ); - }); - }); - - describe('no markdown formatting applied', () => { - it('preserves asterisks literally', () => { - expect(plainTextToHtml('**Key Points:**')).toBe( - `
${span('**Key Points:**')}
` - ); - }); - - it('preserves underscores literally', () => { - expect(plainTextToHtml('file_name_with_underscores')).toBe( - `
${span('file_name_with_underscores')}
` - ); - }); - - it('preserves scope names with dots and underscores', () => { - expect(plainTextToHtml('gmail.settings_basic')).toBe( - `
${span('gmail.settings_basic')}
` - ); - }); - - it('preserves math expressions with asterisks', () => { - expect(plainTextToHtml('3 * 5 = 15')).toBe( - `
${span('3 * 5 = 15')}
` - ); - }); - }); - - it('handles multiline content matching editor format', () => { - const input = 'Hi,\n\nSome text here'; - expect(plainTextToHtml(input)).toBe( - `
${span('Hi,')}


${span('Some text here')}
` - ); - }); -}); diff --git a/apps/web/src/features/block-email/util/prepareEmailBody.test.ts b/apps/web/src/features/block-email/util/prepareEmailBody.test.ts deleted file mode 100644 index 3a46f9d8e37..00000000000 --- a/apps/web/src/features/block-email/util/prepareEmailBody.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -// @vitest-environment jsdom -import type { ApiMessage } from '@service-email/generated/schemas'; -import { describe, expect, it } from 'vitest'; -import { prepareEmailBodyFromHtml } from './prepareEmailBody'; - -function decodeBodyHtml(encoded: string) { - const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); - return decodeURIComponent(escape(atob(base64))); -} - -const replyingTo = { - from: { name: 'Ada Lovelace', email: 'ada@example.com' }, - to: [], - cc: [], - bcc: [], - subject: 'Numbers', - body_text: 'original message text', - internal_date_ts: '2026-08-01T12:00:00Z', - attachments: [], -} as unknown as ApiMessage; - -describe('prepareEmailBodyFromHtml', () => { - it('does not add a quote block without appendReply (undo-send restore)', () => { - const prepared = prepareEmailBodyFromHtml('

hi there

'); - const decoded = decodeBodyHtml(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 = decodeBodyHtml(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 = decodeBodyHtml(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/replyType.ts b/apps/web/src/features/block-email/util/replyType.ts deleted file mode 100644 index 606b25951e5..00000000000 --- a/apps/web/src/features/block-email/util/replyType.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ApiMessage } from '@service-email/generated/schemas'; - -export type ReplyType = 'reply' | 'reply-all' | 'forward'; - -export const getReplyTypeFromDraft: ( - draft: ApiMessage | undefined -) => ReplyType | undefined = (draft: ApiMessage | undefined) => { - if (!draft) { - return undefined; - } - - if (draft.subject?.toLowerCase().startsWith('fwd: ')) { - return 'forward'; - } else if (draft.to.length + draft.cc.length > 1) { - return 'reply-all'; - } else { - return 'reply'; - } -}; diff --git a/apps/web/src/features/command/Launcher.tsx b/apps/web/src/features/command/Launcher.tsx index 9c773bb4371..1f25c28f653 100644 --- a/apps/web/src/features/command/Launcher.tsx +++ b/apps/web/src/features/command/Launcher.tsx @@ -1,9 +1,9 @@ import { startPendingSession } from '@app/features/block-agent/context/pending-session'; import { AGENT_INPUT_TEXT_AREA_ID } from '@app/features/block-agent/ui/AgentInput'; +import { EMAIL_COMPOSE_TO_INPUT_ID } from '@app/features/email-compose/core/constants'; import { openStandaloneReminderComposer } from '@app/features/reminders/reminder-composer'; import { useFeatureFlag } from '@app/lib/analytics/posthog'; import { setAutomationComposerOpen } from '@block-automation/component'; -import { EMAIL_COMPOSE_TO_INPUT_ID } from '@block-email/constants'; import { endTrackedDocumentSpan, registerDocumentSpan, diff --git a/apps/web/src/features/block-email/component/date-selector.tsx b/apps/web/src/features/email-compose/components/date-selector.tsx similarity index 100% rename from apps/web/src/features/block-email/component/date-selector.tsx rename to apps/web/src/features/email-compose/components/date-selector.tsx diff --git a/apps/web/src/features/block-email/component/email-date-selector.tsx b/apps/web/src/features/email-compose/components/email-date-selector.tsx similarity index 94% rename from apps/web/src/features/block-email/component/email-date-selector.tsx rename to apps/web/src/features/email-compose/components/email-date-selector.tsx index 22055c3c6b4..3b47e728e87 100644 --- a/apps/web/src/features/block-email/component/email-date-selector.tsx +++ b/apps/web/src/features/email-compose/components/email-date-selector.tsx @@ -1,14 +1,14 @@ -import { DateSelector } from '@block-email/component/date-selector'; -import { isMobile } from '@core/mobile/isMobile'; import ClockIcon from '@phosphor/clock.svg'; import IconX from '@phosphor/x.svg'; import { Button, Tooltip } from '@ui'; import { addYears } from 'date-fns/addYears'; import { format } from 'date-fns/format'; import { type JSX, Show, type VoidComponent } from 'solid-js'; +import { DateSelector } from './date-selector'; interface EmailDateSelectorProps { sendTime?: Date | null; + mobile: boolean; onSendTimeChange?: (date: Date | null) => void; /** Only show the clock icon, no date text or clear button */ compact?: boolean; @@ -24,7 +24,7 @@ interface EmailDateSelectorProps { export const EmailDateSelector: VoidComponent = ( props ) => { - const isCompact = () => props.compact || isMobile(); + const isCompact = () => props.compact || props.mobile; return ( getDisplayName(emailToMacroId(props.inbox.email_address)); + const name = () => props.inbox.displayName; return ( <> getDisplayName(emailToMacroId(props.inbox.email_address)); + const name = () => props.inbox.displayName; const label = () => name() || props.inbox.email_address; return ( @@ -66,15 +66,16 @@ function FromInboxPill(props: { inbox: FromInbox; selectable: boolean }) { */ export function FromInboxSelector(props: { links: FromInbox[]; - activeLinkId: string | undefined; - onSelect: (linkId: string) => void; + activeInboxId: string | undefined; + onSelect: (inboxId: string) => void; + disabled?: boolean; compact?: boolean; pill?: boolean; class?: string; portalScope?: 'local'; }) { const activeInbox = () => - props.links.find((l) => l.id === props.activeLinkId) ?? props.links[0]; + props.links.find((l) => l.id === props.activeInboxId) ?? props.links[0]; const sortedLinks = () => [...props.links].sort((a, b) => a.email_address.localeCompare(b.email_address) @@ -90,6 +91,7 @@ export function FromInboxSelector(props: { > {active().email_address} @@ -101,7 +103,7 @@ export function FromInboxSelector(props: { {(inbox) => ( props.onSelect(inbox.id)}> - + @@ -130,6 +132,7 @@ export function FromInboxSelector(props: { > ( props.onSelect(inbox.id)}> - + @@ -172,7 +175,10 @@ export function FromInboxSelector(props: { } > - + {(inbox) => } @@ -184,7 +190,7 @@ export function FromInboxSelector(props: { {(inbox) => ( props.onSelect(inbox.id)}> - + diff --git a/apps/web/src/features/block-email/component/MacroSignatureButton.tsx b/apps/web/src/features/email-compose/components/macro-signature-button.tsx similarity index 63% rename from apps/web/src/features/block-email/component/MacroSignatureButton.tsx rename to apps/web/src/features/email-compose/components/macro-signature-button.tsx index c80a49aabbb..8e444be2e5d 100644 --- a/apps/web/src/features/block-email/component/MacroSignatureButton.tsx +++ b/apps/web/src/features/email-compose/components/macro-signature-button.tsx @@ -1,20 +1,16 @@ -import { MACRO_EMAIL_SIGNATURE } from '@block-email/constants'; -import { useHasPaidAccess } from '@core/auth'; -import { PaywallKey, usePaywallState } from '@core/constant/PaywallState'; -import { useUserContext } from '@core/context/user'; +import { MACRO_EMAIL_SIGNATURE } from '@app/features/email-compose/core/constants'; import { Tooltip } from '@ui'; import { Show } from 'solid-js'; interface MacroSignatureButtonProps { signature?: string; + visible: boolean; + onUpgrade?: () => void; } export const MacroSignatureButton = (props: MacroSignatureButtonProps) => { - const paywall = usePaywallState(); - const hasPaidAccess = useHasPaidAccess(); - const { isLoading } = useUserContext(); return ( - + {/* Hover-only guidance; hidden on mobile where tooltips never show (Settings still points mobile users to desktop). */} - + + getDisplayName(tryMacroId(`macro|${email}`)) + ); + const signatures = useFeatureFlag(enableEmailSignatures); + const save = useSaveDraftMutation(); + const remove = useDeleteDraftMutation(); + const send = useSendMessageMutation(); + const upload = useUploadDraftAttachmentsMutation(); + const forward = useAddForwardedAttachmentsMutation(); + const removeAttachment = useRemoveDraftAttachmentMutation(); + const removeForwarded = useRemoveForwardedAttachmentMutation(); + const unschedule = useUnscheduleMessageMutation(); + const { users } = useCombinedRecipients(); + const notice = (options?: ComposeNoticeOptions) => ({ + ...options, + actions: options?.actions?.map((action) => ({ + ...action, + icon: ArrowCounterClockwise, + })), + }); + const reportError = (error: unknown) => + Telemetry.error(error instanceof Error ? error : new Error(String(error))); + return { + recipientName: (id) => getDisplayName(tryMacroId(id)), + recordMention: (sourceId, targetId) => { + void trackMention(sourceId, 'document', targetId).catch(reportError); + }, + accounts: { + ...inboxSource, + primaryId: usePrimaryEmailLinkId(), + }, + viewerEmail, + recipients: users, + hasPaidAccess: useHasPaidAccess(), + presentation: { + viewerLoading: user.isLoading, + prepareSignatureLinks: interceptMailtoLinks, + onUpgrade: () => paywall.showPaywall(PaywallKey.REMOVE_SIGNATURE), + isTouch: isTouchDevice, + isMobile, + scheduleEnabled: ENABLE_EMAIL_SCHEDULED_SEND, + signaturesEnabled: () => signatures().enabled, + }, + editorFiles: { + readDroppedFiles: readDroppedEmailFiles, + makePublic: makeAttachmentPublic, + uploadEditorFiles(input) { + if (!input.editor) return; + handleFileFolderDrop( + input.files, + input.directories, + createFilesReadyHandler( + input.editor, + input.sourceId, + input.sourceId ? 'email' : undefined, + input.dropEvent && input.editor + ? () => getDragDropPosition(input.editor!, input.dropEvent!, true) + : undefined, + input.onUploaded, + { width: 542, height: 542 } + ) + ); + }, + }, + notices: { + feedback: { + success: (message, options) => toast.success(message, notice(options)), + failure: (message, options) => toast.failure(message, notice(options)), + alert: (message, options) => toast.alert(message, notice(options)), + dismiss: toast.dismiss, + }, + reportError, + }, + drafts: { + async saveDraft({ + completingThread, + previousThreadId, + inboxId, + ...input + }) { + const result = await save.mutateAsync({ + ...input, + linkId: headerId(inboxId), + skipSoupRefetch: completingThread, + }); + try { + const threadId = result.draft.thread_db_id; + if (threadId) markThreadDraftSaved(threadId); + if (previousThreadId && previousThreadId !== threadId) { + markThreadDraftSaved(previousThreadId); + invalidateSoupEntity(previousThreadId); + void refetchSoupEntity(previousThreadId, 'emailThread').catch( + reportError + ); + } + } catch (error) { + reportError(error); + } + return { + draftId: result.draft.db_id ?? undefined, + threadId: result.draft.thread_db_id ?? undefined, + inboxId: result.draft.link_id, + }; + }, + async deleteDraft({ completingThread, inboxId, ...input }) { + await remove.mutateAsync({ + ...input, + linkId: headerId(inboxId), + skipSoupRefetch: completingThread, + }); + try { + if (input.threadId) markThreadDraftSaved(input.threadId); + } catch (error) { + reportError(error); + } + }, + async restoreDraft({ threadId, draftId, draft, html, inboxId }) { + if (threadId && !isFeatureEnabled(enableGraphqlSoup)) { + queryClient.setQueryData>( + emailKeys.threadMessages(threadId).queryKey, + (old) => + old + ? { + ...old, + pages: old.pages.map((page) => ({ + ...page, + messages: page.messages.filter( + (message) => message.db_id !== draftId + ), + })), + } + : old + ); + markThreadDraftSaved(threadId); + } + if (draft && html !== undefined) + await restoreDraftBodyAfterUndo(draft, html, headerId(inboxId)); + if (threadId && isFeatureEnabled(enableGraphqlSoup)) + void fetchAndCacheThread(threadId); + }, + }, + delivery: { + async sendMessage({ completingThread, inboxId, ...input }) { + const result = await send.mutateAsync({ + ...input, + linkId: headerId(inboxId), + skipSoupRefetch: completingThread, + }); + try { + if (result.message.thread_db_id) + markThreadDraftSaved(result.message.thread_db_id); + } catch (error) { + reportError(error); + } + return { + draftId: result.message.db_id ?? undefined, + threadId: result.message.thread_db_id ?? undefined, + inboxId: result.message.link_id, + }; + }, + async unschedule({ draftId, inboxId }) { + await unschedule.mutateAsync({ + draftID: draftId, + linkId: headerId(inboxId), + }); + try { + invalidateSoupEntity(draftId); + } catch (error) { + reportError(error); + } + }, + schedule: async ({ draftId, sendTime }, inboxId) => { + await scheduleEmailMessage( + { draftID: draftId, send_time: sendTime }, + headerId(inboxId) + ); + }, + archive: async ({ threadId, value }, inboxId) => { + await archiveEmailThread({ id: threadId, value }, headerId(inboxId)); + }, + undoSend: (input) => + runUndoSend({ + draftId: input.draftId, + linkId: headerId(input.inboxId), + onUndone: async () => { + await input.onUndone(); + if (input.threadId) + void refetchSoupEntity(input.threadId, 'emailThread'); + }, + }), + }, + attachmentStorage: { + uploadAttachments: ({ draftId, inboxId, ...input }) => + upload.mutateAsync({ + ...input, + draftID: draftId, + linkId: headerId(inboxId), + }), + addForwardedAttachments: ({ draftId, attachments, inboxId }) => + forward.mutateAsync({ + draftID: draftId, + attachments: attachments.map(({ attachmentId }) => ({ + attachmentID: attachmentId, + })), + linkId: headerId(inboxId), + }), + removeAttachment: ({ draftId, attachmentId, inboxId }) => + removeAttachment.mutateAsync({ + draftID: draftId, + attachmentID: attachmentId, + linkId: headerId(inboxId), + }), + removeForwardedAttachment: ({ draftId, attachmentId, inboxId }) => + removeForwarded.mutateAsync({ + draftID: draftId, + attachmentID: attachmentId, + linkId: headerId(inboxId), + }), + }, + }; +} diff --git a/apps/web/src/features/email-compose/compose-host-adapter.ts b/apps/web/src/features/email-compose/compose-host-adapter.ts new file mode 100644 index 00000000000..e26249de100 --- /dev/null +++ b/apps/web/src/features/email-compose/compose-host-adapter.ts @@ -0,0 +1,29 @@ +import { useSplitBackInterceptor } from '@components/app/split-layout/back-interceptor'; +import { useSplitLayout } from '@components/app/split-layout/layout'; +import { useSplitPanel } from '@components/app/split-layout/layoutUtils'; +import type { EmailComposeHost } from './context/compose-capabilities'; +import { createPanelFocusSibling } from './editor-adapter'; +export function createEmailComposeHost(): EmailComposeHost { + const { replaceSplit } = useSplitLayout(); + const panel = useSplitPanel(); + return { + showThread: (id: string) => { + replaceSplit({ content: { type: 'email', id }, mergeHistory: true }); + }, + showDraft: (id: string) => { + replaceSplit({ + content: { + type: 'component', + id: 'email-compose', + params: { draftID: id }, + preserveParams: true, + }, + }); + }, + goBack: () => panel?.handle.goBack(), + focusSibling: createPanelFocusSibling(), + registerBack: (handler: () => boolean) => { + useSplitBackInterceptor(handler); + }, + }; +} diff --git a/apps/web/src/features/email-compose/context/compose-capabilities.ts b/apps/web/src/features/email-compose/context/compose-capabilities.ts new file mode 100644 index 00000000000..c7dea922129 --- /dev/null +++ b/apps/web/src/features/email-compose/context/compose-capabilities.ts @@ -0,0 +1,177 @@ +import type { LexicalEditor } from 'lexical'; +import type { Accessor } from 'solid-js'; +import type { EmailDraft } from '../core/email-draft'; +import type { EmailRecipient } from '../core/email-recipient'; + +export interface EmailInbox { + id: string; + email_address: string; + displayName?: string; + photo_url?: string | null; + settings: { + signature?: string | null; + signature_on_replies_forwards?: boolean | null; + }; +} + +/** Identity returned by a successful save or send. Transport envelopes stay in adapters. */ +export interface PersistedEmailIdentity { + draftId?: string; + threadId?: string; + inboxId: string; +} + +export interface SaveEmailDraft { + draft: EmailDraft; + sendTime?: Date | null; + previousThreadId?: string; + inboxId?: string; + completingThread?: boolean; +} +export interface DeleteEmailDraft { + draftId: string; + threadId?: string; + inboxId?: string; + completingThread?: boolean; +} +export interface SendEmailDraft { + message: EmailDraft; + inboxId?: string; + completingThread?: boolean; +} +export interface UploadEmailAttachments { + draftId: string; + attachments: File[]; + inboxId?: string; + onAttachmentAdded?: (file: File, id: string) => void; + onAttachmentUploadFailed?: (file: File) => void; +} +export interface EmailAttachmentChange { + draftId: string; + attachmentId: string; + inboxId?: string; +} + +export interface EmailDraftStorage { + saveDraft(input: SaveEmailDraft): Promise; + deleteDraft(input: DeleteEmailDraft): Promise; + restoreDraft(input: { + draftId: string; + threadId?: string; + draft?: Omit; + html?: string; + inboxId?: string; + }): Promise; +} + +export interface EmailAttachmentStorage { + uploadAttachments(input: UploadEmailAttachments): Promise; + addForwardedAttachments(input: { + draftId: string; + attachments: { attachmentId: string }[]; + inboxId?: string; + }): Promise; + removeAttachment(input: EmailAttachmentChange): Promise; + removeForwardedAttachment(input: EmailAttachmentChange): Promise; +} + +export interface EmailDelivery { + sendMessage(input: SendEmailDraft): Promise; + unschedule(input: { draftId: string; inboxId?: string }): Promise; + schedule( + input: { draftId: string; sendTime: string }, + inboxId?: string + ): Promise; + archive( + input: { threadId: string; value: boolean }, + inboxId?: string + ): Promise; + undoSend(input: { + threadId?: string; + draftId: string; + inboxId: string | undefined; + onUndone: () => Promise | void; + }): Promise; +} + +export interface EmailComposeFeedback { + feedback: { + success( + message: string, + options?: ComposeNoticeOptions + ): number | undefined; + failure(message: string, options?: ComposeNoticeOptions): void; + alert(message: string, options?: ComposeNoticeOptions): void; + dismiss(id: number): void; + }; + reportError(error: unknown): void; +} + +export interface EmailComposeAccounts { + inboxes: Accessor; + loading: Accessor; + failed: Accessor; + primaryId: Accessor; +} + +/** View wiring; controllers do not receive these presentation capabilities. */ +export interface EmailComposePresentation { + viewerLoading: Accessor; + onUpgrade(): void; + prepareSignatureLinks(root: ShadowRoot): void; + isTouch: Accessor; + isMobile: Accessor; + scheduleEnabled: boolean; + signaturesEnabled: Accessor; +} + +export interface EmailEditorFiles { + readDroppedFiles: import('./editor-capabilities').ComposeBodyActions['readDroppedFiles']; + makePublic(id: string): void; + uploadEditorFiles(input: { + editor: LexicalEditor | undefined; + sourceId?: string; + files: FileSystemFileEntry[]; + directories: FileSystemDirectoryEntry[]; + dropEvent?: DragEvent; + onUploaded(ids: string[]): void; + }): void; +} + +/** Production composition groups capabilities for views to wire into their consumers. */ +export interface EmailComposeContext { + drafts: EmailDraftStorage; + attachmentStorage: EmailAttachmentStorage; + delivery: EmailDelivery; + notices: EmailComposeFeedback; + accounts: EmailComposeAccounts; + presentation: EmailComposePresentation; + editorFiles: EmailEditorFiles; + viewerEmail: Accessor; + recipients: Accessor; + recipientName(id: string): string; + hasPaidAccess: Accessor; + recordMention(sourceId: string, targetId: string): void; +} + +export interface ComposeNoticeOptions { + subtext?: string; + duration?: number; + actions?: { label: string; onClick: () => void }[]; +} +export interface EmailComposeHost { + focusSibling?: (direction: 'next' | 'prev') => boolean | void; + showThread?: (id: string) => void; + showDraft?: (id: string) => void; + goBack?: () => void; + registerBack?: (handler: () => boolean) => void; +} +export interface EmailUndoHandle { + id: string; + undo(callbacks?: { + onSuccess?: () => void; + onError?: (error: Error) => void; + onSettled?: () => void; + }): Promise; + dispose(): void; +} diff --git a/apps/web/src/features/email-compose/context/compose-context.ts b/apps/web/src/features/email-compose/context/compose-context.ts new file mode 100644 index 00000000000..2a28938bfaa --- /dev/null +++ b/apps/web/src/features/email-compose/context/compose-context.ts @@ -0,0 +1,14 @@ +import { createContext, useContext } from 'solid-js'; +import type { ComposeContextValue } from '../primitives/compose-view-state'; + +const ComposeContext = createContext(); + +export const ComposeProvider = ComposeContext.Provider; + +export function useCompose(): ComposeContextValue { + const ctx = useContext(ComposeContext); + if (!ctx) { + throw new Error('useCompose must be used within a ComposeProvider'); + } + return ctx; +} diff --git a/apps/web/src/features/email-compose/context/editor-capabilities.ts b/apps/web/src/features/email-compose/context/editor-capabilities.ts new file mode 100644 index 00000000000..92f9e983051 --- /dev/null +++ b/apps/web/src/features/email-compose/context/editor-capabilities.ts @@ -0,0 +1,15 @@ +import type { LexicalEditor } from 'lexical'; +export interface ComposeBodyActions { + focusSibling?: (direction: 'next' | 'prev') => boolean | void; + recipientAdded(email: string): void; + readDroppedFiles( + files: FileSystemFileEntry[], + directories: FileSystemDirectoryEntry[], + onFiles: (files: File[]) => void + ): void; + pasteFiles( + editor: LexicalEditor, + files: FileSystemFileEntry[], + directories: FileSystemDirectoryEntry[] + ): void; +} diff --git a/apps/web/src/features/block-email/component/EmailFormContext.tsx b/apps/web/src/features/email-compose/context/email-form-context.tsx similarity index 60% rename from apps/web/src/features/block-email/component/EmailFormContext.tsx rename to apps/web/src/features/email-compose/context/email-form-context.tsx index b6085da3045..84a031008d4 100644 --- a/apps/web/src/features/block-email/component/EmailFormContext.tsx +++ b/apps/web/src/features/email-compose/context/email-form-context.tsx @@ -2,19 +2,18 @@ import { createContext, type ParentProps, useContext } from 'solid-js'; import { createEmailFormState, type EmailFormStateOptions, -} from './createEmailFormState'; - -type EmailFormContextValue = ReturnType; - -type FormAccessKey = - | { type: 'replying_to'; messageID: string; seed?: string } - | { type: 'draft'; messageID: string; seed?: string }; - -// `seed` identifies the draft version the form seeds from (see EmailInput's +} from '../primitives/email-form-state'; +import type { + EmailFormContextValue, + FormAccessKey, +} from '../primitives/email-form-types'; +import type { EmailFormContextInputs } from './email-form-inputs'; + +// `seed` identifies the draft version the form seeds from (see ThreadReplyInput's // seed key), so a composer remounting on a newer draft version gets a // freshly derived form instead of the cached one. const stringifyKey = (key: FormAccessKey) => { - return `${key.type}_${key.messageID}_${key.seed ?? ''}`; + return `${key.type}_${key.messageId}_${key.seed ?? ''}`; }; type RegistryApi = { @@ -24,18 +23,21 @@ type RegistryApi = { const EmailFormRegistryCtx = createContext(); export function EmailFormContextProvider( - props: ParentProps<{ formOptions: EmailFormStateOptions }> + props: ParentProps<{ + context: EmailFormContextInputs; + formOptions: EmailFormStateOptions; + }> ) { const map = new Map(); const getOrInit: RegistryApi['getOrInit'] = (key) => { if (!key) { - return createEmailFormState(); + return createEmailFormState(props.context); } const stringifiedKey = stringifyKey(key); let existing = map.get(stringifiedKey); if (!existing) { - existing = createEmailFormState(key, props.formOptions); + existing = createEmailFormState(props.context, key, props.formOptions); map.set(stringifiedKey, existing); } return existing; @@ -48,16 +50,6 @@ export function EmailFormContextProvider( ); } -// Use this to get lazy access to getOrInit, e.g. when you don't need to create a new email form context until some UI is interacted with -export function getEmailFormRegistry(): RegistryApi { - const ctx = useContext(EmailFormRegistryCtx); - if (!ctx) - throw new Error( - 'useEmailFormRegistry must be used within EmailFormContextProvider' - ); - return ctx; -} - export function getOrInitEmailFormContext( key?: FormAccessKey ): EmailFormContextValue { diff --git a/apps/web/src/features/email-compose/context/email-form-inputs.ts b/apps/web/src/features/email-compose/context/email-form-inputs.ts new file mode 100644 index 00000000000..bcad84d245c --- /dev/null +++ b/apps/web/src/features/email-compose/context/email-form-inputs.ts @@ -0,0 +1,30 @@ +import type { Accessor } from 'solid-js'; +import type { EmailRecipient } from '../core/email-recipient'; +import type { ReplyType } from '../core/reply-type'; + +export interface EmailFormContextInputs { + viewerEmail: Accessor; + inboxes: Accessor<{ id: string; email_address: string }[]>; +} + +/** Only the reply capabilities consumed by the composer; the caller owns navigation. */ +export interface EmailReplySession { + thread: Accessor< + | { + db_id: string; + link_id: string; + inbox_visible: boolean; + provider_id?: string | null; + } + | undefined + >; + recipientOptions: Accessor; + isPersonalReply: Accessor; + onDraftRemoved(): void; + exitToThread(target: 'last' | 'selected'): boolean; + replyRequest: { + replyType: Accessor; + clear(): void; + }; + getMarkDoneNavigationTargetId(): string | undefined; +} diff --git a/apps/web/src/features/block-email/constants.ts b/apps/web/src/features/email-compose/core/constants.ts similarity index 83% rename from apps/web/src/features/block-email/constants.ts rename to apps/web/src/features/email-compose/core/constants.ts index 30c91261f2f..e62cbb35798 100644 --- a/apps/web/src/features/block-email/constants.ts +++ b/apps/web/src/features/email-compose/core/constants.ts @@ -1,7 +1,3 @@ -export const URL_PARAMS = { - messageId: 'email_message_id', -}; - export const MACRO_EMAIL_SIGNATURE = '-- Sent with Macro'; export const MAX_ATTACHMENTS_BYTES_SIZE = 18_000_000; diff --git a/apps/web/src/features/block-email/util/decodeBase64.ts b/apps/web/src/features/email-compose/core/decode-base64.ts similarity index 100% rename from apps/web/src/features/block-email/util/decodeBase64.ts rename to apps/web/src/features/email-compose/core/decode-base64.ts diff --git a/apps/web/src/features/email-compose/core/email-draft.ts b/apps/web/src/features/email-compose/core/email-draft.ts new file mode 100644 index 00000000000..3c0615f649d --- /dev/null +++ b/apps/web/src/features/email-compose/core/email-draft.ts @@ -0,0 +1,18 @@ +import type { EmailContact } from '../../email-message/core/email-message'; + +/** Feature-owned values. Transport adaptation belongs to queries. */ +export interface EmailDraft { + bcc?: EmailContact[] | null; + body_html?: string | null; + body_macro?: string | null; + body_text?: string | null; + cc?: EmailContact[] | null; + db_id?: string | null; + include_signature?: boolean | null; + provider_id?: string | null; + provider_thread_id?: string | null; + replying_to_id?: string | null; + subject: string; + thread_db_id?: string | null; + to?: EmailContact[] | null; +} diff --git a/apps/web/src/features/email-compose/core/email-recipient.ts b/apps/web/src/features/email-compose/core/email-recipient.ts new file mode 100644 index 00000000000..7fb1f1102de --- /dev/null +++ b/apps/web/src/features/email-compose/core/email-recipient.ts @@ -0,0 +1,34 @@ +import type { EmailContact } from '../../email-message/core/email-message'; +import { getFirstName } from '../../email-message/core/name'; + +/** Address choices accepted by the composer and shared recipient controls. */ +export type EmailRecipient = + | { + kind: 'user'; + id: string; + data: { + id: string; + email: string; + name: string; + photoUrl?: string; + lastInteraction?: Date | string; + }; + } + | { + kind: 'contact'; + id: string; + data: EmailContact & { id: string; type: 'extracted' }; + } + | { + kind: 'custom'; + id: string; + data: { id: string; email: string; invalid: boolean }; + }; + +export type RecipientFieldId = 'to' | 'cc' | 'bcc'; +export type EmailFormRecipients = Record; + +export function getRecipientDisplayName(item: EmailRecipient): string { + if (item.kind === 'custom') return item.data.email; + return getFirstName(item.data.name) || item.data.email; +} diff --git a/apps/web/src/features/block-email/util/mailto.test.ts b/apps/web/src/features/email-compose/core/mailto.test.ts similarity index 100% rename from apps/web/src/features/block-email/util/mailto.test.ts rename to apps/web/src/features/email-compose/core/mailto.test.ts diff --git a/apps/web/src/features/block-email/util/mailto.ts b/apps/web/src/features/email-compose/core/mailto.ts similarity index 100% rename from apps/web/src/features/block-email/util/mailto.ts rename to apps/web/src/features/email-compose/core/mailto.ts diff --git a/apps/web/src/features/email-compose/core/plain-text-to-html.test.ts b/apps/web/src/features/email-compose/core/plain-text-to-html.test.ts new file mode 100644 index 00000000000..1c33232fc81 --- /dev/null +++ b/apps/web/src/features/email-compose/core/plain-text-to-html.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { plainTextToHtml } from './plain-text-to-html'; + +function body(text: string) { + return new DOMParser().parseFromString(plainTextToHtml(text), 'text/html') + .body; +} + +describe('plaintext imported into the editor', () => { + it.each([ + { input: '', breaks: 1, text: '' }, + { input: 'line1\nline2', breaks: 1, text: 'line1line2' }, + { input: 'above\n\nbelow', breaks: 3, text: 'abovebelow' }, + { input: '\n\n', breaks: 5, text: '' }, + ])( + 'preserves the existing line spacing for $input', + ({ input, breaks, text }) => { + const result = body(input); + expect(result.querySelectorAll('br')).toHaveLength(breaks); + expect(result.textContent).toBe(text); + } + ); + + it('renders HTML-looking content and ampersands as literal text', () => { + const input = ' & '; + const result = body(input); + expect(result.textContent).toBe(input); + expect(result.querySelector('script, img')).toBeNull(); + }); + + it('preserves Markdown punctuation and whitespace without introducing formatting', () => { + const input = '**Key Points:** file_name gmail.settings_basic 3 * 5 = 15'; + const result = body(input); + expect(result.textContent).toBe(input); + expect(result.querySelector('strong, em, code')).toBeNull(); + expect(result.querySelector('[style]')?.getAttribute('style')).toContain( + 'white-space: pre-wrap' + ); + }); +}); diff --git a/apps/web/src/features/block-email/util/plainTextToHtml.ts b/apps/web/src/features/email-compose/core/plain-text-to-html.ts similarity index 100% rename from apps/web/src/features/block-email/util/plainTextToHtml.ts rename to apps/web/src/features/email-compose/core/plain-text-to-html.ts diff --git a/apps/web/src/features/block-email/util/recipientConversion.ts b/apps/web/src/features/email-compose/core/recipient-conversion.ts similarity index 53% rename from apps/web/src/features/block-email/util/recipientConversion.ts rename to apps/web/src/features/email-compose/core/recipient-conversion.ts index 2f3803a5b44..6225c656647 100644 --- a/apps/web/src/features/block-email/util/recipientConversion.ts +++ b/apps/web/src/features/email-compose/core/recipient-conversion.ts @@ -1,18 +1,9 @@ -import { - type ContactInfo, - type ExtractedContactInfo, - emailToId, - recipientEntityMapper, -} from '@core/user'; -import type { ApiMessage } from '@service-email/generated/schemas'; -import type { EmailRecipient } from '../component/EmailContext'; - -const extractedContactInfo = (contact: ContactInfo): ExtractedContactInfo => ({ - ...contact, - id: emailToId(contact.email), - type: 'extracted', -}); - +import type { + EmailFormRecipients, + EmailRecipient, +} from '@app/features/email-compose/core/email-recipient'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; +import type { EmailContact as ContactInfo } from '../../email-message/core/email-message'; export const convertEmailRecipientToContactInfo = ( item: EmailRecipient ): ContactInfo => { @@ -29,21 +20,17 @@ export const convertEmailRecipientToContactInfo = ( export const convertContactInfoToEmailRecipient = ( contact: ContactInfo ): EmailRecipient => { - return recipientEntityMapper('contact')(extractedContactInfo(contact)); + const id = `macro|${contact.email}`; + return { kind: 'contact', id, data: { ...contact, id, type: 'extracted' } }; }; // Note: because of the logic, this works with a reference message that is either the message being replied to, or the draft. export const getReplyAllRecipients = ( - referenceMessage: ApiMessage | undefined, + referenceMessage: EmailMessage | undefined, userEmail: string -): { - to: EmailRecipient[]; - cc: EmailRecipient[]; - bcc: EmailRecipient[]; -} => { +): EmailFormRecipients => { + if (!referenceMessage) return { to: [], cc: [], bcc: [] }; let to: EmailRecipient[] = []; - let cc: EmailRecipient[] = []; - if (!referenceMessage) return { to, cc, bcc: [] }; // If last message was from user - reply to the to recipients (cc is handled separately below) if (referenceMessage?.from?.email === userEmail) { @@ -63,42 +50,16 @@ export const getReplyAllRecipients = ( ); to = [sender, ...otherRecipients].map(convertContactInfoToEmailRecipient); } - if ( - referenceMessage.cc && - referenceMessage.cc.filter((recipient) => recipient.email !== userEmail) - .length > 0 - ) { - cc = referenceMessage.cc - .filter((recipient) => recipient.email !== userEmail) - .map(convertContactInfoToEmailRecipient); - } + const cc = (referenceMessage.cc ?? []) + .filter((recipient) => recipient.email !== userEmail) + .map(convertContactInfoToEmailRecipient); return { to, cc, bcc: [] }; }; -// Whether Reply-all is meaningfully different from Reply for this message. -// Hidden when the user sent the message (Reply == Reply-all per -// getReplyRecipientsFromParent), or when no recipient remains in to/cc -// after filtering out both the user and the sender. -export const isReplyAllEligible = ( - message: ApiMessage, - userEmail: string -): boolean => { - const sender = message.from?.email; - if (sender === userEmail) return false; - const isOther = (email: string) => email !== userEmail && email !== sender; - const otherTo = message.to.filter((r) => isOther(r.email)); - const otherCc = message.cc.filter((r) => isOther(r.email)); - return otherTo.length + otherCc.length > 0; -}; - export const getReplyRecipientsFromParent = ( - replyingTo: ApiMessage | undefined, + replyingTo: EmailMessage | undefined, userEmail: string -): { - to: EmailRecipient[]; - cc: EmailRecipient[]; - bcc: EmailRecipient[]; -} => { +): EmailFormRecipients => { if (!replyingTo) return { to: [], cc: [], bcc: [] }; // If last message was from user, reply === replyAll if (replyingTo?.from?.email === userEmail) { diff --git a/apps/web/src/features/email-compose/core/reply-type.ts b/apps/web/src/features/email-compose/core/reply-type.ts new file mode 100644 index 00000000000..24fbe7b8f6b --- /dev/null +++ b/apps/web/src/features/email-compose/core/reply-type.ts @@ -0,0 +1 @@ +export type ReplyType = 'reply' | 'reply-all' | 'forward'; diff --git a/apps/web/src/features/block-email/util/subjectText.test.ts b/apps/web/src/features/email-compose/core/subject-text.test.ts similarity index 88% rename from apps/web/src/features/block-email/util/subjectText.test.ts rename to apps/web/src/features/email-compose/core/subject-text.test.ts index d4774879f64..2dd8e9ee9bb 100644 --- a/apps/web/src/features/block-email/util/subjectText.test.ts +++ b/apps/web/src/features/email-compose/core/subject-text.test.ts @@ -1,6 +1,6 @@ -import type { ApiMessage } from '@service-email/generated/schemas'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; import { describe, expect, it } from 'vitest'; -import { displaySubject, getSubjectText } from './subjectText'; +import { displaySubject, getSubjectText } from './subject-text'; describe('displaySubject', () => { it('returns the subject as-is when there is nothing to strip', () => { @@ -36,8 +36,8 @@ describe('displaySubject', () => { }); describe('getSubjectText', () => { - const messageWithSubject = (subject: string): ApiMessage => - ({ subject }) as ApiMessage; + const messageWithSubject = (subject: string): EmailMessage => + ({ subject }) as EmailMessage; it('preserves an existing reply prefix regardless of case', () => { expect(getSubjectText(messageWithSubject('Re: Q3 contract'), 'reply')).toBe( diff --git a/apps/web/src/features/block-email/util/subjectText.ts b/apps/web/src/features/email-compose/core/subject-text.ts similarity index 86% rename from apps/web/src/features/block-email/util/subjectText.ts rename to apps/web/src/features/email-compose/core/subject-text.ts index 57047a549a1..9f89f25894b 100644 --- a/apps/web/src/features/block-email/util/subjectText.ts +++ b/apps/web/src/features/email-compose/core/subject-text.ts @@ -1,5 +1,5 @@ -import type { ApiMessage } from '@service-email/generated/schemas'; -import type { ReplyType } from './replyType'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; +import type { ReplyType } from './reply-type'; const NO_SUBJECT = '[No subject]'; @@ -18,7 +18,7 @@ export const isPlaceholderSubject = (title: string): boolean => title === NO_SUBJECT; export const getSubjectText = ( - replyingTo: ApiMessage | undefined, + replyingTo: EmailMessage | undefined, replyType: ReplyType | undefined ) => { if (!replyingTo) return ''; diff --git a/apps/web/src/features/email-compose/editor-adapter.ts b/apps/web/src/features/email-compose/editor-adapter.ts new file mode 100644 index 00000000000..4d8438862e3 --- /dev/null +++ b/apps/web/src/features/email-compose/editor-adapter.ts @@ -0,0 +1,57 @@ +import { useSplitPanel } from '@components/app/split-layout/layoutUtils'; +import { createFilesReadyHandler } from '@core/component/LexicalMarkdown/utils/fileUploadUtils'; +import { toast } from '@core/component/Toast/Toast'; +import { handleFileFolderDrop } from '@core/util/upload'; +import { type FocusableElement, tabbable } from 'tabbable'; +import type { ComposeBodyActions } from './context/editor-capabilities'; +import { makeAttachmentPublic } from './make-attachment-public'; + +export function createPanelFocusSibling() { + const panel = useSplitPanel(); + return (direction: 'next' | 'prev') => { + const root = panel?.panelRef(); + if (!root) return false; + const elements = tabbable(root); + const index = elements.indexOf(document.activeElement as FocusableElement); + const target = + index < 0 + ? elements.at(-1) + : elements[index + (direction === 'next' ? 1 : -1)]; + if (!target) return false; + target.focus(); + return true; + }; +} + +export const readDroppedEmailFiles: ComposeBodyActions['readDroppedFiles'] = ( + files, + directories, + onFiles +) => + handleFileFolderDrop(files, directories, (uploaded) => + onFiles(uploaded.map((item) => item.file)) + ); + +export function createComposeBodyActions(): ComposeBodyActions { + return { + focusSibling: createPanelFocusSibling(), + recipientAdded: (email) => { + toast.success(`${email} added to CC`); + }, + readDroppedFiles: readDroppedEmailFiles, + pasteFiles(editor, files, directories) { + handleFileFolderDrop( + files, + directories, + createFilesReadyHandler( + editor, + undefined, + undefined, + undefined, + (ids) => ids.forEach(makeAttachmentPublic), + { width: 542, height: 542 } + ) + ); + }, + }; +} diff --git a/apps/web/src/features/email-compose/email-compose.tsx b/apps/web/src/features/email-compose/email-compose.tsx new file mode 100644 index 00000000000..da37f232072 --- /dev/null +++ b/apps/web/src/features/email-compose/email-compose.tsx @@ -0,0 +1,11 @@ +import type { ComponentProps } from 'solid-js'; +import { createEmailComposeContext } from './compose-adapter'; +import { createEmailComposeHost } from './compose-host-adapter'; +import { EmailComposeView } from './views/email-compose'; +export function EmailCompose( + props: Omit, 'context' | 'host'> +) { + const composeContext = createEmailComposeContext(); + const host = createEmailComposeHost(); + return ; +} diff --git a/apps/web/src/features/block-email/util/makeAttachmentPublic.ts b/apps/web/src/features/email-compose/make-attachment-public.ts similarity index 62% rename from apps/web/src/features/block-email/util/makeAttachmentPublic.ts rename to apps/web/src/features/email-compose/make-attachment-public.ts index 3fe40e1c862..3c62aeb7ffa 100644 --- a/apps/web/src/features/block-email/util/makeAttachmentPublic.ts +++ b/apps/web/src/features/email-compose/make-attachment-public.ts @@ -1,29 +1,11 @@ import { analytics } from '@app/lib/analytics'; import { toast } from '@core/component/Toast/Toast'; - import { Telemetry } from '@macro-inc/observability'; - -import { storageServiceClient } from '@service-storage/client'; +import { ensureEmailAttachmentPublic } from '@queries/email/integration'; export const makeAttachmentPublic = async (attachmentId: string) => { - const permissions = await storageServiceClient.getDocumentPermissions({ - document_id: attachmentId, - }); - if ( - !permissions.isErr() && - permissions.value.linkShare === 'PUBLIC' && - permissions.value.linkShareAccessLevel === 'view' - ) { - return; - } - - const result = await storageServiceClient.editDocument({ - documentId: attachmentId, - sharePermission: { - linkShare: 'PUBLIC', - linkShareAccessLevel: 'view', - }, - }); + const result = await ensureEmailAttachmentPublic(attachmentId); + if (!result) return; if (!result.isErr()) { toast.success('Recipients can now view this file', { subtext: 'File share permissions have been updated to public view-only', diff --git a/apps/web/src/features/email-compose/primitives/appended-reply-round-trip.test.ts b/apps/web/src/features/email-compose/primitives/appended-reply-round-trip.test.ts new file mode 100644 index 00000000000..dc528e2aadc --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/appended-reply-round-trip.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment jsdom + +import { $generateNodesFromDOM } from '@lexical/html'; +import { ClassedBlockNode } from '@macro-inc/lexical-core'; +import { $getRoot, $nodesOfType } from 'lexical'; +import { describe, expect, it } from 'vitest'; +import { message } from '../../email-message/tests/messages'; +import { decodeBase64Utf8 } from '../core/decode-base64'; +import { createEmailEditor } from '../tests/editor'; +import { + prepareEmailBody, + registerToggleAppendedThread, + TOGGLE_APPEND_EMAIL_THREAD_COMMAND, +} from './prepare-email-body'; + +const replyingTo = message('original', { + replying_to_id: 'parent-message-id', + 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: [], +}); + +function makeEditor() { + const editor = createEmailEditor('my reply'); + registerToggleAppendedThread(editor); + return editor; +} + +function $collectMacroQuotes() { + return $nodesOfType(ClassedBlockNode).filter((node) => + node.exportJSON().classes.includes('macro_quote') + ); +} + +function appendQuote(editor: ReturnType) { + editor.dispatchCommand(TOGGLE_APPEND_EMAIL_THREAD_COMMAND, { + replyingTo, + replyType: 'reply', + visible: true, + }); +} + +function hideQuote(editor: ReturnType) { + editor.dispatchCommand(TOGGLE_APPEND_EMAIL_THREAD_COMMAND, { + replyingTo, + replyType: 'reply', + visible: false, + }); +} + +describe('appended reply draft round trip', () => { + it('keeps one removable quote before and after saving and reloading a draft', () => { + const editorA = makeEditor(); + appendQuote(editorA); + editorA.read(() => expect($collectMacroQuotes()).toHaveLength(1)); + hideQuote(editorA); + editorA.read(() => expect($collectMacroQuotes()).toHaveLength(0)); + appendQuote(editorA); + const prepared = prepareEmailBody(editorA); + const html = decodeBase64Utf8(prepared!.bodyHtml); + + // Reload path: setEditorStateFromHtml -> $generateNodesFromDOM. + const editorB = makeEditor(); + editorB.update(() => { + const dom = new DOMParser().parseFromString(html, 'text/html'); + const nodes = $generateNodesFromDOM(editorB, dom); + const root = $getRoot(); + root.clear(); + root.append(...nodes); + }); + + editorB.read(() => { + expect($collectMacroQuotes()).toHaveLength(1); + expect($getRoot().getTextContent()).toContain('original message text'); + }); + editorB.dispatchCommand(TOGGLE_APPEND_EMAIL_THREAD_COMMAND, { + replyingTo, + replyType: 'forward', + visible: true, + }); + editorB.read(() => { + expect($collectMacroQuotes()).toHaveLength(1); + }); + hideQuote(editorB); + editorB.read(() => { + expect($collectMacroQuotes()).toHaveLength(0); + expect($getRoot().getTextContent().trim()).toBe('my reply'); + }); + }); +}); diff --git a/apps/web/src/features/email-compose/primitives/attachment-persistence.test.ts b/apps/web/src/features/email-compose/primitives/attachment-persistence.test.ts new file mode 100644 index 00000000000..91c09b6c746 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/attachment-persistence.test.ts @@ -0,0 +1,113 @@ +import { createRoot } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { UploadEmailAttachments } from '../context/compose-capabilities'; +import { createAttachmentPersistence } from './attachment-persistence'; +import { createEmailFormState } from './email-form-state'; + +const disposers: (() => void)[] = []; +afterEach(() => disposers.splice(0).forEach((dispose) => dispose())); + +function setup( + uploadAttachments: (input: UploadEmailAttachments) => Promise +) { + return createRoot((dispose) => { + disposers.push(dispose); + const form = createEmailFormState({ + viewerEmail: () => undefined, + inboxes: () => [], + }); + const services = { + uploadAttachments: vi.fn(uploadAttachments), + removeAttachment: vi.fn(async () => {}), + removeForwardedAttachment: vi.fn(async () => {}), + }; + const persistence = createAttachmentPersistence({ + services, + attachments: form.attachments, + draftId: () => 'draft', + inboxId: () => 'secondary-inbox', + }); + return { form, services, persistence }; + }); +} + +describe('draft attachment persistence', () => { + it('waits for earlier content uploads even after their attachment IDs are assigned', async () => { + const { promise: uploadFinished, resolve: finish } = + Promise.withResolvers(); + const file = new File(['content'], 'notes.txt'); + const state = setup(async (input) => { + input.onAttachmentAdded?.(file, 'attachment'); + await uploadFinished; + }); + state.form.attachments.add({ type: 'local', file }); + const first = state.persistence.upload('draft'); + let secondDone = false; + const second = state.persistence.upload('draft').then(() => { + secondDone = true; + }); + await Promise.resolve(); + expect(secondDone).toBe(false); + expect(state.persistence.uploading()).toBe(true); + expect(state.services.uploadAttachments).toHaveBeenCalledOnce(); + finish(); + await Promise.all([first, second]); + expect(secondDone).toBe(true); + expect(state.persistence.uploading()).toBe(false); + expect(state.services.uploadAttachments.mock.calls[0][0].inboxId).toBe( + 'secondary-inbox' + ); + }); + + it('propagates an upload failure and allows a subsequent retry', async () => { + const file = new File(['content'], 'notes.txt'); + const state = setup(async (input) => { + input.onAttachmentUploadFailed?.(file); + throw new Error('Upload failed'); + }); + state.form.attachments.add({ type: 'local', file }); + await expect(state.persistence.upload('draft')).rejects.toThrow( + 'Upload failed' + ); + expect(state.persistence.uploading()).toBe(false); + state.services.uploadAttachments.mockImplementationOnce(async (input) => { + input.onAttachmentAdded?.(file, 'retry-attachment'); + }); + await state.persistence.upload('draft'); + expect(state.form.attachments.list()[0].attachmentId).toBe( + 'retry-attachment' + ); + }); + + it('removes local and forwarded attachments through the appropriate operation', async () => { + const state = setup(async () => {}); + const local = { + type: 'local' as const, + file: new File(['x'], 'notes.txt'), + attachmentId: 'local', + }; + const forwarded = { + type: 'forwarded' as const, + attachmentId: 'forwarded', + fileName: 'forward.txt', + mimeType: 'text/plain', + fileSize: 1, + }; + state.form.attachments.add(local); + state.form.attachments.add(forwarded); + state.persistence.remove(local); + state.persistence.remove(forwarded); + expect(state.form.attachments.list()).toEqual([]); + expect(state.services.removeAttachment).toHaveBeenCalledWith({ + draftId: 'draft', + attachmentId: 'local', + inboxId: 'secondary-inbox', + }); + expect(state.services.removeForwardedAttachment).toHaveBeenCalledWith({ + draftId: 'draft', + attachmentId: 'forwarded', + inboxId: 'secondary-inbox', + }); + await Promise.resolve(); + }); +}); diff --git a/apps/web/src/features/email-compose/primitives/attachment-persistence.ts b/apps/web/src/features/email-compose/primitives/attachment-persistence.ts new file mode 100644 index 00000000000..8d378034ccf --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/attachment-persistence.ts @@ -0,0 +1,88 @@ +import { type Accessor, createSignal } from 'solid-js'; +import type { EmailAttachmentStorage } from '../context/compose-capabilities'; +import type { DraftFormAttachment } from './email-form-state'; +import type { EmailFormContextValue } from './email-form-types'; + +type AttachmentState = Pick< + EmailFormContextValue['attachments'], + | 'list' + | 'assignAttachmentId' + | 'clearAttachmentId' + | 'removeByFile' + | 'removeById' + | 'removeForwarded' +>; + +/** Attachment transport and completion, independent of draft/send orchestration. */ +export function createAttachmentPersistence(options: { + attachments: AttachmentState; + draftId: Accessor; + inboxId: Accessor; + services: Pick< + EmailAttachmentStorage, + 'uploadAttachments' | 'removeAttachment' | 'removeForwardedAttachment' + >; +}) { + // An assigned ID only proves that the attachment record exists. Every save + // must also wait for content uploads started by earlier concurrent saves. + const inFlight = new Set>(); + const [uploading, setUploading] = createSignal(false); + + return { + uploading, + async upload(draftId: string, inbox = { inboxId: options.inboxId() }) { + const attachments = options.attachments + .list() + .filter( + ( + attachment + ): attachment is Extract => + attachment.type === 'local' && !attachment.attachmentId + ); + let run: Promise | undefined; + if (attachments.length) { + run = options.services.uploadAttachments({ + draftId: draftId, + attachments: attachments.map((attachment) => attachment.file), + inboxId: inbox.inboxId, + onAttachmentAdded: options.attachments.assignAttachmentId, + onAttachmentUploadFailed: options.attachments.clearAttachmentId, + }); + const settled = run.then( + () => undefined, + () => undefined + ); + inFlight.add(settled); + setUploading(true); + void settled.then(() => { + inFlight.delete(settled); + setUploading(inFlight.size > 0); + }); + } + while (inFlight.size) await Promise.all([...inFlight]); + // All work has settled; rethrow this save's own upload failure. + if (run) await run; + }, + remove(attachment: DraftFormAttachment) { + const state = options.attachments; + if (attachment.type === 'local') state.removeByFile(attachment.file); + else if (attachment.type === 'forwarded') + state.removeForwarded(attachment.attachmentId); + else state.removeById(attachment.attachmentId); + + const draftId = options.draftId(); + if (!draftId || !attachment.attachmentId) return; + const operation = + attachment.type === 'forwarded' + ? options.services.removeForwardedAttachment + : options.services.removeAttachment; + void operation({ + draftId, + attachmentId: attachment.attachmentId, + inboxId: options.inboxId(), + }).catch(() => { + // The attachment query reports removal failures; keep optimistic removal. + }); + }, + }; +} diff --git a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts new file mode 100644 index 00000000000..703f025baee --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { PersistedEmailIdentity } from '../context/compose-capabilities'; +import { decodeBase64Utf8 } from '../core/decode-base64'; +import { createComposeContext } from '../tests/capabilities'; +import { mountEmailComposer } from '../tests/composer'; + +const response: PersistedEmailIdentity = { + draftId: 'saved-id', + threadId: 'thread', + inboxId: 'inbox', +}; +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +it('flushes the latest pending body and envelope exactly once on disposal', async () => { + const composeContext = createComposeContext(); + const root = mountEmailComposer(composeContext); + root.edit('Last-second edit', 'Launch review'); + root.dispose(); + await vi.advanceTimersByTimeAsync(1000); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); + const { draft } = vi.mocked(composeContext.drafts.saveDraft).mock.calls[0][0]; + expect(decodeBase64Utf8(draft.body_html ?? '')).toContain('Last-second edit'); + expect(draft.subject).toBe('Launch review'); + expect(draft.to).toEqual([ + expect.objectContaining({ email: 'colleague@example.com' }), + ]); +}); +it('does not save an untouched composer or repeat a settled autosave on disposal', async () => { + const composeContext = createComposeContext(); + const untouched = mountEmailComposer(composeContext); + untouched.dispose(); + await vi.advanceTimersByTimeAsync(1000); + expect(composeContext.drafts.saveDraft).not.toHaveBeenCalled(); + const edited = mountEmailComposer(composeContext); + edited.edit('Saved'); + await vi.advanceTimersByTimeAsync(600); + edited.dispose(); + await vi.advanceTimersByTimeAsync(1000); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); +}); +it('serializes a disposal flush behind the first save and reuses its returned ID', async () => { + const pending = Promise.withResolvers(); + const composeContext = createComposeContext(); + vi.mocked(composeContext.drafts.saveDraft).mockReturnValueOnce( + pending.promise + ); + const root = mountEmailComposer(composeContext); + root.edit('First'); + await vi.advanceTimersByTimeAsync(600); + root.edit('Latest'); + root.dispose(); + await vi.advanceTimersByTimeAsync(600); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); + pending.resolve(response); + await vi.advanceTimersByTimeAsync(1); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(2); + const { draft } = vi.mocked(composeContext.drafts.saveDraft).mock.calls[1][0]; + expect(draft.db_id).toBe('saved-id'); + expect(decodeBase64Utf8(draft.body_html ?? '')).toContain('Latest'); +}); +it('discard cancels an unsaved debounce without creating a draft', async () => { + const composeContext = createComposeContext(); + const root = mountEmailComposer(composeContext); + root.edit('Discard me'); + await root.state.deleteDraftAndReset(); + root.dispose(); + await vi.advanceTimersByTimeAsync(1000); + expect(composeContext.drafts.saveDraft).not.toHaveBeenCalled(); + expect(composeContext.drafts.deleteDraft).not.toHaveBeenCalled(); +}); +it('discard waits for an in-flight first save and deletes its returned draft', async () => { + const pending = Promise.withResolvers(); + const composeContext = createComposeContext(); + vi.mocked(composeContext.drafts.saveDraft).mockReturnValueOnce( + pending.promise + ); + const root = mountEmailComposer(composeContext); + root.edit('First'); + await vi.advanceTimersByTimeAsync(600); + root.edit('Discard these changes too'); + const discard = root.state.deleteDraftAndReset(); + root.dispose(); + pending.resolve(response); + await discard; + await vi.advanceTimersByTimeAsync(1000); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.drafts.deleteDraft).toHaveBeenCalledWith( + expect.objectContaining({ draftId: 'saved-id' }) + ); +}); +it('keeps the draft editable after failed deletion and saves later edits', async () => { + const composeContext = createComposeContext(); + const root = mountEmailComposer(composeContext); + root.edit('Saved'); + await vi.advanceTimersByTimeAsync(600); + vi.mocked(composeContext.drafts.deleteDraft).mockRejectedValueOnce( + new Error('offline') + ); + await expect(root.state.deleteDraftAndReset()).rejects.toThrow('offline'); + root.edit('Still here'); + await vi.advanceTimersByTimeAsync(600); + root.dispose(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(2); +}); +it('waits for the saved draft ID, prevents duplicate sends, and does not recreate the sent draft on disposal', async () => { + const pending = Promise.withResolvers(); + const composeContext = createComposeContext(); + vi.mocked(composeContext.drafts.saveDraft).mockReturnValueOnce( + pending.promise + ); + const root = mountEmailComposer(composeContext); + root.edit('Send this'); + root.state.context.onSend(); + root.state.context.onSend(); + expect(root.state.context.disabled()).toBe(true); + await vi.advanceTimersByTimeAsync(1); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.delivery.sendMessage).not.toHaveBeenCalled(); + pending.resolve(response); + await vi.advanceTimersByTimeAsync(1); + expect(composeContext.delivery.sendMessage).toHaveBeenCalledOnce(); + expect( + vi.mocked(composeContext.delivery.sendMessage).mock.calls[0][0].message + .db_id + ).toBe('saved-id'); + root.dispose(); + await vi.advanceTimersByTimeAsync(1000); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); +}); +it('resumes autosave after a failed send', async () => { + const composeContext = createComposeContext(); + vi.mocked(composeContext.delivery.sendMessage).mockRejectedValueOnce( + new Error('offline') + ); + const root = mountEmailComposer(composeContext); + root.edit('Send this'); + root.state.context.onSend(); + await vi.advanceTimersByTimeAsync(1); + expect(root.state.context.disabled()).toBe(false); + root.edit('Retry with this'); + root.dispose(); + await vi.advanceTimersByTimeAsync(1000); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(2); + expect( + composeContext.notices.feedback.failure + ).toHaveBeenCalledExactlyOnceWith('Failed to send email'); +}); + +it('keeps a successful send completed when navigation fails', async () => { + const composeContext = createComposeContext(); + const error = new Error('Navigation failed'); + const root = mountEmailComposer(composeContext, { + showThread: () => { + throw error; + }, + }); + root.edit('Send this'); + root.state.context.onSend(); + await vi.advanceTimersByTimeAsync(1); + expect(composeContext.notices.reportError).toHaveBeenCalledWith(error); + expect(composeContext.notices.feedback.failure).not.toHaveBeenCalled(); + root.state.context.onSend(); + root.dispose(); + await vi.advanceTimersByTimeAsync(1000); + expect(composeContext.delivery.sendMessage).toHaveBeenCalledOnce(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); +}); + +it('keeps completion independent when two composers share delivery capabilities', async () => { + const composeContext = createComposeContext(); + const pending = Promise.withResolvers(); + vi.mocked(composeContext.delivery.sendMessage).mockReturnValueOnce( + pending.promise + ); + const first = mountEmailComposer(composeContext); + const second = mountEmailComposer(composeContext); + first.edit('First'); + second.edit('Second'); + first.state.context.onSend(); + await vi.advanceTimersByTimeAsync(1); + second.state.context.onSend(); + await vi.advanceTimersByTimeAsync(1); + expect(composeContext.delivery.sendMessage).toHaveBeenCalledTimes(2); + expect(first.state.context.isSending()).toBe(true); + expect(second.state.context.isSending()).toBe(false); + pending.resolve(response); + await vi.advanceTimersByTimeAsync(1); + expect(first.state.context.isSending()).toBe(false); + first.dispose(); + second.dispose(); +}); +it('waits for an existing attachment upload before flushing newer body edits', async () => { + const pending = Promise.withResolvers(); + const composeContext = createComposeContext(); + vi.mocked( + composeContext.attachmentStorage.uploadAttachments + ).mockReturnValueOnce(pending.promise); + const root = mountEmailComposer(composeContext); + root.edit('With attachment'); + root.state.context.onAddAttachments([ + { + type: 'local', + file: new File(['notes'], 'notes.txt', { type: 'text/plain' }), + }, + ]); + await vi.advanceTimersByTimeAsync(600); + root.edit('Final attachment note'); + root.dispose(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); + pending.resolve(); + await vi.advanceTimersByTimeAsync(1); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(2); + expect( + vi.mocked(composeContext.drafts.saveDraft).mock.calls[1][0].draft.db_id + ).toBe('draft'); +}); + +it('rejects sender/schedule changes and repeated discard while a deletion is pending', async () => { + const pending = Promise.withResolvers(); + const composeContext = createComposeContext(); + const root = mountEmailComposer(composeContext); + root.edit('Saved'); + await vi.advanceTimersByTimeAsync(600); + vi.mocked(composeContext.drafts.deleteDraft).mockReturnValueOnce( + pending.promise + ); + const discard = root.state.deleteDraftAndReset(); + expect(await root.state.deleteDraftAndReset()).toBe(false); + root.state.context.onSelectInbox?.('other-inbox'); + await root.state.context.onSendTimeChange?.(new Date('2026-12-01T12:00:00Z')); + expect(root.state.context.selectedInboxId?.()).toBe('inbox'); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.delivery.schedule).not.toHaveBeenCalled(); + pending.resolve(); + expect(await discard).toBe(true); + root.dispose(); +}); + +it('rejects sender and scheduling changes after send dispatch', async () => { + const pending = Promise.withResolvers(); + const composeContext = createComposeContext(); + const root = mountEmailComposer(composeContext); + vi.mocked(composeContext.delivery.sendMessage).mockReturnValueOnce( + pending.promise + ); + root.edit('Send this'); + root.state.context.onSend(); + await vi.advanceTimersByTimeAsync(1); + root.state.context.onSelectInbox?.('other-inbox'); + await root.state.context.onSendTimeChange?.(new Date('2026-12-01T12:00:00Z')); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.delivery.schedule).not.toHaveBeenCalled(); + expect(root.state.context.selectedInboxId?.()).toBe('inbox'); + pending.resolve(response); + await vi.advanceTimersByTimeAsync(1); + root.dispose(); +}); + +it('uses the captured inbox for attachment upload when a sender switch queues behind a save', async () => { + const pending = Promise.withResolvers(); + const composeContext = createComposeContext(); + composeContext.accounts = { + ...composeContext.accounts, + inboxes: () => [ + { id: 'inbox', email_address: 'me@example.com', settings: {} }, + { id: 'other', email_address: 'other@example.com', settings: {} }, + ], + }; + vi.mocked(composeContext.drafts.saveDraft).mockReturnValueOnce( + pending.promise + ); + const root = mountEmailComposer(composeContext); + root.edit('With files'); + root.state.context.onAddAttachments([ + { type: 'local', file: new File(['notes'], 'notes.txt') }, + ]); + await vi.advanceTimersByTimeAsync(600); + root.state.context.onSelectInbox?.('other'); + pending.resolve(response); + await vi.advanceTimersByTimeAsync(1); + expect( + vi.mocked(composeContext.attachmentStorage.uploadAttachments).mock + .calls[0][0].inboxId + ).toBe('inbox'); + expect( + vi.mocked(composeContext.drafts.saveDraft).mock.calls[1][0].inboxId + ).toBe('other'); + root.dispose(); +}); diff --git a/apps/web/src/features/block-email/component/compose/ComposeContext.ts b/apps/web/src/features/email-compose/primitives/compose-view-state.ts similarity index 64% rename from apps/web/src/features/block-email/component/compose/ComposeContext.ts rename to apps/web/src/features/email-compose/primitives/compose-view-state.ts index bbb7b155f2a..a02d7160a75 100644 --- a/apps/web/src/features/block-email/component/compose/ComposeContext.ts +++ b/apps/web/src/features/email-compose/primitives/compose-view-state.ts @@ -1,22 +1,27 @@ -import type { DraftFormAttachment } from '@block-email/component/createEmailFormState'; -import type { EmailRecipient } from '@block-email/component/EmailContext'; import type { LexicalEditor } from 'lexical'; -import { type Accessor, createContext, type JSX, useContext } from 'solid-js'; - -export type EmailFormRecipients = { - to: EmailRecipient[]; - cc: EmailRecipient[]; - bcc: EmailRecipient[]; -}; - -export type RecipientFieldId = 'to' | 'cc' | 'bcc'; +import type { Accessor, JSX } from 'solid-js'; +import type { ComposeBodyActions } from '../context/editor-capabilities'; +import type { + EmailFormRecipients, + EmailRecipient, +} from '../core/email-recipient'; +import type { DraftFormAttachment } from './email-form-state'; export type ComposeValidationError = { type: 'no_recipient' | 'no_message' | 'no_subject' | 'no_link'; message: string; }; -export interface ComposeContextValue { +export interface ComposeContextValue extends ComposeState { + bodyActions: ComposeBodyActions; + isMobile: Accessor; + scheduleEnabled: boolean; + attachmentFailure(message: string, options?: { subtext?: string }): void; + onUpgrade?: () => void; + viewerLoading?: Accessor; +} + +export interface ComposeState { // Form state (read) recipients: () => EmailFormRecipients; subject: () => string; @@ -37,10 +42,10 @@ export interface ComposeContextValue { // Editor captureEditor: (editor: LexicalEditor) => void; + onEditorInitialized?: (editor: LexicalEditor) => void; // Actions onSend: () => void; - onSaveDraft?: () => void | Promise; onDelete?: () => void; onSendTimeChange?: (date: Date | null) => void; @@ -69,29 +74,19 @@ export interface ComposeContextValue { // From-inbox selection: the inboxes the user can send from, the active one, // and a setter to change it. fromInboxes?: Accessor< - { id: string; email_address: string; photo_url?: string | null }[] + { + id: string; + email_address: string; + displayName?: string; + photo_url?: string | null; + }[] >; - selectedFromLinkId?: Accessor; - onSelectFromLink?: (linkId: string) => void; + selectedInboxId?: Accessor; + onSelectInbox?: (inboxId: string) => void; hasPaidAccess: Accessor; - // Toolbar slot — allows orchestrators to provide a custom toolbar - toolbar?: () => JSX.Element; - // Signature preview slot — rendered below the body. Provided by the new-email // composer and the AI chat composer (ChatCompose); the reply/forward input // renders its own preview and omits this slot. signaturePreview?: () => JSX.Element; } - -const ComposeContext = createContext(); - -export const ComposeProvider = ComposeContext.Provider; - -export function useCompose(): ComposeContextValue { - const ctx = useContext(ComposeContext); - if (!ctx) { - throw new Error('useCompose must be used within a ComposeProvider'); - } - return ctx; -} diff --git a/apps/web/src/features/email-compose/primitives/draft-autosave.ts b/apps/web/src/features/email-compose/primitives/draft-autosave.ts new file mode 100644 index 00000000000..1ab431f6d18 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/draft-autosave.ts @@ -0,0 +1,41 @@ +import { debounce } from '@solid-primitives/scheduled'; +import { onCleanup } from 'solid-js'; + +/** Capture live editor values before queuing; each write sees the preceding draft ID. */ +export function createDraftAutosave(options: { + capture(): Snapshot; + persist(snapshot: Snapshot): Promise; + paused(): boolean; +}) { + let pending = false; + let queue: Promise = Promise.resolve(undefined); + const cancel = () => { + scheduled.clear(); + pending = false; + }; + const save = (snapshot = options.capture()) => { + cancel(); + const write = () => options.persist(snapshot); + queue = queue.then(write, write); + return queue; + }; + const scheduled = debounce(() => { + if (options.paused()) return; + void save().catch(() => {}); + }, 500); + onCleanup(() => { + const flush = pending && !options.paused(); + cancel(); + if (flush) void save().catch(() => {}); + }); + return { + save, + cancel, + settled: () => queue, + schedule() { + if (options.paused()) return; + pending = true; + scheduled(); + }, + }; +} diff --git a/apps/web/src/features/email-compose/primitives/email-composer.test.ts b/apps/web/src/features/email-compose/primitives/email-composer.test.ts new file mode 100644 index 00000000000..7e388b1e957 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/email-composer.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { decodeBase64Utf8 } from '../core/decode-base64'; +import { createComposeContext } from '../tests/capabilities'; +import { mountEmailComposer } from '../tests/composer'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +// Real controller and editor, with only feature capabilities replaced. +describe('standalone compose controller', () => { + it('saves a composed draft after debounce and preserves the recipients and HTML', async () => { + const context = createComposeContext(); + const root = mountEmailComposer(context); + try { + root.edit('Keep this draft', 'Architecture'); + await vi.advanceTimersByTimeAsync(600); + expect(context.drafts.saveDraft).toHaveBeenCalledOnce(); + const { draft } = vi.mocked(context.drafts.saveDraft).mock.calls[0][0]; + expect(draft.to).toEqual([ + expect.objectContaining({ email: 'colleague@example.com' }), + ]); + expect(draft.subject).toBe('Architecture'); + expect(decodeBase64Utf8(draft.body_html ?? '')).toContain( + 'Keep this draft' + ); + expect(root.state.context.hasDraft()).toBe(true); + } finally { + root.dispose(); + } + }); + + it('rejects an empty body in an otherwise ready composer and sends once content is added', async () => { + const context = createComposeContext(); + const root = mountEmailComposer(context); + try { + root.state.context.setSubject('Review'); + root.state.context.onSend(); + await vi.advanceTimersByTimeAsync(0); + expect(root.state.context.validationError('no_message')).toMatchObject({ + type: 'no_message', + message: 'Please enter a message', + }); + expect(context.delivery.sendMessage).not.toHaveBeenCalled(); + root.edit('Ready to send'); + root.state.context.onSend(); + await vi.advanceTimersByTimeAsync(0); + expect(context.delivery.sendMessage).toHaveBeenCalledOnce(); + } finally { + root.dispose(); + } + }); + + it('reports scheduling failure without adopting an unconfirmed time, and keeps a confirmed schedule when archive fails', async () => { + const context = createComposeContext(); + const root = mountEmailComposer(context); + try { + root.edit('Schedule this reply', 'Schedule review'); + const requested = new Date('2026-10-01T12:00:00Z'); + vi.mocked(context.delivery.schedule).mockRejectedValueOnce( + new Error('offline') + ); + await root.state.context.onSendTimeChange?.(requested); + expect(root.state.context.sendTime()).toBeFalsy(); + expect(context.notices.feedback.failure).toHaveBeenCalledWith( + 'Failed to schedule message' + ); + vi.mocked(context.delivery.archive).mockRejectedValueOnce( + new Error('archive offline') + ); + await root.state.context.onSendTimeChange?.(requested); + expect(root.state.context.sendTime()).toEqual(requested); + expect(context.notices.feedback.failure).toHaveBeenCalledWith( + 'Email scheduled, but unable to mark thread done' + ); + } finally { + root.dispose(); + } + }); + + it('blocks immediate send and overlapping changes while a scheduling request is pending', async () => { + const pending = Promise.withResolvers(); + const context = createComposeContext(); + vi.mocked(context.delivery.schedule).mockReturnValue(pending.promise); + const root = mountEmailComposer(context); + try { + root.edit('Schedule this reply', 'Schedule review'); + const request = root.state.context.onSendTimeChange?.( + new Date('2026-10-01T12:00:00Z') + ); + await vi.advanceTimersByTimeAsync(0); + expect(context.delivery.schedule).toHaveBeenCalledOnce(); + expect(root.state.context.disabled()).toBe(true); + root.state.context.onSend(); + await root.state.context.onSendTimeChange?.( + new Date('2026-10-02T12:00:00Z') + ); + expect(context.delivery.sendMessage).not.toHaveBeenCalled(); + expect(context.delivery.schedule).toHaveBeenCalledOnce(); + pending.resolve(); + await request; + expect(root.state.context.disabled()).toBe(false); + expect(root.state.draftDirty()).toBe(true); + } finally { + pending.resolve(); + root.dispose(); + } + }); +}); diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts new file mode 100644 index 00000000000..6e9c77e2de2 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -0,0 +1,717 @@ +import { MACRO_EMAIL_SIGNATURE } from '@app/features/email-compose/core/constants'; +import { $generateHtmlFromNodes } from '@lexical/html'; +import { + $appendWatermarkNodeToLast, + $removeAllWatermarkNodes, +} from '@macro-inc/lexical-core'; +import * as EmailValidator from 'email-validator'; +import type { LexicalEditor } from 'lexical'; +import { type Accessor, createMemo, createSignal } from 'solid-js'; +import { unwrap } from 'solid-js/store'; +import type { + EmailContact, + EmailMessage, +} from '../../email-message/core/email-message'; +import type { + EmailAttachmentStorage, + EmailComposeAccounts, + EmailComposeFeedback, + EmailComposeHost, + EmailDelivery, + EmailDraftStorage, + PersistedEmailIdentity, +} from '../context/compose-capabilities'; +import { decodeBase64Utf8 } from '../core/decode-base64'; +import type { EmailRecipient } from '../core/email-recipient'; +import { plainTextToHtml } from '../core/plain-text-to-html'; +import { convertEmailRecipientToContactInfo } from '../core/recipient-conversion'; +import type { + ComposeState, + ComposeValidationError, +} from '../primitives/compose-view-state'; +import type { EmailFormRecipients } from '../primitives/email-form-state'; +import { + createEmailFormState, + type DraftFormAttachment, +} from '../primitives/email-form-state'; +import { + clearEmailBody, + hasDraftContent, + prepareEmailBody, +} from '../primitives/prepare-email-body'; +import { endUndoSend } from '../primitives/undo-send-claim'; +import { createAttachmentPersistence } from './attachment-persistence'; +import { createDraftAutosave } from './draft-autosave'; +import { createEmailSendSchedule } from './email-send-schedule'; +import { createEmailUndoStore } from './undo-store'; + +type UndoComposeSnapshot = { + draftId: string; + inboxId?: string; + recipients: EmailFormRecipients; + subject: string; + bodyHtml: string; + attachments: DraftFormAttachment[]; + includeSignature: boolean; +}; + +const composeUndo = createEmailUndoStore(); + +export type EmailComposerOptions = { + drafts: EmailDraftStorage; + attachmentStorage: EmailAttachmentStorage; + delivery: EmailDelivery; + notices: EmailComposeFeedback; + accounts: EmailComposeAccounts; + viewerEmail: Accessor; + hasPaidAccess: Accessor; + recipients: Accessor; + recipientName(id: string): string; + host?: EmailComposeHost; + draft?: EmailMessage; + /** Identity for a composer reopened from a local undo snapshot. */ + draftId?: string; + recipientOptions?: Accessor; + onRecipientsChange?: (recipients: EmailRecipient[]) => void; + /** Prefill for the To field (e.g. from an intercepted mailto: link). Ignored when editing an existing draft. */ + initialTo?: string[]; +}; + +export function createEmailComposer(props: EmailComposerOptions) { + const initialDraftId = props.draft?.db_id ?? props.draftId; + const hasPaidAccess = props.hasPaidAccess; + + const form = createEmailFormState( + { + viewerEmail: props.viewerEmail, + inboxes: props.accounts.inboxes, + }, + initialDraftId + ? { + type: 'draft', + messageId: initialDraftId, + } + : undefined, + { + getMessageById: () => props.draft, + getDraftForMessageReply: () => undefined, + onRecipientsChange: props.onRecipientsChange, + } + ); + + const primaryInboxId = props.accounts.primaryId; + const link = createMemo(() => { + const inboxes = props.accounts.inboxes(); + if (inboxes.length === 0) return undefined; + // Send from the inbox the user picked, else the inbox that owns the draft + // being edited, else the primary inbox — not whichever inbox sorts first. + const targetId = + form.selectedInboxId() ?? props.draft?.link_id ?? primaryInboxId(); + return inboxes.find((inbox) => inbox.id === targetId) ?? inboxes[0]; + }); + + const activeInboxId = () => link()?.id; + + // The sending inbox's saved signature (empty for inboxes without one). New + // emails include it by default; the preview's dismiss drops it for this one + // message. The backend injects it on send (see include_signature below); the + // FE only renders the preview and signals an explicit dismiss. + const signature = () => link()?.settings.signature ?? undefined; + const [includeSignature, setIncludeSignature] = createSignal(true); + + const hasInboxError = createMemo(() => { + if (props.accounts.loading()) return false; + return props.accounts.failed() || props.accounts.inboxes().length === 0; + }); + + const destinationOptions = props.recipients; + + const [editor, setEditor] = createSignal(); + const [content, setContent] = createSignal(''); + const [currentDraftId, setCurrentDraftId] = createSignal( + initialDraftId + ); + + // Thread the draft currently lives under; switching the sending inbox + // re-homes the draft server-side, so the previous thread's soup row must + // be dropped after the save. + const [currentThreadId, setCurrentThreadId] = createSignal< + string | undefined + >(props.draft?.thread_db_id); + + const attachmentPersistence = createAttachmentPersistence({ + services: props.attachmentStorage, + attachments: form.attachments, + draftId: currentDraftId, + inboxId: activeInboxId, + }); + + // Restore form state from undo-send snapshot if available + const restoredSnapshot = initialDraftId + ? composeUndo.take(initialDraftId) + : undefined; + + if (restoredSnapshot) { + form.setSelectedInbox(restoredSnapshot.inboxId); + form.setRecipients('to', restoredSnapshot.recipients.to); + form.setRecipients('cc', restoredSnapshot.recipients.cc); + form.setRecipients('bcc', restoredSnapshot.recipients.bcc); + form.setSubject(restoredSnapshot.subject); + for (const attachment of restoredSnapshot.attachments) { + form.attachments.add(attachment); + } + setIncludeSignature(restoredSnapshot.includeSignature); + } + + if (!initialDraftId && props.initialTo?.length) { + form.setRecipients( + 'to', + props.initialTo.map((email) => ({ + kind: 'custom' as const, + id: `macro|${email}`, + data: { + id: `macro|${email}`, + email, + invalid: !EmailValidator.validate(email), + }, + })) + ); + } + + // --- Draft persistence --- + + 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, + form.recipients().to.length + + form.recipients().cc.length + + form.recipients().bcc.length + ) + ) { + return null; + } + return { + bcc: form.recipients().bcc.map(convertEmailRecipientToContactInfo), + body_html: prepared.bodyHtml, + cc: form.recipients().cc.map(convertEmailRecipientToContactInfo), + subject: form.subject(), + to: form.recipients().to.map(convertEmailRecipientToContactInfo), + }; + } + + async function persistDraft( + draftToSave: ReturnType, + saveInboxId: string | undefined + ) { + if (!draftToSave) { + const draftId = currentDraftId(); + if (draftId) { + await props.drafts.deleteDraft({ + draftId: draftId, + threadId: currentThreadId(), + inboxId: saveInboxId, + }); + } + setCurrentDraftId(undefined); + return; + } + + const previousThreadId = currentThreadId(); + const draftResponse = await props.drafts.saveDraft({ + draft: { + ...draftToSave, + db_id: currentDraftId(), + }, + inboxId: saveInboxId, + previousThreadId: previousThreadId, + }); + + const newThreadId = draftResponse.threadId ?? undefined; + setCurrentThreadId(newThreadId); + + const draftId = draftResponse.draftId; + if (draftId) { + setCurrentDraftId(draftId); + await attachmentPersistence.upload(draftId, { inboxId: saveInboxId }); + return draftId; + } + } + + // Edits since the composer opened; an untouched existing draft can be + // left without the keep-or-delete prompt. + const [draftDirty, setDraftDirty] = createSignal(false); + + const [sendPhase, setSendPhase] = createSignal< + 'idle' | 'preparing' | 'sending' + >('idle'); + const submitting = () => sendPhase() !== 'idle'; + const sending = () => sendPhase() === 'sending'; + const [discarding, setDiscarding] = createSignal(false); + let completed = false; + const persistencePaused = () => submitting() || discarding() || completed; + + const autosave = createDraftAutosave({ + capture: () => ({ draft: collectDraft(), inboxId: activeInboxId() }), + persist: ({ draft, inboxId }) => persistDraft(draft, inboxId), + paused: persistencePaused, + }); + const markDirtyAndScheduleSave = () => { + if (persistencePaused()) return; + setDraftDirty(true); + autosave.schedule(); + }; + + // --- Attachment handling --- + + const handleAddAttachments = (attachments: DraftFormAttachment[]) => { + for (const attachment of attachments) { + form.attachments.add(attachment); + } + markDirtyAndScheduleSave(); + }; + + const handleRemoveAttachment = (attachment: DraftFormAttachment) => { + setDraftDirty(true); + attachmentPersistence.remove(attachment); + }; + + // --- Content change --- + + let firstChangeConsumed = false; + const onContentChange = (newContent: string) => { + setContent(newContent); + if (!firstChangeConsumed) { + firstChangeConsumed = true; + return; + } + markDirtyAndScheduleSave(); + }; + + // --- Send --- + + const [validationError, setValidationError] = + createSignal(null); + + // Everything that follows a successful unschedule: scrub the new thread's + // cache, restore the server-side draft, and remount the compose view so it + // restores the form from the undo snapshot. + const restoreAfterUndoSend = async ( + draftId: string, + threadId: string | undefined, + inboxId: string | undefined + ) => { + const snapshot = composeUndo.peek(draftId); + await props.drafts.restoreDraft({ + draftId, + threadId, + draft: snapshot + ? { + bcc: snapshot.recipients.bcc.map( + convertEmailRecipientToContactInfo + ), + cc: snapshot.recipients.cc.map(convertEmailRecipientToContactInfo), + db_id: draftId, + subject: snapshot.subject, + to: snapshot.recipients.to.map(convertEmailRecipientToContactInfo), + } + : undefined, + html: snapshot?.bodyHtml, + inboxId, + }); + + props.host?.showDraft?.(draftId); + }; + + // Undo retains the inbox used by the send even after navigation. + const undoSend = ( + draftId: string, + threadId: string | undefined, + inboxId: string | undefined + ) => + props.delivery.undoSend({ + threadId, + draftId, + inboxId, + onUndone: () => restoreAfterUndoSend(draftId, threadId, inboxId), + }); + + const afterSend = ( + identity: PersistedEmailIdentity, + inboxId: string | undefined + ) => { + const draftId = identity.draftId; + const threadId = identity.threadId; + if (draftId) endUndoSend(draftId); + 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, threadId ?? undefined, inboxId).catch( + props.notices.reportError + ); + }, + }, + ] + : undefined, + duration: 5_000, + }); + } catch (error) { + props.notices.reportError(error); + } + try { + if (threadId) props.host?.showThread?.(threadId); + } catch (error) { + props.notices.reportError(error); + } + }; + + const onSubmit = async () => { + if (scheduling() || persistencePaused()) return; + setValidationError(null); + + const currentEditor = editor(); + const currentLink = link(); + const recipients = form.recipients(); + + if (!recipients.to.length) { + setValidationError({ + type: 'no_recipient', + message: 'Please select at least one recipient', + }); + return; + } + + if (!content().trim()) { + setValidationError({ + type: 'no_message', + message: 'Please enter a message', + }); + return; + } + + if (!form.subject()?.trim()) { + setValidationError({ + type: 'no_subject', + message: 'Please enter a subject', + }); + return; + } + + if (!currentLink) { + setValidationError({ + type: 'no_link', + message: 'Unable to find linked email account', + }); + return; + } + + // Failsafe: don't send if a scheduled send time is set + if (form.sendTime()) { + return; + } + + setSendPhase('preparing'); + try { + // Ensure the draft is saved before sending so undo-send always has a + // draft id to snapshot and restore (the send reuses the draft's db_id). + autosave.cancel(); + try { + await autosave.save(); + } catch { + // Draft save is best-effort; the send still works without one. + } + + // Scheduling may have started while the draft save was pending. + if (scheduling() || form.sendTime()) return; + + // Snapshot editor state before watermark so undo-send can restore it + if (currentEditor) { + const snapshotHtml = currentEditor.read(() => + $generateHtmlFromNodes(currentEditor) + ); + const draftId = currentDraftId(); + if (draftId) { + composeUndo.remember({ + inboxId: currentLink.id, + draftId, + recipients: structuredClone(unwrap(form.recipients())), + subject: form.subject(), + bodyHtml: snapshotHtml, + attachments: [...form.attachments.list()], + includeSignature: includeSignature(), + }); + } + } + + // 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 prepared = prepareEmailBody(currentEditor); + if (!prepared) { + cleanupWatermark(); + return; + } + + const bodyMacro = content(); + + try { + setSendPhase('sending'); + const result = await props.delivery.sendMessage({ + message: { + to: convertToContactInfoArray(recipients.to), + cc: + recipients.cc.length > 0 + ? convertToContactInfoArray(recipients.cc) + : [], + bcc: + recipients.bcc.length > 0 + ? convertToContactInfoArray(recipients.bcc) + : [], + subject: form.subject(), + body_text: prepared.bodyText, + body_html: prepared.bodyHtml, + body_macro: bodyMacro, + db_id: currentDraftId(), + // Backend includes the signature by default for new emails; only signal + // an explicit dismiss. Omitting it falls through to the backend default. + include_signature: includeSignature() ? undefined : false, + }, + inboxId: activeInboxId(), + }); + + completed = true; + afterSend(result, currentLink.id); + } finally { + cleanupWatermark(); + } + } catch (error) { + props.notices.reportError(error); + if (!completed) props.notices.feedback.failure('Failed to send email'); + } finally { + setSendPhase('idle'); + } + }; + + // --- Schedule --- + + const totalRecipientCount = () => { + const recipients = form.recipients(); + return recipients.to.length + recipients.cc.length + recipients.bcc.length; + }; + const schedule = createEmailSendSchedule({ + delivery: props.delivery, + notices: props.notices, + draftId: currentDraftId, + saveDraft: autosave.save, + threadId: currentThreadId, + inboxId: activeInboxId, + sendTime: form.sendTime, + setSendTime: (date) => { + form.setSendTime(date); + setDraftDirty(true); + }, + recipientCount: totalRecipientCount, + }); + const scheduling = schedule.pending; + const scheduleBlocked = () => sending() || discarding() || completed; + const handleSendTimeChange = (date: Date | null) => { + if (scheduleBlocked()) return Promise.resolve(); + return schedule.change(date); + }; + + // --- Reset / delete --- + + const resetState = () => { + clearEmailBody(editor()); + setContent(''); + setCurrentDraftId(undefined); + form.clear(); + }; + + const deleteDraftAndReset = async () => { + if (persistencePaused() || scheduling()) return false; + setDiscarding(true); + autosave.cancel(); + try { + // A first save may still be creating the draft. Delete its returned ID + // after it settles so discard cannot leave an orphan behind. + await autosave.settled().catch(() => {}); + const draftId = currentDraftId(); + if (draftId) { + await props.drafts.deleteDraft({ + draftId, + threadId: currentThreadId(), + inboxId: activeInboxId(), + }); + } + resetState(); + return true; + } finally { + setDiscarding(false); + } + }; + + // --- Derived state --- + + const initialHtml = () => { + if (restoredSnapshot) { + return restoredSnapshot.bodyHtml; + } + + const draft = form.draft; + if (!draft) return; + + if (draft.body_html_sanitized) { + return decodeBase64Utf8(draft.body_html_sanitized); + } + + if (draft.body_text) { + return plainTextToHtml(draft.body_text); + } + }; + + const getRecipientOptions = () => { + const fromDraft = props.recipientOptions?.(); + return fromDraft ?? destinationOptions(); + }; + + const previewName = createMemo(() => { + const recipients = form.recipients().to; + if (recipients.length === 0) { + return 'Draft email'; + } + + if (recipients.length === 1) { + let recipientName = recipients[0].data.email; + + if (recipients[0].kind === 'user') { + recipientName = props.recipientName(recipients[0].data.id); + } + + return recipientName ? `Email to ${recipientName}` : 'Draft email'; + } + + const names = recipients + .slice(0, 2) + .map((r) => { + if (r.kind === 'user') { + return props.recipientName(r.data.id); + } + return r.data.email || 'Unknown'; + }) + .filter(Boolean); + + if (recipients.length > 2) { + return `Email to ${names.join(', ')}, and others`; + } + + return `Email to ${names.join(' and ')}`; + }); + + // --- Context value --- + + const ctxValue: ComposeState = { + // Form state (read) + recipients: form.recipients, + subject: form.subject, + attachments: form.attachments.list, + sendTime: form.sendTime, + initialHtml, + + // Form state (write) + setRecipients: (field, value) => { + form.setRecipients(field, value); + markDirtyAndScheduleSave(); + }, + setSubject: (value) => { + form.setSubject(value); + markDirtyAndScheduleSave(); + }, + onContentChange, + onAddAttachments: handleAddAttachments, + onRemoveAttachment: handleRemoveAttachment, + + // Editor + captureEditor: setEditor, + + // Actions + onSend: () => void onSubmit(), + onDelete: () => void deleteDraftAndReset().catch(() => {}), + onSendTimeChange: handleSendTimeChange, + + // Status + disabled: () => hasInboxError() || persistencePaused() || scheduling(), + isSending: submitting, + hasDraft: () => currentDraftId() != null, + + // Validation + validationError: (type) => { + const error = validationError(); + if (error?.type === type) return error; + return undefined; + }, + + // Recipients + recipientOptions: getRecipientOptions, + focusRecipientsOnMount: !hasInboxError(), + + // Schedule send + scheduleSendDisabled: () => + totalRecipientCount() === 0 || scheduling() || persistencePaused(), + + // Display + fromAddress: () => link()?.email_address, + fromInboxes: () => props.accounts.inboxes() ?? [], + selectedInboxId: () => link()?.id, + // Persist immediately on a sender switch so the draft moves to the new + // inbox even without a text edit. + onSelectInbox: (inboxId) => { + if (persistencePaused() || scheduling()) return; + form.setSelectedInbox(inboxId); + setDraftDirty(true); + autosave.cancel(); + void autosave.save().catch(() => {}); + }, + hasPaidAccess, + }; + return { + context: ctxValue, + editor, + previewName, + hasInboxError, + draftDirty, + deleteDraftAndReset, + signature, + includeSignature, + setIncludeSignature, + }; +} + +function convertToContactInfoArray( + recipients: EmailRecipient[] +): EmailContact[] { + return recipients.map((recipient) => ({ + email: recipient.data.email, + name: + 'name' in recipient.data ? recipient.data.name || undefined : undefined, + })); +} diff --git a/apps/web/src/features/email-compose/primitives/email-editor-commands.ts b/apps/web/src/features/email-compose/primitives/email-editor-commands.ts new file mode 100644 index 00000000000..6be719bbe34 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/email-editor-commands.ts @@ -0,0 +1,11 @@ +import { createCommand } from 'lexical'; +import type { EmailMessage } from '../../email-message/core/email-message'; +import type { ReplyType } from '../core/reply-type'; +export const TOGGLE_APPEND_EMAIL_THREAD_COMMAND = createCommand<{ + replyingTo: EmailMessage | 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'); diff --git a/apps/web/src/features/email-compose/primitives/email-form-state.ts b/apps/web/src/features/email-compose/primitives/email-form-state.ts new file mode 100644 index 00000000000..83791f29478 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/email-form-state.ts @@ -0,0 +1,334 @@ +import type { EmailFormRecipients } from '../core/email-recipient'; + +export type { EmailFormRecipients } from '../core/email-recipient'; + +import type { EmailRecipient } from '@app/features/email-compose/core/email-recipient'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; +import { createSignal, type Setter } from 'solid-js'; +import { createStore, reconcile, unwrap } from 'solid-js/store'; +import { match } from 'ts-pattern'; +import type { EmailFormContextInputs } from '../context/email-form-inputs'; +import { decodeBase64Utf8 } from '../core/decode-base64'; +import { + convertContactInfoToEmailRecipient, + getReplyAllRecipients, + getReplyRecipientsFromParent, +} from '../core/recipient-conversion'; +import type { ReplyType } from '../core/reply-type'; +import { getSubjectText } from '../core/subject-text'; + +export type DraftFormAttachment = + | { + type: 'local'; + file: File; + attachmentId?: string; + } + | { + type: 'remote'; + url: string; + fileName: string; + contentType: string; + attachmentId: string; + fileSize: number; + } + | { + type: 'forwarded'; + attachmentId: string; + fileName: string; + mimeType: string; + fileSize: number; + }; + +export interface EmailFormStateOptions { + getMessageById: (id: string) => EmailMessage | undefined; + getDraftForMessageReply: (id: string) => EmailMessage | undefined; + onRecipientsChange?: (next: EmailRecipient[]) => void; +} + +type EmailFormState = { + recipients: EmailFormRecipients; + replyType: ReplyType; + withQuotedText: boolean; + subject: string; + sendTime?: Date; +}; + +const EMPTY_FORM_STATE: EmailFormState = { + recipients: { + to: [], + cc: [], + bcc: [], + }, + replyType: 'reply-all', + withQuotedText: false, + subject: '', +}; + +/** + * Creates a state object for the email form. + * @param purpose - The purpose of the form. Are we managing the state of a draft reply or just a draft message + * @param options - Required options for the initial state to be calculated from + * @returns A state object for the email form. + */ +export function createEmailFormState( + context: EmailFormContextInputs, + purpose?: + | { type: 'replying_to'; messageId: string } + | { type: 'draft'; messageId: string }, + + options?: EmailFormStateOptions +) { + const userEmail = context.viewerEmail; + + let replyingTo: EmailMessage | undefined; + + if (purpose?.type === 'replying_to') { + replyingTo = options?.getMessageById(purpose.messageId); + } + + let draft: EmailMessage | undefined; + + if (purpose?.type === 'draft') { + draft = options?.getMessageById(purpose.messageId); + } else if (purpose?.type === 'replying_to') { + draft = options?.getDraftForMessageReply(purpose.messageId); + } + + // The inbox this compose sends from. Defaults to the inbox that owns the + // thread/draft; the user can change it via the "from" selector. + const [selectedInboxId, setSelectedInboxId] = createSignal< + string | undefined + >((draft ?? replyingTo)?.link_id ?? undefined); + // Reply logic ("did I send this?") must be judged against the inbox the + // message is sent from, not the account's primary email — otherwise replying + // from a secondary or delegated inbox misclassifies the sender and picks the + // wrong recipients. + const inboxEmail = () => { + const inboxId = selectedInboxId() ?? (draft ?? replyingTo)?.link_id; + const ownerEmail = inboxId + ? context.inboxes().find((l) => l.id === inboxId)?.email_address + : undefined; + return ownerEmail ?? userEmail() ?? ''; + }; + + const draftContainsAppendedReply = () => { + const encoded = draft?.body_html_sanitized; + if (!encoded) return false; + const decodedHtml = decodeBase64Utf8(encoded); + if (!decodedHtml) return false; + const parsed = new DOMParser().parseFromString(decodedHtml, 'text/html'); + + return parsed.body.querySelector('div.macro_quote') !== null; + }; + + const getInitialState = () => { + const replyType = + (replyingTo?.to.length ?? 0) + (replyingTo?.cc.length ?? 0) > 1 + ? 'reply-all' + : 'reply'; + + let initialSubject = draft?.subject; + + if (initialSubject == null) { + initialSubject = getSubjectText(replyingTo, replyType); + } + + let initialRecipients: EmailFormRecipients = { to: [], cc: [], bcc: [] }; + + if (draft) { + initialRecipients = { + to: draft.to.map(convertContactInfoToEmailRecipient), + cc: draft.cc.map(convertContactInfoToEmailRecipient), + bcc: draft.bcc.map(convertContactInfoToEmailRecipient), + }; + } else if (replyingTo) { + initialRecipients = + replyType === 'reply-all' + ? getReplyAllRecipients(replyingTo, inboxEmail()) + : getReplyRecipientsFromParent(replyingTo, inboxEmail()); + } + + return { + recipients: initialRecipients, + replyType, + withQuotedText: draftContainsAppendedReply(), + subject: initialSubject, + sendTime: draft?.scheduled_send_time + ? new Date(draft.scheduled_send_time) + : undefined, + } satisfies EmailFormState; + }; + + const [state, setState] = createStore(getInitialState()); + + // Values and edit revisions may outlive a mounted composer; effects do not. + const [editRevision, setEditRevision] = createSignal(0); + + const [attachments, setAttachments] = createSignal([ + ...(draft?.attachments_draft.map((a) => ({ + type: 'remote' as const, + attachmentId: a.id, + contentType: a.content_type, + fileName: a.file_name, + url: a.s3_key, + fileSize: a.size, + })) ?? []), + ...(draft?.attachments_forwarded.map((a) => ({ + type: 'forwarded' as const, + attachmentId: a.attachment_id, + fileName: a.filename ?? 'attachment', + mimeType: a.mime_type ?? 'application/octet-stream', + fileSize: a.size_bytes ?? 0, + })) ?? []), + ]); + + const setRecipients = ( + field: keyof EmailFormRecipients, + value: EmailRecipient[] + ) => { + setState('recipients', field, value); + callDirty(); + const recipients = state.recipients; + const all = [...recipients.to, ...recipients.cc, ...recipients.bcc]; + options?.onRecipientsChange?.(unwrap(all)); + }; + + const setSubject: Setter = (value) => { + const result = setState('subject', value); + callDirty(); + return result; + }; + + const setReplyType = (next: ReplyType) => { + setState('replyType', next); + const msg = replyingTo; + + // Clear forwarded attachments when switching away from forward + setAttachments((prev) => prev.filter((a) => a.type !== 'forwarded')); + + if (msg) { + const calculated = match(next) + .with('reply-all', () => getReplyAllRecipients(msg, inboxEmail())) + .with('reply', () => getReplyRecipientsFromParent(msg, inboxEmail())) + .with('forward', () => ({ to: [], cc: [], bcc: [] })) + .exhaustive(); + + setRecipients('to', calculated.to); + setRecipients('cc', calculated.cc); + setRecipients('bcc', calculated.bcc); + + setSubject(getSubjectText(msg, next)); + + if (next === 'forward') { + setState('withQuotedText', true); + // Populate forwarded attachments from original message (skip inline images) + const fwdAttachments: DraftFormAttachment[] = (msg.attachments ?? []) + .filter((a) => !a.content_id) + .map((a) => ({ + type: 'forwarded' as const, + attachmentId: a.db_id, + fileName: a.filename ?? 'attachment', + mimeType: a.mime_type ?? 'application/octet-stream', + fileSize: a.size_bytes ?? 0, + })); + setAttachments((prev) => [...prev, ...fwdAttachments]); + } + } + + callDirty(); + return next; + }; + + // Change the inbox this compose sends from. For an active reply, re-derive the + // recipients against the newly selected inbox (the sender comparison changes). + const setSelectedInbox = (inboxId: string | undefined) => { + setSelectedInboxId(inboxId); + if (!replyingTo || draft || state.replyType === 'forward') return; + const recalculated = + state.replyType === 'reply-all' + ? getReplyAllRecipients(replyingTo, inboxEmail()) + : getReplyRecipientsFromParent(replyingTo, inboxEmail()); + setRecipients('to', recalculated.to); + setRecipients('cc', recalculated.cc); + setRecipients('bcc', recalculated.bcc); + }; + + const setSendTime = (date: Date | null) => { + setState('sendTime', date ?? undefined); + }; + + const callDirty = () => { + setEditRevision((revision) => revision + 1); + }; + + const reset = (next: EmailFormState) => { + setState(reconcile(next)); + const recipients = state.recipients; + + // Notify context of the full recipient list after reset + const all = [...recipients.to, ...recipients.cc, ...recipients.bcc]; + options?.onRecipientsChange?.(unwrap(all)); + + setAttachments([]); + }; + + return { + draft, + replyAppended: () => state.withQuotedText, + setReplyAppended: (next: boolean) => setState('withQuotedText', next), + recipients: () => state.recipients, + setRecipients, + subject: () => state.subject, + setSubject, + replyType: () => state.replyType, + setReplyType, + selectedInboxId, + setSelectedInbox, + editRevision, + sendTime: () => state.sendTime, + setSendTime, + reset: () => reset(getInitialState()), + clear: () => reset({ ...EMPTY_FORM_STATE }), + attachments: { + list: attachments, + add: (attachment: DraftFormAttachment) => { + setAttachments((p) => [...p, attachment]); + }, + assignAttachmentId: (file: File, attachmentId: string) => { + setAttachments((p) => + p.map((a) => + a.type === 'local' && a.file === file ? { ...a, attachmentId } : a + ) + ); + }, + clearAttachmentId: (file: File) => { + setAttachments((p) => + p.map((a) => + a.type === 'local' && a.file === file + ? { ...a, attachmentId: undefined } + : a + ) + ); + }, + removeByFile: (file: File) => { + setAttachments((p) => + p.filter((a) => a.type !== 'local' || a.file !== file) + ); + }, + removeById: (attachmentId: string) => { + setAttachments((p) => + p.filter( + (a) => a.type !== 'remote' || a.attachmentId !== attachmentId + ) + ); + }, + removeForwarded: (attachmentId: string) => { + setAttachments((p) => + p.filter( + (a) => a.type !== 'forwarded' || a.attachmentId !== attachmentId + ) + ); + }, + }, + }; +} diff --git a/apps/web/src/features/email-compose/primitives/email-form-types.ts b/apps/web/src/features/email-compose/primitives/email-form-types.ts new file mode 100644 index 00000000000..7a388bb2911 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/email-form-types.ts @@ -0,0 +1,6 @@ +import type { createEmailFormState } from './email-form-state'; +export type EmailFormContextValue = ReturnType; + +export type FormAccessKey = + | { type: 'replying_to'; messageId: string; seed?: string } + | { type: 'draft'; messageId: string; seed?: string }; diff --git a/apps/web/src/features/email-compose/primitives/email-send-schedule.ts b/apps/web/src/features/email-compose/primitives/email-send-schedule.ts new file mode 100644 index 00000000000..b3c2f44b23d --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/email-send-schedule.ts @@ -0,0 +1,96 @@ +import { type Accessor, createEffect, createSignal, on } from 'solid-js'; +import type { + EmailComposeFeedback, + EmailDelivery, +} from '../context/compose-capabilities'; + +export function createEmailSendSchedule(options: { + delivery: Pick; + notices: EmailComposeFeedback; + draftId: Accessor; + saveDraft: () => Promise; + threadId: Accessor; + inboxId: Accessor; + sendTime: Accessor; + setSendTime: (date: Date | null) => void; + recipientCount: Accessor; +}) { + const { delivery, notices } = options; + const [pending, setPending] = createSignal(false); + + const change = async (date: Date | null) => { + if (pending()) return; + const inboxId = options.inboxId(); + setPending(true); + try { + const previous = options.sendTime(); + const currentDraft = options.draftId(); + if (!date && previous && currentDraft) { + try { + await delivery.unschedule({ + draftId: currentDraft, + inboxId, + }); + } catch (error) { + notices.reportError(error); + notices.feedback.failure('Failed to unschedule email'); + return; + } + options.setSendTime(null); + notices.feedback.success('Email unscheduled'); + return; + } + if (!date) { + options.setSendTime(null); + return; + } + // Persistence owns its failure notice; a failed save is not a failed schedule request. + let draftId: string | undefined; + try { + draftId = await options.saveDraft(); + } catch (error) { + notices.reportError(error); + return; + } + try { + if (!draftId) throw new Error('Draft required'); + await delivery.schedule( + { draftId, sendTime: date.toISOString() }, + inboxId + ); + } catch (error) { + notices.reportError(error); + notices.feedback.failure('Failed to schedule message'); + return; + } + options.setSendTime(date); + const threadId = options.threadId(); + if (threadId) { + try { + await delivery.archive({ threadId, value: true }, inboxId); + } catch (error) { + notices.reportError(error); + notices.feedback.failure( + 'Email scheduled, but unable to mark thread done' + ); + } + } + } catch (error) { + // Presentation failures do not change a successful schedule/unschedule. + notices.reportError(error); + } finally { + setPending(false); + } + }; + + createEffect( + on( + options.recipientCount, + (count) => { + if (count === 0 && options.sendTime()) void change(null); + }, + { defer: true } + ) + ); + return { pending, change }; +} diff --git a/apps/web/src/features/block-email/util/flattenConsecutiveParagraphs.test.ts b/apps/web/src/features/email-compose/primitives/flatten-consecutive-paragraphs.test.ts similarity index 62% rename from apps/web/src/features/block-email/util/flattenConsecutiveParagraphs.test.ts rename to apps/web/src/features/email-compose/primitives/flatten-consecutive-paragraphs.test.ts index 5943f7f133c..4f07c4eeda0 100644 --- a/apps/web/src/features/block-email/util/flattenConsecutiveParagraphs.test.ts +++ b/apps/web/src/features/email-compose/primitives/flatten-consecutive-paragraphs.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, expect, it } from 'vitest'; -import { flattenConsecutiveParagraphs } from './flattenConsecutiveParagraphs'; +import { flattenConsecutiveParagraphs } from './flatten-consecutive-paragraphs'; function flatten(html: string) { const body = new DOMParser().parseFromString(html, 'text/html').body; @@ -45,4 +45,27 @@ describe('flattenConsecutiveParagraphs', () => { '
one

two
' ); }); + + it('preserves paragraph attributes when joining matching paragraphs', () => { + expect( + flatten( + '

one

two

' + ) + ).toBe( + '
one

two
' + ); + expect(flatten('

only

')).toBe( + '
only
' + ); + }); + + it('keeps different paragraph alignment and blank-line spacing separate', () => { + expect( + flatten( + '

one

two

three

' + ) + ).toBe( + '
one

two

three
' + ); + }); }); diff --git a/apps/web/src/features/email-compose/primitives/flatten-consecutive-paragraphs.ts b/apps/web/src/features/email-compose/primitives/flatten-consecutive-paragraphs.ts new file mode 100644 index 00000000000..edddedd4de0 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/flatten-consecutive-paragraphs.ts @@ -0,0 +1,35 @@ +/** + * Flattens runs of consecutive `

` siblings with matching attributes into a `

` 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: {
from
ctx.onSelectFromLink?.(id)} + activeInboxId={ctx.selectedInboxId?.()} + onSelect={(id) => ctx.onSelectInbox?.(id)} />
@@ -225,7 +226,7 @@ export function ComposeLayout(props: {
); diff --git a/apps/web/src/features/block-email/component/compose/ComposeRecipients.tsx b/apps/web/src/features/email-compose/views/compose-recipients.tsx similarity index 90% rename from apps/web/src/features/block-email/component/compose/ComposeRecipients.tsx rename to apps/web/src/features/email-compose/views/compose-recipients.tsx index 1f991333a79..2941cdd8901 100644 --- a/apps/web/src/features/block-email/component/compose/ComposeRecipients.tsx +++ b/apps/web/src/features/email-compose/views/compose-recipients.tsx @@ -1,11 +1,12 @@ -import type { EmailRecipient } from '@block-email/component/EmailContext'; -import { EMAIL_COMPOSE_TO_INPUT_ID } from '@block-email/constants'; +import { EMAIL_COMPOSE_TO_INPUT_ID } from '@app/features/email-compose/core/constants'; +import type { EmailRecipient } from '@app/features/email-compose/core/email-recipient'; import { RecipientSelector } from '@core/component/RecipientSelector'; -import { isMobile } from '@core/mobile/isMobile'; + import { cn } from '@ui'; import { createSignal, type JSX, onCleanup, Show } from 'solid-js'; -import { FromInboxSelector } from '../FromInboxSelector'; -import { type RecipientFieldId, useCompose } from './ComposeContext'; +import { FromInboxSelector } from '../components/from-inbox-selector'; +import { useCompose } from '../context/compose-context'; +import type { RecipientFieldId } from '../core/email-recipient'; type DragState = { recipient: EmailRecipient; @@ -29,6 +30,7 @@ function ComposeFieldRow(props: { onRowFocusIn?: () => void; onRowFocusOut?: (e: FocusEvent) => void; }) { + const ctx = useCompose(); const [isDragOver, setIsDragOver] = createSignal(false); const handleDragOver = (e: DragEvent) => { @@ -55,7 +57,7 @@ function ComposeFieldRow(props: {
{props.label} @@ -146,7 +148,9 @@ export function ComposeRecipients(props: { const inputEls: Partial> = {}; const showSummary = (field: RecipientFieldId) => - isMobile() && activeField() !== field && ctx.recipients()[field].length > 0; + ctx.isMobile() && + activeField() !== field && + ctx.recipients()[field].length > 0; const summaryParts = (field: RecipientFieldId) => { const names = ctx.recipients()[field].map(recipientName).filter(Boolean); @@ -204,18 +208,18 @@ export function ComposeRecipients(props: { selectedOptions={ctx.recipients()[field]} setSelectedOptions={(next) => { ctx.setRecipients(field, next); - if (isMobile()) { + if (ctx.isMobile()) { clearTimeout(collapseTimer); activate(field); } }} - placeholder={isMobile() ? '' : 'Macro users or email addresses'} + placeholder={ctx.isMobile() ? '' : 'Macro users or email addresses'} focusOnMount={opts?.focusOnMount} openOnFocus={false} hideBorder class={cn( 'bg-transparent [&_input]:ml-0!', - isMobile() && '[&_input]:min-w-16! [&_input]:min-h-9!' + ctx.isMobile() && '[&_input]:min-w-16! [&_input]:min-h-9!' )} noPadding disabled={ctx.disabled()} @@ -273,7 +277,7 @@ export function ComposeRecipients(props: { props.setShowBcc(true); }; - const fieldLabel = (text: string) => (isMobile() ? `${text}:` : text); + const fieldLabel = (text: string) => (ctx.isMobile() ? `${text}:` : text); const toRow = (handlers?: RowFocusHandlers) => fieldRow( @@ -314,7 +318,7 @@ export function ComposeRecipients(props: { return ( {toRow()} @@ -348,9 +352,10 @@ export function ComposeRecipients(props: {
From:
ctx.onSelectFromLink?.(id)} + activeInboxId={ctx.selectedInboxId?.()} + onSelect={(id) => ctx.onSelectInbox?.(id)} />
diff --git a/apps/web/src/features/block-email/component/compose/ComposeSubject.tsx b/apps/web/src/features/email-compose/views/compose-subject.tsx similarity index 90% rename from apps/web/src/features/block-email/component/compose/ComposeSubject.tsx rename to apps/web/src/features/email-compose/views/compose-subject.tsx index 3d85414e79a..579fbccdc92 100644 --- a/apps/web/src/features/block-email/component/compose/ComposeSubject.tsx +++ b/apps/web/src/features/email-compose/views/compose-subject.tsx @@ -1,7 +1,6 @@ -import { isMobile } from '@core/mobile/isMobile'; import { cn } from '@ui'; import { createSignal, Show } from 'solid-js'; -import { useCompose } from './ComposeContext'; +import { useCompose } from '../context/compose-context'; function autosize(el: HTMLTextAreaElement) { el.style.height = 'auto'; @@ -19,7 +18,7 @@ export function ComposeSubject(props: { let textareaRef: HTMLTextAreaElement | undefined; const showSummary = () => - isMobile() && !editing() && ctx.subject().length > 0; + ctx.isMobile() && !editing() && ctx.subject().length > 0; const blurOnEscape = ( e: KeyboardEvent & { currentTarget: HTMLInputElement } @@ -33,20 +32,20 @@ export function ComposeSubject(props: {
- {isMobile() ? 'Subject:' : 'Subject'} + {ctx.isMobile() ? 'Subject:' : 'Subject'}
LexicalEditor | undefined; @@ -34,7 +32,9 @@ export function EmailComposeToolbar(props: { const attachmentsToAddByteSize = files.reduce((sum, f) => sum + f.size, 0); if (attachmentsToAddByteSize >= MAX_ATTACHMENTS_BYTES_SIZE) { - toast.failure(`${plural('Attachment', files.length)} exceed 18MB`); + ctx.attachmentFailure( + `${plural('Attachment', files.length)} exceed 18MB` + ); return; } @@ -47,7 +47,7 @@ export function EmailComposeToolbar(props: { currentAttachmentsByteSize + attachmentsToAddByteSize >= MAX_ATTACHMENTS_BYTES_SIZE ) { - toast.failure("Can't add more attachments", { + ctx.attachmentFailure("Can't add more attachments", { subtext: 'Total attachments exceed 18MB limit', }); return; @@ -78,7 +78,7 @@ export function EmailComposeToolbar(props: {
- - - - +
- - - - + + & { context: EmailComposeContext }; +export function EmailComposeView(props: EmailComposeViewProps) { + const composeContext = props.context; + const state = createEmailComposer({ + drafts: composeContext.drafts, + attachmentStorage: composeContext.attachmentStorage, + delivery: composeContext.delivery, + notices: composeContext.notices, + accounts: composeContext.accounts, + viewerEmail: composeContext.viewerEmail, + hasPaidAccess: composeContext.hasPaidAccess, + recipients: composeContext.recipients, + recipientName: composeContext.recipientName, + host: props.host, + draft: props.draft, + draftId: props.draftId, + recipientOptions: props.recipientOptions, + onRecipientsChange: props.onRecipientsChange, + initialTo: props.initialTo, + }); + const { + editor, + previewName, + hasInboxError, + draftDirty, + deleteDraftAndReset, + signature, + includeSignature, + setIncludeSignature, + } = state; + const ctxValue: ComposeContextValue = { + ...state.context, + bodyActions: { + focusSibling: props.host?.focusSibling, + recipientAdded: (email) => + composeContext.notices.feedback.success(`${email} added to CC`), + readDroppedFiles: composeContext.editorFiles.readDroppedFiles, + pasteFiles: (editor, files, directories) => + composeContext.editorFiles.uploadEditorFiles({ + editor, + files, + directories, + onUploaded: (ids) => + ids.forEach(composeContext.editorFiles.makePublic), + }), + }, + isMobile: composeContext.presentation.isMobile, + scheduleEnabled: composeContext.presentation.scheduleEnabled, + attachmentFailure: composeContext.notices.feedback.failure, + onUpgrade: composeContext.presentation.onUpgrade, + viewerLoading: composeContext.presentation.viewerLoading, + signaturePreview: () => ( + + {(html) => ( + setIncludeSignature(false)} + /> + )} + + ), + }; + const [draftBackMenuOpen, setDraftBackMenuOpen] = createSignal(false); + + if (composeContext.presentation.isMobile()) { + // Backing out of a compose that has a draft asks whether to keep it. + props.host?.registerBack?.(() => { + if (!ctxValue.hasDraft() || !draftDirty()) return false; + setDraftBackMenuOpen(true); + return true; + }); + } + + const leaveCompose = () => { + setDraftBackMenuOpen(false); + props.host?.goBack?.(); + }; + + return ( + + + + , + ]} + /> + + +
+
+ ( + + {children} + + )} + > + } + notice={hasInboxError() ? : undefined} + class="size-full p-4 bg-surface max-h-full touch:max-h-none overflow-hidden flex flex-col min-h-0 touch:min-h-full" + /> + +
+
+ + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/web/src/features/email-compose/views/reply-envelope.tsx b/apps/web/src/features/email-compose/views/reply-envelope.tsx new file mode 100644 index 00000000000..9156c94b9a3 --- /dev/null +++ b/apps/web/src/features/email-compose/views/reply-envelope.tsx @@ -0,0 +1,330 @@ +import { RecipientSelector } from '@core/component/RecipientSelector'; +import ChevronDown from '@phosphor/caret-down.svg'; +import CaretRight from '@phosphor/caret-right.svg'; +import { Button, cn } from '@ui'; +import type { Accessor } from 'solid-js'; +import { Show } from 'solid-js'; +import { FromInboxSelector } from '../components/from-inbox-selector'; +import { RecipientDropRow } from '../components/recipient-drop-row'; +import type { EmailInbox } from '../context/compose-capabilities'; +import type { EmailRecipient, RecipientFieldId } from '../core/email-recipient'; +import { getRecipientDisplayName } from '../core/email-recipient'; +import type { ReplyType } from '../core/reply-type'; +import type { EmailFormRecipients } from '../primitives/email-form-state'; +import type { createReplyRecipientFields } from '../primitives/reply-recipient-fields'; + +type ReplyEnvelopeProps = { + fields: ReturnType; + values: Accessor; + options: Accessor; + inboxes: Accessor; + activeInboxId: Accessor; + senderEmail: Accessor; + onSenderChange: (id: string) => void; + subject: Accessor; + onSubjectChange: (subject: string) => void; + showSubject: boolean; + mobile: Accessor; + portalScope: Accessor<'local' | undefined>; + replyType: Accessor; +}; + +/** Sender, recipients and subject, sharing field behavior across both layouts. */ +export function ReplyEnvelope(props: ReplyEnvelopeProps) { + const { + showExpandedRecipients, + setShowExpandedRecipients, + setToRef, + ccRef, + setCcRef, + bccRef, + setBccRef, + showCc, + setShowCc, + showBcc, + setShowBcc, + recipientDragState, + handleChipDragStart, + handleChipDragEnd, + handleRecipientDrop, + mobileDrawerCcBccOpen, + toggleMobileDrawerCcBcc, + } = props.fields; + const summary = () => { + const values = props.values(); + const recipients = [...values.to, ...values.cc, ...values.bcc]; + const first = recipients[0]; + const action = + props.replyType() === 'forward' ? 'Forwarding' : 'Replying to'; + if (!first) return action; + const suffix = recipients.length > 1 ? ` + ${recipients.length - 1}` : ''; + return `${action} ${getRecipientDisplayName(first)}${suffix}`; + }; + const RecipientInput = (field: { + field: RecipientFieldId; + mobile?: boolean; + }) => ( + + disabled={props.fields.disabled()} + openOnFocus={false} + class={ + field.mobile + ? 'min-w-0 flex-1 bg-transparent rounded-none! [&_input]:ml-0! [&_input]:min-w-0! [&_input]:text-[17px] [&_input]:leading-6 [&_input]:text-ink [&_input]:placeholder:text-ink-placeholder' + : 'min-w-0 bg-transparent rounded-none! [&_input]:ml-0!' + } + inputRef={ + field.field === 'to' + ? setToRef + : field.field === 'cc' + ? setCcRef + : setBccRef + } + options={props.options} + selfEmail={props.senderEmail()} + selectedOptions={props.values()[field.field]} + setSelectedOptions={(values) => + props.fields.setRecipients(field.field, values) + } + triggerMode="input" + portalScope={field.mobile ? props.portalScope() : undefined} + hideBorder + noPadding + onChipDragStart={(option, event) => + handleChipDragStart(field.field, option, event) + } + onChipDragEnd={handleChipDragEnd} + hideMenuOnEscape + /> + ); + return ( + +
+ + +
+ } + > +
+
+
+
+ From +
+ +
+
+ + + + + + +
+
+ + +
+ To +
+ +
+ {/* Expanded CC */} + 0}> + +
+ Cc +
+ +
+
+ {/* Expanded BCC */} + 0}> + +
+ Bcc +
+ +
+
+
+
+
+
+
Subject
+ { + props.onSubjectChange(e.currentTarget.value); + }} + onKeyDown={(e) => { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.currentTarget.blur(); + }} + placeholder="Subject" + /> +
+ + } + > +
+ +
To:
+ + +
+ + 0}> + +
Cc:
+ +
+
+ + 0}> + +
Bcc:
+ +
+
+ +
+ From:  + +
+ +
+ { + props.onSubjectChange(e.currentTarget.value); + }} + onKeyDown={(e) => { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.currentTarget.blur(); + }} + placeholder="Subject:" + /> +
+
+ + ); +} diff --git a/apps/web/src/features/email-compose/views/reply-input.tsx b/apps/web/src/features/email-compose/views/reply-input.tsx new file mode 100644 index 00000000000..ca9beaceca9 --- /dev/null +++ b/apps/web/src/features/email-compose/views/reply-input.tsx @@ -0,0 +1,598 @@ +import { EmailAttachmentPill } from '@app/features/email-message/components/attachment-pill'; +import { FileDropOverlay } from '@core/component/FileDropOverlay'; +import { buildConfig } from '@core/component/LexicalMarkdown/builder/MarkdownConfigBuilder'; +import { MarkdownShell } from '@core/component/LexicalMarkdown/builder/MarkdownShell'; +import { iosCursorScrollPlugin } from '@core/component/LexicalMarkdown/plugins/ios-cursor-scroll'; +import { fileFolderDrop } from '@core/directive/fileFolderDrop'; +import { fileSelector } from '@core/directive/fileSelector'; +import { registerHotkey, useHotkeyDOMScope } from '@core/hotkey/hotkeys'; +import { TOKENS } from '@core/hotkey/tokens'; +import { isNativeMobilePlatform } from '@core/mobile/isNativeMobilePlatform'; +import { useTouchOutsideToDismissKeyboard } from '@core/mobile/useTouchOutsideToDismissKeyboard'; +import { ToggleButton as KToggleButton } from '@kobalte/core/toggle-button'; +import DotsThree from '@phosphor/dots-three.svg'; +import Paperclip from '@phosphor/paperclip.svg'; +import Trash from '@phosphor/trash.svg'; +import { isIOS } from '@solid-primitives/platform'; +import { Button, cn, Layer, SendButton, Surface, Tooltip } from '@ui'; +import type { LexicalEditor } from 'lexical'; +import { $getRoot } from 'lexical'; +import { createSignal, For, onMount, Show } from 'solid-js'; +import { EmailDateSelector } from '../components/email-date-selector'; +import { MacroSignatureButton } from '../components/macro-signature-button'; +import { SignaturePreview } from '../components/signature-preview'; +import type { EmailComposeContext } from '../context/compose-capabilities'; +import { getOrInitEmailFormContext } from '../context/email-form-context'; +import { registerToggleAppendedThread } from '../primitives/prepare-email-body'; +import { ReplyEnvelope } from './reply-envelope'; + +false && fileFolderDrop; +false && fileSelector; + +import { + createReplyComposer, + type ReplyComposerOptions, +} from '../primitives/reply-composer'; + +type ReplyInputViewProps = Omit< + ReplyComposerOptions, + | 'drafts' + | 'attachmentStorage' + | 'delivery' + | 'notices' + | 'accounts' + | 'viewerEmail' + | 'hasPaidAccess' + | 'recordMention' + | 'focusAfterReplyRequest' +> & { + context: EmailComposeContext; + markdownDomRef?: (ref: HTMLDivElement) => void | HTMLDivElement; + unframed?: boolean; + mobileDrawer?: { onClose: () => void }; +}; +export function ReplyInputView(props: ReplyInputViewProps) { + const composeContext = props.context; + const ctx = props.session; + const [isDragging, setIsDragging] = createSignal(); + let composeContainerRef: HTMLDivElement | undefined; + let bottomBarRef: HTMLDivElement | undefined; + const [editor, setEditor] = createSignal(); + const state = createReplyComposer( + { + drafts: composeContext.drafts, + attachmentStorage: composeContext.attachmentStorage, + delivery: composeContext.delivery, + notices: composeContext.notices, + accounts: composeContext.accounts, + viewerEmail: composeContext.viewerEmail, + hasPaidAccess: composeContext.hasPaidAccess, + recordMention: composeContext.recordMention, + focusAfterReplyRequest: () => !composeContext.presentation.isTouch(), + session: props.session, + sourceEntityId: props.sourceEntityId, + replyingTo: props.replyingTo, + isEditingExisting: props.isEditingExisting, + draft: props.draft, + preloadedHtml: props.preloadedHtml, + formSeed: props.formSeed, + onEngaged: props.onEngaged, + sideEffectOnSend: props.sideEffectOnSend, + onMarkDone: props.onMarkDone, + setShowReply: props.setShowReply, + }, + editor, + { container: () => composeContainerRef, footer: () => bottomBarRef }, + getOrInitEmailFormContext + ); + const { + form, + activeInboxId, + activeInboxEmail, + setIncludeSignature, + setScrollContainer, + composerExpanded, + setComposerExpanded, + quoteCollapsed, + setQuoteCollapsed, + savedDraftId, + handleEditorConnect, + isSending, + collectDraft, + scheduleDraftSave, + persistDraftOnSenderSwitch, + hasPaidAccess, + sendEmail, + deleteDraftAndReset, + handleAddAttachments, + handleRemoveAttachment, + handleSendTimeChange, + sendActionDisabled, + scheduleSendDisabled, + toggleQuotedText, + } = state; + const sendActionHidden = () => + composeContext.presentation.isTouch() && + !state.hasBodyText() && + state.replyType() !== 'forward'; + const signatureHtml = () => + composeContext.presentation.signaturesEnabled() + ? state.signatureHtml() + : undefined; + const isMobileDrawer = () => props.mobileDrawer !== undefined; + const composePortalScope = () => + isMobileDrawer() ? ('local' as const) : undefined; + const scrollAreaSignatureHtml = () => + isMobileDrawer() ? signatureHtml() : undefined; + const footerSignatureHtml = () => + isMobileDrawer() ? undefined : signatureHtml(); + // File sharing and editor plugin wiring belong to this view. The controller only + // needs to know when editor content has changed and requires another save. + const editorConfig = buildConfig('markdown') + .namespace('email-base-input-markdown') + .withMentions({ + onUserMention: state.handleUserMention, + onDocumentMention: (item) => { + composeContext.editorFiles.makePublic(item.id); + scheduleDraftSave(); + }, + }) + .withEmojis() + .withLinks({ floatingMenu: true, autoLinkMatchMode: 'common-tlds' }) + .withHistory({ timeGap: 400 }) + .withMedia() + .withCode() + .withCheckboxToTask() + .withRestoreFocus() + .withSelectionData() + .withFloatingFormatMenu() + .use(registerToggleAppendedThread) + .onChange(state.onContentChange) + .withFilePaste({ + onPasteFilesAndDirs: (files, directories) => { + composeContext.editorFiles.uploadEditorFiles({ + editor: editor(), + sourceId: props.sourceEntityId, + files, + directories, + onUploaded: (ids) => { + ids.forEach(composeContext.editorFiles.makePublic); + scheduleDraftSave(); + }, + }); + }, + }); + if (isIOS || isNativeMobilePlatform()) { + editorConfig.use( + iosCursorScrollPlugin({ scrollContainer: state.scrollContainer }) + ); + } + const markdownHandle = editorConfig.buildHandle(); + setEditor(markdownHandle.lexical); + // Set up hotkey scope for the compose message component + const [attachComposeHotkeys, composeHotkeyScope] = + useHotkeyDOMScope('compose-message'); + useTouchOutsideToDismissKeyboard(() => composeContainerRef); + + onMount(() => { + if (composeContainerRef) { + attachComposeHotkeys(composeContainerRef); + + registerHotkey({ + hotkey: 'cmd+enter', + scopeId: composeHotkeyScope, + description: 'Send email', + keyDownHandler: () => { + if (form.sendTime()) return false; + sendEmail(); + return true; + }, + runWithInputFocused: true, + hotkeyToken: TOKENS.email.send, + displayPriority: 9, + }); + + registerHotkey({ + hotkey: 'shift+cmd+enter', + scopeId: composeHotkeyScope, + description: 'Send and mark done', + keyDownHandler: () => { + if (form.sendTime()) return false; + sendEmail(true); + return true; + }, + runWithInputFocused: true, + hotkeyToken: TOKENS.email.sendAndMarkDone, + displayPriority: 10, + }); + + registerHotkey({ + hotkey: 'arrowup', + scopeId: composeHotkeyScope, + description: 'Select last message', + runWithInputFocused: true, + condition: () => { + const ed = editor(); + if (!ed) return false; + const rootEl = ed.getRootElement(); + if (!rootEl || !rootEl.contains(document.activeElement)) return false; + return ed.read(() => { + const text = $getRoot().getTextContent(); + return text.trim().length === 0; + }); + }, + keyDownHandler: () => { + return ctx.exitToThread('last'); + }, + hotkeyToken: TOKENS.email.previousMessage, + }); + + registerHotkey({ + hotkey: 'escape', + scopeId: composeHotkeyScope, + description: 'Close reply', + keyDownHandler: () => { + const draft = collectDraft(); + const isEmpty = draft === null; + + if (isEmpty) { + // Delete draft and close reply + deleteDraftAndReset(); + } else { + // Move focus back to the message + ctx.exitToThread('selected'); + } + return true; + }, + // Let editable fields handle Escape before closing the reply. + runWithInputFocused: false, + hotkeyToken: TOKENS.email.cancelReply, + displayPriority: 8, + }); + } + }); + + const AttachmentsRow = (rowProps?: { class?: string }) => ( + 0}> +
+ + {(attachment) => ( + handleRemoveAttachment(attachment)} + /> + )} + +
+
+ ); + + const AttachButton = (buttonProps?: { + variant?: 'ghost' | 'outline'; + class?: string; + }) => ( + + ); + + return ( + { + composeContainerRef = el; + }} + depth={2} + solid + > + + +
+
+ +
+
+ + sendEmail()} + /> +
+
+
+
+ { + form.setSubject(subject); + scheduleDraftSave(); + }} + showSubject={!!props.isEditingExisting} + mobile={isMobileDrawer} + portalScope={composePortalScope} + replyType={state.replyType} + /> +
+
{ + if (composerExpanded() || e.currentTarget.scrollTop <= 0) return; + setComposerExpanded(true); + // Keep the send bar pinned while the box grows + requestAnimationFrame(() => { + bottomBarRef?.scrollIntoView({ block: 'nearest' }); + }); + }} + onclick={() => { + editor()?.focus(); + }} + use:fileFolderDrop={{ + onDragStart: (valid) => setIsDragging(valid), + onDragEnd: () => setIsDragging(false), + onDrop: (files, directories, event) => { + const currentEditor = editor(); + if (!currentEditor || !event) return; + composeContext.editorFiles.uploadEditorFiles({ + editor: currentEditor, + sourceId: props.sourceEntityId, + files, + directories, + dropEvent: event, + onUploaded: (ids) => { + setIsDragging(false); + ids.forEach(composeContext.editorFiles.makePublic); + scheduleDraftSave(); + }, + }); + }, + }} + > +
+ Drop file(s) to attach +
+ props.markdownDomRef?.(el)} + onConnect={handleEditorConnect} + /> + +
+ +
+
+ + + + + {(html) => ( + { + // Dismissal is composer-local state worth keeping — latch + // the seed so a draft upgrade can't remount it away. + props.onEngaged?.(); + setIncludeSignature(false); + }} + /> + )} + +
+ {/* Quoted-text controls live below the scroll area so they stay + anchored to the composer bottom instead of scrolling with (and + floating over) tall content. */} + +
+ +
+
+ +
e.stopPropagation()} + > + + + + + +
+
+ + {/* Below the scroll area so quoted email content can never overlap it */} + + + {(html) => ( + { + // Dismissal is composer-local state worth keeping — latch + // the seed so a draft upgrade can't remount it away. + props.onEngaged?.(); + setIncludeSignature(false); + }} + /> + )} + + {/* No fixed height: the send button (size-7.5) is taller than the icon + buttons, and a fixed h-9 minus the vertical padding left it 4px short + — with items-end it bled upward over the signature bar above. */} +
+
+
+ +
+ + +
+ +
+ + + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/features/email-message/attachment-action-adapter.ts b/apps/web/src/features/email-message/attachment-action-adapter.ts new file mode 100644 index 00000000000..8d5d3011bdc --- /dev/null +++ b/apps/web/src/features/email-message/attachment-action-adapter.ts @@ -0,0 +1,55 @@ +import { useSplitLayout } from '@components/app/split-layout/layout'; +import { toast } from '@core/component/Toast/Toast'; +import { fileTypeToBlockName } from '@core/constant/allBlocks'; +import { Telemetry } from '@macro-inc/observability'; +import { + getEmailAttachmentDocument, + getEmailAttachmentMetadata, +} from '@queries/email/integration'; +import { refetchSoupEntity } from '@queries/soup/cache'; +import { FileTypeMap } from '@service-storage/fileTypeMap'; +import type { FileType } from '@service-storage/generated/schemas/fileType'; +import type { EmailAttachment } from './core/email-message'; +export function createEmailAttachmentOpener() { + const { openWithSplit } = useSplitLayout(); + const openAttachment = async (attachment: EmailAttachment) => { + const dbId = attachment.db_id; + if (!dbId) return; + const response = await getEmailAttachmentDocument(dbId); + if (response.isErr()) { + toast.failure('Failed to get attachment. Please try again.'); + return Telemetry.error( + new Error( + 'Failed to get or create attachment document id: ' + response.error + ) + ); + } + const { document_id } = response.value; + + const maybeDocumentMetadata = await getEmailAttachmentMetadata(document_id); + if (maybeDocumentMetadata.isErr()) { + toast.failure('Failed to get attachment. Please try again.'); + return Telemetry.error( + new Error( + 'Failed to get or create attachment document metadata: ' + + maybeDocumentMetadata.error + ) + ); + } + + refetchSoupEntity(document_id, 'document'); + + const fileType = Object.values(FileTypeMap).findLast( + (type) => type.mime === attachment.mime_type + )?.extension; + const blockName = fileType + ? fileTypeToBlockName(fileType as FileType) + : 'unknown'; + openWithSplit( + { type: blockName, id: document_id }, + { preferNewSplit: true } + ); + }; + + return openAttachment; +} diff --git a/apps/web/src/features/email-message/components/attachment-pill.test.tsx b/apps/web/src/features/email-message/components/attachment-pill.test.tsx new file mode 100644 index 00000000000..be62ca062ab --- /dev/null +++ b/apps/web/src/features/email-message/components/attachment-pill.test.tsx @@ -0,0 +1,42 @@ +import { render } from '@solidjs/testing-library'; +import userEvent from '@testing-library/user-event'; +import { expect, it, vi } from 'vitest'; +import { EmailAttachmentPill } from './attachment-pill'; + +// EntityIcon imports the block registry, which initializes app services. This +// covers the pill's keyboard behavior, not isolation of the shared icon. +vi.mock('@core/component/EntityIcon', () => ({ EntityIcon: () => null })); + +it('opens attachments with Enter and Space, while removal stays a separate keyboard action', async () => { + const open = vi.fn(); + const remove = vi.fn(); + const user = userEvent.setup(); + const view = render(() => ( + <> + + + + )); + try { + await user.tab(); + expect(document.activeElement).toBe( + view.getByRole('button', { name: 'agenda.pdf' }) + ); + await user.keyboard('{Enter} '); + expect(open).toHaveBeenCalledTimes(2); + await user.tab(); + expect(document.activeElement).toBe( + view.getByRole('button', { name: 'Remove agenda.pdf' }) + ); + await user.keyboard('{Enter}'); + expect(remove).toHaveBeenCalledOnce(); + expect(open).toHaveBeenCalledTimes(2); + } finally { + view.unmount(); + } +}); diff --git a/apps/web/src/features/block-email/component/AttachmentPill.tsx b/apps/web/src/features/email-message/components/attachment-pill.tsx similarity index 55% rename from apps/web/src/features/block-email/component/AttachmentPill.tsx rename to apps/web/src/features/email-message/components/attachment-pill.tsx index b1b9a7849e8..409d3d297a4 100644 --- a/apps/web/src/features/block-email/component/AttachmentPill.tsx +++ b/apps/web/src/features/email-message/components/attachment-pill.tsx @@ -3,6 +3,7 @@ import X from '@phosphor/x.svg'; import { FileTypeMap } from '@service-storage/fileTypeMap'; import type { FileType } from '@service-storage/generated/schemas/fileType'; import { Show } from 'solid-js'; +import { Dynamic } from 'solid-js/web'; const mimeToFileExtTypeMap = new Map( Object.values(FileTypeMap).map((value) => [value.mime, value.extension]) @@ -12,7 +13,7 @@ type EmailAttachmentPillProps = { attachment: { fileName: string; mimeType?: string }; removable?: boolean; onRemove?: () => void; - onClick?: (fileType?: FileType) => void; + onClick?: () => void; }; export function EmailAttachmentPill(props: EmailAttachmentPillProps) { @@ -30,18 +31,40 @@ export function EmailAttachmentPill(props: EmailAttachmentPillProps) { classList={{ 'pl-2': props.removable, }} - onClick={() => props.onClick?.(fileType())} > - - - -
{props.attachment.fileName}
+ { + if (e.key === 'Enter' || e.key === ' ') e.stopPropagation(); + }} + > + + + + + {props.attachment.fileName} + + -
{ + // Keep the editor's focus. Focusing its message card can scroll + // this control away between pointerdown and click. + e.preventDefault(); + }} + onMouseDown={(e) => e.preventDefault()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') e.stopPropagation(); + }} onclick={(e) => { e.preventDefault(); e.stopImmediatePropagation(); @@ -56,7 +79,7 @@ export function EmailAttachmentPill(props: EmailAttachmentPillProps) { }} > -
+
); diff --git a/apps/web/src/features/block-email/component/CollapsedMessage.tsx b/apps/web/src/features/email-message/components/collapsed-message.tsx similarity index 68% rename from apps/web/src/features/block-email/component/CollapsedMessage.tsx rename to apps/web/src/features/email-message/components/collapsed-message.tsx index 9a4b8bb9333..f2cfa8da07b 100644 --- a/apps/web/src/features/block-email/component/CollapsedMessage.tsx +++ b/apps/web/src/features/email-message/components/collapsed-message.tsx @@ -1,31 +1,23 @@ -import { UserIcon, type UserIconProps } from '@core/component/UserIcon'; -import { useEmail } from '@core/context/user'; -import type { ApiMessage } from '@service-email/generated/schemas'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; import { Tooltip } from '@ui'; -import { createMemo, Show } from 'solid-js'; -import { getSenderDisplayName, getSenderMacroId } from '../util/emailUser'; -import { formatFullDate, formatShortDate } from '../util/formatEmailDate'; -import { EmailUserTooltip } from './EmailUserTooltip'; +import { createMemo, type JSX, Show } from 'solid-js'; +import { getSenderDisplayName } from '../core/email-user'; +import { formatFullDate, formatShortDate } from '../core/format-email-date'; +import { EmailUserTooltip } from './email-user-tooltip'; interface CollapsedMessageProps { - message: ApiMessage; + message: EmailMessage; + avatar?: JSX.Element; + currentUserEmail?: string; } /** Collapsed thread row: sender, snippet, date. The chrome is MessageCard's. */ export function CollapsedMessage(props: CollapsedMessageProps) { - const currentUserEmail = useEmail(); + const currentUserEmail = () => props.currentUserEmail; const senderDisplay = createMemo(() => getSenderDisplayName(props.message, currentUserEmail()) ); - const senderMacroId = createMemo(() => getSenderMacroId(props.message)); - const senderIconProps = createMemo(() => { - const senderId = senderMacroId(); - const photoUrl = props.message.from?.photo_url ?? undefined; - if (senderId) return { id: senderId, photoUrl }; - return { email: props.message.from?.email ?? '', photoUrl }; - }); - const snippet = createMemo(() => { if (props.message.body_text) { return props.message.body_text.replace(/\s+/g, ' ').trim(); @@ -45,12 +37,7 @@ export function CollapsedMessage(props: CollapsedMessageProps) {
- + {props.avatar}
diff --git a/apps/web/src/features/block-email/component/EmailMessageTopBar.tsx b/apps/web/src/features/email-message/components/email-message-top-bar.tsx similarity index 89% rename from apps/web/src/features/block-email/component/EmailMessageTopBar.tsx rename to apps/web/src/features/email-message/components/email-message-top-bar.tsx index 95e698bdfcd..93b7111b5a4 100644 --- a/apps/web/src/features/block-email/component/EmailMessageTopBar.tsx +++ b/apps/web/src/features/email-message/components/email-message-top-bar.tsx @@ -1,7 +1,5 @@ -import { useEmail } from '@core/context/user'; -import { isTouchDevice } from '@core/mobile/isTouchDevice'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; import CaretRight from '@phosphor/caret-right.svg'; -import type { ApiMessage } from '@service-email/generated/schemas'; import { Button, cn, Tooltip } from '@ui'; import { type Accessor, @@ -15,22 +13,23 @@ import { import { getRecipientDisplayName, getSenderDisplayName, -} from '../util/emailUser'; -import { formatFullDate, formatShortDate } from '../util/formatEmailDate'; +} from '../core/email-user'; +import { formatFullDate, formatShortDate } from '../core/format-email-date'; -import { EmailUserTooltip } from './EmailUserTooltip'; -import { type EmailMessageAction, MessageActions } from './MessageActions'; +import { EmailUserTooltip } from './email-user-tooltip'; +import { type EmailMessageAction, MessageActions } from './message-actions'; interface EmailMessageTopBarProps { - message: ApiMessage; + message: EmailMessage; focused: boolean; + viewerEmail?: string; + isTouch: boolean; setExpandedBodyId: (id: string, expanded: boolean) => void; isBodyExpanded: Accessor; expandedHeader: Accessor; setExpandedHeader: Setter; setFocusedMessageId: (messageId: string | undefined) => void; - setShowReply: Setter; - isLastMessage?: boolean; + onReply?: (action: EmailMessageAction) => void; hiddenActions?: EmailMessageAction[]; avatar?: JSX.Element; } @@ -84,7 +83,7 @@ function DetailRow(props: { ); } -function ExpandedDetails(props: { message: ApiMessage }): JSX.Element { +function ExpandedDetails(props: { message: EmailMessage }): JSX.Element { const fromRecipients = createMemo(() => props.message.from ? [props.message.from] : [] ); @@ -135,10 +134,9 @@ function HeaderTopRow(props: { showHeaderToggle: boolean; isExpanded: boolean; onToggle: () => void; - message: ApiMessage; + message: EmailMessage; focused: boolean; - setShowReply: Setter; - isLastMessage?: boolean; + onReply?: (action: EmailMessageAction) => void; hiddenActions?: EmailMessageAction[]; currentUserEmail?: string; }): JSX.Element { @@ -196,8 +194,7 @@ function HeaderTopRow(props: {
@@ -217,14 +214,14 @@ function HeaderTopRow(props: { export function EmailMessageTopBar(props: EmailMessageTopBarProps) { const [isHovering, setIsHovering] = createSignal(false); - const userEmail = useEmail(); + const userEmail = () => props.viewerEmail; const senderName = () => getSenderDisplayName(props.message, userEmail()); const showHeaderToggle = () => isHovering() || props.expandedHeader() || - (isTouchDevice() && props.isBodyExpanded()); + (props.isTouch && props.isBodyExpanded()); const handleHeaderClick = (e: MouseEvent) => { const id = props.message.db_id; @@ -258,8 +255,7 @@ export function EmailMessageTopBar(props: EmailMessageTopBarProps) { onToggle={() => props.setExpandedHeader(!props.expandedHeader())} message={props.message} focused={props.focused} - setShowReply={props.setShowReply} - isLastMessage={props.isLastMessage} + onReply={props.onReply} hiddenActions={props.hiddenActions} currentUserEmail={userEmail()} /> diff --git a/apps/web/src/features/block-email/component/EmailUserTooltip.tsx b/apps/web/src/features/email-message/components/email-user-tooltip.tsx similarity index 95% rename from apps/web/src/features/block-email/component/EmailUserTooltip.tsx rename to apps/web/src/features/email-message/components/email-user-tooltip.tsx index 7f741dab85e..04d3d278d42 100644 --- a/apps/web/src/features/block-email/component/EmailUserTooltip.tsx +++ b/apps/web/src/features/email-message/components/email-user-tooltip.tsx @@ -1,6 +1,6 @@ import { HoverCard } from '@core/component/HoverCard'; import { UserTooltip } from '@core/component/UserTooltip'; -import { emailToMacroId } from '@core/user'; +import { emailToMacroId } from '@core/user/macroId'; import { createSignal, type JSX } from 'solid-js'; interface Recipient { diff --git a/apps/web/src/features/block-email/component/MessageActions.tsx b/apps/web/src/features/email-message/components/message-actions.tsx similarity index 58% rename from apps/web/src/features/block-email/component/MessageActions.tsx rename to apps/web/src/features/email-message/components/message-actions.tsx index 173812fe9f6..740d34eb086 100644 --- a/apps/web/src/features/block-email/component/MessageActions.tsx +++ b/apps/web/src/features/email-message/components/message-actions.tsx @@ -1,46 +1,26 @@ -import type { ReplyType } from '@block-email/util/replyType'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; import ArrowBendUpLeft from '@phosphor/arrow-bend-up-left.svg'; import ArrowBendUpRight from '@phosphor/arrow-bend-up-right.svg'; -import type { ApiMessage } from '@service-email/generated/schemas'; -import { createCallback } from '@solid-primitives/rootless'; import { Button } from '@ui'; -import { type Setter, Show } from 'solid-js'; -import { useEmailContext } from './EmailContext'; -import { openEmailReplyComposerForMessage } from './emailReplyActions'; +import { Show } from 'solid-js'; const EMAIL_MESSAGE_ACTIONS = ['reply', 'reply-all', 'forward'] as const; export type EmailMessageAction = (typeof EMAIL_MESSAGE_ACTIONS)[number]; export function MessageActions(props: { - message: ApiMessage; + message: EmailMessage; showActions: boolean; - setShowReply: Setter; - isLastMessage?: boolean; + onReply?: (action: EmailMessageAction) => void; hiddenActions?: EmailMessageAction[]; }) { - const ctx = useEmailContext(); - - const canShowActions = () => { - if (!props.showActions) return false; - - const allActionsHidden = props.hiddenActions?.every((a) => - EMAIL_MESSAGE_ACTIONS.includes(a) + const canShowActions = () => + props.showActions && + !!props.onReply && + !EMAIL_MESSAGE_ACTIONS.every((action) => + props.hiddenActions?.includes(action) ); - - return !allActionsHidden; - }; - - const onChangeReplyType = (type: ReplyType) => { - return createCallback(() => { - openEmailReplyComposerForMessage({ - ctx, - message: props.message, - replyType: type, - isLastMessage: props.isLastMessage, - setShowReply: props.setShowReply, - }); - }); - }; + const onChangeReplyType = (action: EmailMessageAction) => () => + props.onReply?.(action); return (
void; + onSelect?: () => void; + onHover?: () => void; + onUnhover?: () => void; + onFocus?: (element: HTMLElement) => void; children: JSX.Element; } @@ -34,25 +36,6 @@ interface MessageCardProps { * since this node keeps DOM focus while its content swaps. */ export function MessageCard(props: MessageCardProps) { - const context = useEmailContext(); - - const select = () => { - if (!props.messageId) return; - context.messages.setFocused(props.messageId); - }; - - const hoverThisRow = () => { - if (!props.messageId) return; - context.messages.setHovered({ kind: 'message', id: props.messageId }); - }; - - const unhoverThisRow = () => { - const hovered = context.messages.hovered(); - if (hovered?.kind === 'message' && hovered.id === props.messageId) { - context.messages.setHovered(undefined); - } - }; - return (
@@ -66,12 +49,12 @@ export function MessageCard(props: MessageCardProps) { style={{ '--user-icon-width': '1rem' }} data-message-body-id={props.messageId} tabIndex={0} - onPointerEnter={hoverThisRow} - onPointerLeave={unhoverThisRow} + onPointerEnter={props.onHover} + onPointerLeave={props.onUnhover} onClick={(e) => { // Selection is unconditional: a click that lands on a link or a // button inside the card focuses that child, not the card. - select(); + props.onSelect?.(); const target = e.target; if (target instanceof Element && target.closest('[data-button]')) { return; @@ -85,8 +68,8 @@ export function MessageCard(props: MessageCardProps) { props.onActivate(); }} onFocus={(e) => { - scrollFocusedCardIntoView(e.currentTarget); - select(); + props.onFocus?.(e.currentTarget); + props.onSelect?.(); }} > {props.children} diff --git a/apps/web/src/features/email-message/context/email-rendering-context.tsx b/apps/web/src/features/email-message/context/email-rendering-context.tsx new file mode 100644 index 00000000000..7f190e9c41e --- /dev/null +++ b/apps/web/src/features/email-message/context/email-rendering-context.tsx @@ -0,0 +1,29 @@ +import type { ImagePolicy } from '@macro-inc/email-renderer'; +import type { + ResourceLifetime, + ThemeColorParams, +} from '@macro-inc/email-renderer/browser'; +import { type Accessor, createContext, useContext } from 'solid-js'; +import type { EmailAttachment } from '../core/email-message'; + +/** Rendering capabilities shared by email surfaces; no thread or block state. */ +export interface EmailRenderingContextValue { + theme: Accessor; + images?: ImagePolicy; + prepareLinks?: (container: HTMLElement) => void; + resolveImages( + root: ShadowRoot, + attachments: EmailAttachment[], + lifetime: ResourceLifetime + ): Promise; +} + +const EmailRenderingContext = createContext(); +export const EmailRenderingProvider = EmailRenderingContext.Provider; + +export function useEmailRenderingContext(): EmailRenderingContextValue { + const value = useContext(EmailRenderingContext); + if (!value) + throw new Error('Email rendering requires an EmailRenderingProvider'); + return value; +} diff --git a/apps/web/src/features/email-message/core/email-message.ts b/apps/web/src/features/email-message/core/email-message.ts new file mode 100644 index 00000000000..c1dda7b396d --- /dev/null +++ b/apps/web/src/features/email-message/core/email-message.ts @@ -0,0 +1,63 @@ +/** Feature-owned values. Transport adaptation belongs to queries. */ +export interface EmailContact { + email: string; + name?: string | null; + photo_url?: string | null; +} + +export interface EmailAttachment { + content_id?: string | null; + db_id: string; + filename?: string | null; + mime_type?: string | null; + sfs_id?: string | null; + size_bytes?: number | null; +} + +export interface EmailDraftAttachment { + content_type: string; + file_name: string; + id: string; + s3_key: string; + size: number; +} + +export interface EmailForwardedAttachment { + attachment_id: string; + filename?: string | null; + mime_type?: string | null; + size_bytes?: number | null; +} + +export interface EmailLabel { + name?: string | null; + provider_label_id: string; +} + +export interface EmailMessage { + attachments: EmailAttachment[]; + attachments_draft: EmailDraftAttachment[]; + attachments_forwarded: EmailForwardedAttachment[]; + bcc: EmailContact[]; + body_html_sanitized?: string | null; + body_macro?: string | null; + body_replyless?: string | null; + body_text?: string | null; + cc: EmailContact[]; + created_at: string; + db_id: string; + from?: null | EmailContact; + internal_date_ts?: string | null; + is_draft: boolean; + labels: EmailLabel[]; + link_id: string; + provider_id?: string | null; + replying_to_id?: string | null; + scheduled_send_time?: string | null; + sent_at?: string | null; + snippet?: string | null; + subject?: string | null; + thread_db_id: string; + to: EmailContact[]; + updated_at: string; +} diff --git a/apps/web/src/features/block-email/util/emailUser.ts b/apps/web/src/features/email-message/core/email-user.ts similarity index 81% rename from apps/web/src/features/block-email/util/emailUser.ts rename to apps/web/src/features/email-message/core/email-user.ts index e5ae38261b4..353ffda1434 100644 --- a/apps/web/src/features/block-email/util/emailUser.ts +++ b/apps/web/src/features/email-message/core/email-user.ts @@ -1,12 +1,12 @@ -import { emailToMacroId } from '@core/user'; -import type { ApiMessage } from '@service-email/generated/schemas'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; + import { getFirstName } from './name'; /** * Check if a message is from the current user */ function isMessageFromCurrentUser( - message: ApiMessage, + message: EmailMessage, currentUserEmail?: string ): boolean { if (!currentUserEmail) return false; @@ -19,7 +19,7 @@ function isMessageFromCurrentUser( * Get the sender display name, showing "Me" for the current user */ export function getSenderDisplayName( - message: ApiMessage, + message: EmailMessage, currentUserEmail?: string ): string { if (isMessageFromCurrentUser(message, currentUserEmail)) { @@ -36,9 +36,9 @@ export function getSenderDisplayName( /** * Convert the message sender email to a macro id for user tooling. */ -export function getSenderMacroId(message: ApiMessage): string | undefined { +export function getSenderMacroId(message: EmailMessage): string | undefined { const senderEmail = message.from?.email; - return senderEmail ? emailToMacroId(senderEmail) : undefined; + return senderEmail?.includes('@') ? `macro|${senderEmail}` : undefined; } interface Recipient { diff --git a/apps/web/src/features/block-email/util/formatEmailDate.ts b/apps/web/src/features/email-message/core/format-email-date.ts similarity index 93% rename from apps/web/src/features/block-email/util/formatEmailDate.ts rename to apps/web/src/features/email-message/core/format-email-date.ts index bac6a5d193e..eabb5f3e740 100644 --- a/apps/web/src/features/block-email/util/formatEmailDate.ts +++ b/apps/web/src/features/email-message/core/format-email-date.ts @@ -1,4 +1,4 @@ -import type { DateValue } from '@core/util/date'; +type DateValue = Date | string | number; export function formatFullDate(date: DateValue): string { return new Date(date) diff --git a/apps/web/src/features/block-email/util/isPersonalMessage.ts b/apps/web/src/features/email-message/core/is-personal-message.ts similarity index 84% rename from apps/web/src/features/block-email/util/isPersonalMessage.ts rename to apps/web/src/features/email-message/core/is-personal-message.ts index 063ebc57b90..7b49475cdb2 100644 --- a/apps/web/src/features/block-email/util/isPersonalMessage.ts +++ b/apps/web/src/features/email-message/core/is-personal-message.ts @@ -1,10 +1,10 @@ -import type { ApiMessage } from '@service-email/generated/schemas'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; /** Personal messages get theme-adapted rendering (vs the forced white panel * for table-layout marketing emails). Mirrored between the message view and * the compose quote. */ export function isPersonalMessage( - message: ApiMessage, + message: EmailMessage, userEmail: string | undefined, personalSenders: Set ): boolean { diff --git a/apps/web/src/features/block-email/util/name.ts b/apps/web/src/features/email-message/core/name.ts similarity index 100% rename from apps/web/src/features/block-email/util/name.ts rename to apps/web/src/features/email-message/core/name.ts diff --git a/apps/web/src/features/email-message/image-adapter.test.ts b/apps/web/src/features/email-message/image-adapter.test.ts new file mode 100644 index 00000000000..4b3b03e984b --- /dev/null +++ b/apps/web/src/features/email-message/image-adapter.test.ts @@ -0,0 +1,77 @@ +import { SERVER_HOSTS } from '@core/constant/servers'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { EmailAttachment } from './core/email-message'; +import { fetchImagesViaPlatform, resolveCidImages } from './image-adapter'; + +const { fetchImage } = vi.hoisted(() => ({ fetchImage: vi.fn() })); +// Exercise the production adapter's native path without invoking Tauri IPC. +// Feature logic tests inject resolveImages through EmailRenderingProvider. +vi.mock('@core/util/platform', () => ({ isTauri: () => true })); +vi.mock('@core/util/platformFetch', () => ({ platformFetch: fetchImage })); + +function imageRoot(src: string) { + const host = document.createElement('div'); + const root = host.attachShadow({ mode: 'open' }); + const img = document.createElement('img'); + img.src = src; + root.append(img); + return { root, img }; +} + +describe('production image adaptation', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + fetchImage.mockReset(); + }); + it('resolves matching CID attachments and leaves unknown IDs unchanged', () => { + const { root, img } = imageRoot('cid:part'); + const unknown = document.createElement('img'); + unknown.src = 'cid:unknown'; + root.append(unknown); + resolveCidImages(root, [ + { content_id: '', sfs_id: 'file-id' } as EmailAttachment, + ]); + expect(img.src).toBe(`${SERVER_HOSTS['static-file']}/file/file-id`); + expect(unknown.getAttribute('src')).toBe('cid:unknown'); + }); + it('does not create a blob URL when disposed during the platform response body', async () => { + const { root, img } = imageRoot('https://files.example.com/image'); + const { promise: blob, resolve: finish } = Promise.withResolvers(); + fetchImage.mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'image/png' }), + blob: () => blob, + }); + const create = vi.fn(); + vi.stubGlobal('URL', { createObjectURL: create }); + let disposed = false; + const pending = fetchImagesViaPlatform(root, [], () => disposed); + await Promise.resolve(); + disposed = true; + finish(new Blob()); + await pending; + expect(create).not.toHaveBeenCalled(); + expect(img.getAttribute('src')).toBe('https://files.example.com/image'); + }); + it('retains successful native images for lifetime cleanup and rejects non-image responses', async () => { + const { root, img } = imageRoot('https://files.example.com/image'); + fetchImage.mockResolvedValue( + new Response('image', { headers: { 'content-type': 'image/png' } }) + ); + vi.stubGlobal('URL', { createObjectURL: () => 'blob:resolved' }); + const urls: string[] = []; + await fetchImagesViaPlatform(root, urls, () => false); + expect(img.src).toBe('blob:resolved'); + expect(urls).toEqual(['blob:resolved']); + const html = imageRoot('https://files.example.com/login'); + fetchImage.mockResolvedValue( + new Response('login', { headers: { 'content-type': 'text/html' } }) + ); + await fetchImagesViaPlatform(html.root, urls, () => false); + expect(html.img.getAttribute('src')).toBe( + 'https://files.example.com/login' + ); + expect(urls).toEqual(['blob:resolved']); + }); +}); diff --git a/apps/web/src/features/block-email/util/resolveEmailImages.ts b/apps/web/src/features/email-message/image-adapter.ts similarity index 96% rename from apps/web/src/features/block-email/util/resolveEmailImages.ts rename to apps/web/src/features/email-message/image-adapter.ts index f56fa7f07a6..82fc6aa5c27 100644 --- a/apps/web/src/features/block-email/util/resolveEmailImages.ts +++ b/apps/web/src/features/email-message/image-adapter.ts @@ -1,12 +1,12 @@ +import type { EmailMessage } from '@app/features/email-message/core/email-message'; import { SERVER_HOSTS } from '@core/constant/servers'; import { isTauri } from '@core/util/platform'; import { platformFetch } from '@core/util/platformFetch'; -import type { ApiMessage } from '@service-email/generated/schemas'; /** Resolves cid: URLs in tags to their static-file-service equivalents. */ export function resolveCidImages( root: ShadowRoot, - attachments: ApiMessage['attachments'] + attachments: EmailMessage['attachments'] ): void { const contentIdToSfsId = new Map(); for (const att of attachments ?? []) { diff --git a/apps/web/src/features/email-message/primitives/email-message-body.test.ts b/apps/web/src/features/email-message/primitives/email-message-body.test.ts new file mode 100644 index 00000000000..e88ed18f89a --- /dev/null +++ b/apps/web/src/features/email-message/primitives/email-message-body.test.ts @@ -0,0 +1,121 @@ +import type { ResourceLifetime } from '@macro-inc/email-renderer/browser'; +import { createRoot, createSignal } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { message } from '../tests/messages'; +import { + createEmailMessageBody, + type EmailMessageBodyProps, +} from './email-message-body'; + +const theme = { + inkL: 0.2, + inkC: 0, + inkH: 0, + panelL: 1, + accentL: 0.6, + accentC: 0.1, + accentH: 50, +}; +const bodyOptions: Omit = { + isPersonal: true, + isBodyExpanded: () => true, + setExpandedMessageBody() {}, + setFocusedMessageId() {}, + isFocused: false, +}; +describe('independent email body', () => { + afterEach(() => vi.unstubAllGlobals()); + it('renders one message, rewires its DOM on source changes, and cleans image/resize resources on disposal', async () => { + const disconnect = vi.fn(); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect = disconnect; + } + ); + const revoke = vi.fn(); + vi.stubGlobal('URL', { ...URL, revokeObjectURL: revoke }); + const resolveImages = vi.fn( + async (_root, _attachments, lifetime: ResourceLifetime) => { + lifetime.onDispose(() => URL.revokeObjectURL('blob:email')); + } + ); + const prepareLinks = vi.fn(); + const root = createRoot((dispose) => { + const [value, setValue] = createSignal( + message('one', { + body_html_sanitized: + '

Hello Person

Quoted thread
', + body_replyless: '

Hello Person

', + }) + ); + const body = createEmailMessageBody( + { + get message() { + return value(); + }, + ...bodyOptions, + }, + { theme: () => theme, resolveImages, prepareLinks } + ); + return { dispose, body, setValue }; + }); + try { + await Promise.resolve(); + const host = root.body.host()!; + expect(host.shadowRoot?.textContent).toContain('Hello Person'); + root.body.setShowFullHTML(true); + await Promise.resolve(); + expect(root.body.host()!.shadowRoot?.textContent).toContain( + 'Quoted thread' + ); + const link = root.body.host()!.shadowRoot?.querySelector('a'); + expect(link?.target).toBe('_blank'); + expect(link?.rel).toBe('noopener noreferrer'); + expect(prepareLinks).toHaveBeenCalled(); + const expandedHost = root.body.host(); + root.setValue( + message('two', { + body_html_sanitized: '

Second message

', + body_replyless: '

Second message

', + }) + ); + await Promise.resolve(); + expect(root.body.host()).not.toBe(expandedHost); + expect(root.body.host()!.shadowRoot?.textContent).toContain( + 'Second message' + ); + } finally { + root.dispose(); + } + expect(resolveImages).toHaveBeenCalled(); + expect(revoke).toHaveBeenCalledWith('blob:email'); + expect(disconnect).toHaveBeenCalled(); + }); + it.each([ + { body_macro: 'A document mention' }, + { + body_html_sanitized: null, + body_text: '**Plaintext using the existing app renderer**', + }, + ])( + 'keeps the app Markdown paths free of hidden HTML resources: %j', + async (content) => { + const resolveImages = vi.fn(async () => {}); + createRoot((dispose) => { + const body = createEmailMessageBody( + { + message: message('markdown', content), + ...bodyOptions, + }, + { theme: () => theme, resolveImages } + ); + expect(body.host()).toBeUndefined(); + dispose(); + }); + await Promise.resolve(); + expect(resolveImages).not.toHaveBeenCalled(); + } + ); +}); diff --git a/apps/web/src/features/email-message/primitives/email-message-body.ts b/apps/web/src/features/email-message/primitives/email-message-body.ts new file mode 100644 index 00000000000..9d683128870 --- /dev/null +++ b/apps/web/src/features/email-message/primitives/email-message-body.ts @@ -0,0 +1,76 @@ +import { prepareEmailBody } from '@macro-inc/email-renderer'; +import { mountEmailBody } from '@macro-inc/email-renderer/browser'; +import { + type Accessor, + createEffect, + createMemo, + createSignal, + onCleanup, + untrack, +} from 'solid-js'; +import type { EmailRenderingContextValue } from '../context/email-rendering-context'; +import type { EmailMessage } from '../core/email-message'; + +export interface EmailMessageBodyProps { + message: EmailMessage; + isPersonal: boolean; + isBodyExpanded: Accessor; + setExpandedMessageBody: (id: string) => void; + setFocusedMessageId: (messageId: string | undefined) => void; + showFullContent?: boolean; + isFocused: boolean; +} + +/** Solid only translates reactive inputs and owns the renderer's lifetime. */ +export function createEmailMessageBody( + props: EmailMessageBodyProps, + renderingContext: EmailRenderingContextValue +) { + const [showFullHTML, setShowFullHTML] = createSignal(false); + const prepared = createMemo(() => + prepareEmailBody( + { + html: props.message.body_html_sanitized, + replylessHtml: props.message.body_replyless, + text: props.message.body_text, + }, + { + showQuotedContent: showFullHTML(), + showFullContent: props.showFullContent, + images: renderingContext.images, + } + ) + ); + const rendered = createMemo(() => { + // Preserve the app's existing Markdown paths without starting hidden HTML + // resources behind them. Their Lexical semantics stay at the app boundary. + if ( + (!showFullHTML() && props.message.body_macro) || + !props.message.body_html_sanitized + ) + return; + const body = prepared(); + const attachments = props.message.attachments; + const host = document.createElement('div'); + const renderer = mountEmailBody(host, body, { + theme: renderingContext.theme(), + adaptColors: props.isPersonal || !body.hasTable, + normalizeFonts: + props.isPersonal && + !props.message.from?.email?.toLowerCase().endsWith('@macro.com'), + expanded: untrack(props.isBodyExpanded), + prepareLinks: renderingContext.prepareLinks, + resolveImages: (root, lifetime) => + renderingContext.resolveImages(root, attachments, lifetime), + }); + onCleanup(() => renderer.dispose()); + return { host, renderer }; + }); + createEffect(() => rendered()?.renderer.setExpanded(props.isBodyExpanded())); + return { + showFullHTML, + setShowFullHTML, + host: () => rendered()?.host, + hasHiddenReplyStructure: () => prepared().hasHiddenContent, + }; +} diff --git a/apps/web/src/features/email-message/rendering-adapter.ts b/apps/web/src/features/email-message/rendering-adapter.ts new file mode 100644 index 00000000000..34391920f17 --- /dev/null +++ b/apps/web/src/features/email-message/rendering-adapter.ts @@ -0,0 +1,44 @@ +import { ENABLE_PROXY_EMAIL_IMAGES } from '@core/constant/featureFlags'; +import { SERVER_HOSTS } from '@core/constant/servers'; +import { interceptMailtoLinks } from '@core/util/interceptMailtoLinks'; +import { createMemo } from 'solid-js'; +import { themeReactive } from '../theme/signals/themeReactive'; +import { themeUpdate } from '../theme/signals/themeSignals'; +import type { EmailRenderingContextValue } from './context/email-rendering-context'; +import { fetchImagesViaPlatform, resolveCidImages } from './image-adapter'; + +export function createEmailRenderingContext(): EmailRenderingContextValue { + const theme = createMemo(() => { + themeUpdate(); + return { + inkL: themeReactive.c0.l[0](), + inkC: themeReactive.c0.c[0](), + inkH: themeReactive.c0.h[0](), + panelL: themeReactive.b1.l[0](), + accentL: themeReactive.a0.l[0](), + accentC: themeReactive.a0.c[0](), + accentH: themeReactive.a0.h[0](), + }; + }); + return { + theme, + images: { + remote: 'allow', + proxyUrl: ENABLE_PROXY_EMAIL_IMAGES + ? `${SERVER_HOSTS['image-proxy-service']}/proxy` + : undefined, + }, + prepareLinks: interceptMailtoLinks, + async resolveImages(root, attachments, lifetime) { + const blobUrls: string[] = []; + const isDisposed = () => lifetime.signal.aborted; + lifetime.onDispose(() => { + for (const url of blobUrls) URL.revokeObjectURL(url); + }); + if (isDisposed()) return; + resolveCidImages(root, attachments); + if (isDisposed()) return; + await fetchImagesViaPlatform(root, blobUrls, isDisposed); + }, + }; +} diff --git a/apps/web/src/features/email-message/sender-icon-adapter.tsx b/apps/web/src/features/email-message/sender-icon-adapter.tsx new file mode 100644 index 00000000000..454ea026d7a --- /dev/null +++ b/apps/web/src/features/email-message/sender-icon-adapter.tsx @@ -0,0 +1,15 @@ +import { UserIcon } from '@core/component/UserIcon'; +import type { EmailMessage } from './core/email-message'; +import { getSenderMacroId } from './core/email-user'; + +/** Profile lookup and the interactive user card belong to application composition. */ +export function EmailSenderIcon(props: { message: EmailMessage }) { + const sender = () => { + const id = getSenderMacroId(props.message); + const photoUrl = props.message.from?.photo_url ?? undefined; + return id + ? { id, photoUrl } + : { email: props.message.from?.email ?? '', photoUrl }; + }; + return ; +} diff --git a/apps/web/src/features/email-message/tests/messages.ts b/apps/web/src/features/email-message/tests/messages.ts new file mode 100644 index 00000000000..9cf96baba03 --- /dev/null +++ b/apps/web/src/features/email-message/tests/messages.ts @@ -0,0 +1,26 @@ +import type { EmailMessage } from '../core/email-message'; +export function message( + id: string, + overrides: Partial = {} +): EmailMessage { + return { + db_id: id, + thread_db_id: 'thread', + link_id: 'inbox', + created_at: '2026-09-01T10:00:00Z', + updated_at: '2026-09-01T10:00:00Z', + internal_date_ts: '2026-09-01T10:00:00Z', + from: { email: 'sender@example.com' }, + to: [{ email: 'viewer@example.com' }], + cc: [], + bcc: [], + subject: 'Review', + body_html_sanitized: '

Hello

', + attachments: [], + attachments_draft: [], + attachments_forwarded: [], + labels: [], + is_draft: false, + ...overrides, + }; +} diff --git a/apps/web/src/features/email-message/views/email-message-body.tsx b/apps/web/src/features/email-message/views/email-message-body.tsx new file mode 100644 index 00000000000..fb8a74d6dd9 --- /dev/null +++ b/apps/web/src/features/email-message/views/email-message-body.tsx @@ -0,0 +1,74 @@ +import { StaticMarkdown } from '@core/component/LexicalMarkdown/component/core/StaticMarkdown'; +import { channelTheme } from '@core/component/LexicalMarkdown/theme'; +import DotsThree from '@phosphor/dots-three.svg'; +import { Button, cn } from '@ui'; +import { Match, Show, Switch } from 'solid-js'; + +import { useEmailRenderingContext } from '../context/email-rendering-context'; +import { + createEmailMessageBody, + type EmailMessageBodyProps, +} from '../primitives/email-message-body'; +export function EmailMessageBody(props: EmailMessageBodyProps) { + const { showFullHTML, setShowFullHTML, host, hasHiddenReplyStructure } = + createEmailMessageBody(props, useEmailRenderingContext()); + return ( +
{ + if (!props.isBodyExpanded() && props.message.db_id) { + props.setExpandedMessageBody(props.message.db_id); + props.setFocusedMessageId(props.message.db_id); + } else if (props.message.db_id) { + props.setFocusedMessageId(props.message.db_id); + } + }} + > +
+ + {/* If available, we use body_macro to render "Macro-fied" email content in static markdown with, e.g. correctly styled document mentions. */} + + {(bodyMacro) => { + return ( + + ); + }} + + + + + {host()} + + +
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/features/email-message/views/email-message.tsx b/apps/web/src/features/email-message/views/email-message.tsx new file mode 100644 index 00000000000..37a5107c738 --- /dev/null +++ b/apps/web/src/features/email-message/views/email-message.tsx @@ -0,0 +1,213 @@ +import { EmailAttachmentPill } from '@app/features/email-message/components/attachment-pill'; +import { CollapsedMessage } from '@app/features/email-message/components/collapsed-message'; +import { EmailMessageTopBar } from '@app/features/email-message/components/email-message-top-bar'; +import { MessageCard } from '@app/features/email-message/components/message-card'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; +import { EmailMessageBody } from '@app/features/email-message/views/email-message-body'; +import { ImageGalleryPreview } from '@core/component/ImageGalleryPreview'; +import { VideoPreview } from '@core/component/VideoPreview'; +import type { JSX } from 'solid-js'; +import { createMemo, createSignal, For, Show } from 'solid-js'; +import type { EmailMessageAction } from '../components/message-actions'; +import type { EmailAttachment } from '../core/email-message'; +export interface EmailMessageViewProps { + message: EmailMessage; + renderAvatar?: (message: EmailMessage) => JSX.Element; + viewerEmail?: string; + isTouch: boolean; + isPersonal: boolean; + showFullContent?: boolean; + isSelected: boolean; + allowHover: boolean; + isExpanded: boolean; + onExpand?: () => void; + onExpandedChange?: (expanded: boolean) => void; + onSelect?: () => void; + onHover?: () => void; + onUnhover?: () => void; + onFocus?: (element: HTMLElement) => void; + onReply?: (action: EmailMessageAction) => void; + onOpenAttachment?: (attachment: EmailAttachment) => void; + children?: JSX.Element; +} + +export function EmailMessageView(props: EmailMessageViewProps) { + const [expandedHeader, setExpandedHeader] = createSignal(false); + const isBodyExpanded = () => props.isExpanded; + + // Hide attachments that are referenced in inline images + const inlineContentIds = createMemo(() => { + const set = new Set(); + const html = props.message.body_html_sanitized ?? ''; + for (const match of html.matchAll(/src=["']cid:([^"']+)["']/gi)) { + const normalized = match[1].replace(/[<>]/g, '').trim(); + if (normalized) set.add(normalized); + } + return set; + }); + + const visibleAttachments = createMemo(() => { + return props.message.attachments.filter((a) => { + if (!a.db_id) return false; + const contentId = a.content_id?.toString(); + if (!contentId) return true; + const normalized = contentId.replace(/[<>]/g, '').trim(); + return !inlineContentIds().has(normalized); + }); + }); + + const imageAttachmentsWithSfs = createMemo(() => { + return visibleAttachments().filter( + (a) => a.mime_type?.startsWith('image/') && a.sfs_id + ); + }); + + const videoAttachmentsWithSfs = createMemo(() => { + return visibleAttachments().filter( + (a) => a.mime_type?.startsWith('video/') && a.sfs_id + ); + }); + + const otherAttachments = createMemo(() => { + return visibleAttachments().filter( + (a) => + !a.sfs_id || + (!a.mime_type?.startsWith('image/') && + !a.mime_type?.startsWith('video/')) + ); + }); + + return ( + + + } + > +
+ + props.onExpandedChange?.(expanded) + } + isBodyExpanded={isBodyExpanded} + expandedHeader={expandedHeader} + setExpandedHeader={setExpandedHeader} + setFocusedMessageId={() => props.onSelect?.()} + onReply={props.onReply} + isTouch={props.isTouch} + viewerEmail={props.viewerEmail} + hiddenActions={ + props.onReply ? undefined : ['reply', 'reply-all', 'forward'] + } + avatar={ +
+ {props.renderAvatar?.(props.message)} +
+ } + /> +
+ props.onExpandedChange?.(true)} + setFocusedMessageId={() => props.onSelect?.()} + showFullContent={props.showFullContent} + isFocused={props.isSelected} + /> +
+ {/* Image attachments */} + 0}> +
+ ({ + id: a.sfs_id!, + }))} + variant="small" + attachmentIds={imageAttachmentsWithSfs().map((a) => a.db_id!)} + /> +
+
+ + {/* Video attachments */} + 0}> + + {(attachment) => ( + + )} + + + + {/* Other attachments (non-media or without sfs_id) */} + 0}> +
+ + {(attachment) => ( + props.onOpenAttachment?.(attachment) + : undefined + } + /> + )} + +
+
+ + {/* Draft attachments */} + 0 || + props.message.attachments_forwarded.length > 0 + } + > +
+ + {(attachment) => ( + + )} + + + {(attachment) => ( + + )} + +
+
+
+ {props.children} +
+
+ ); +} diff --git a/apps/web/src/features/block-email/component/CopySubjectButton.tsx b/apps/web/src/features/email-thread/components/copy-subject-button.tsx similarity index 78% rename from apps/web/src/features/block-email/component/CopySubjectButton.tsx rename to apps/web/src/features/email-thread/components/copy-subject-button.tsx index adc00074473..78209860f6c 100644 --- a/apps/web/src/features/block-email/component/CopySubjectButton.tsx +++ b/apps/web/src/features/email-thread/components/copy-subject-button.tsx @@ -1,10 +1,9 @@ -import { toast } from '@core/component/Toast/Toast'; +import { isPlaceholderSubject } from '@app/features/email-compose/core/subject-text'; import WideCopy from '@icon/wide-copy.svg'; import IconCheck from '@phosphor/check.svg'; import { debounce } from '@solid-primitives/scheduled'; import { cn } from '@ui/utils/classname'; import { createSignal, Show } from 'solid-js'; -import { isPlaceholderSubject } from '../util/subjectText'; function copyableSubject(title: string): string | undefined { const subject = title.trim(); @@ -12,7 +11,11 @@ function copyableSubject(title: string): string | undefined { return subject; } -export function CopySubjectButton(props: { subject: string; class?: string }) { +export function CopySubjectButton(props: { + subject: string; + class?: string; + onCopy?: (subject: string) => void; +}) { const [copied, setCopied] = createSignal(false); const resetCopied = debounce(() => setCopied(false), 800); @@ -20,14 +23,13 @@ export function CopySubjectButton(props: { subject: string; class?: string }) { e.stopPropagation(); const subject = copyableSubject(props.subject); if (!subject) return; - navigator.clipboard.writeText(subject); - toast.success('Subject copied'); + props.onCopy?.(subject); setCopied(true); resetCopied(); } return ( - + + + + ); + }, +})); + +function ThreadTestProvider(props: { + messages: EmailMessage[]; + children: (state: EmailThreadState) => JSX.Element; +}) { + const context = createThreadContext({ thread: () => thread(props.messages) }); + const state = createEmailThreadState(context); + return ( + + + {props.children(state)} + + + ); +} + +it('preserves an engaged composer through a same-message update but resets it for a different reply target', () => { + const first = message('first'); + const second = message('second'); + const [target, setTarget] = createSignal(first); + const view = render(() => ( + + {() => } + + )); + try { + view.getByText('first').click(); + setTarget({ ...first, updated_at: '2026-09-05T00:00:00Z' }); + expect(lifecycle.mounted).toEqual(['first']); + setTarget(second); + expect(lifecycle.mounted).toEqual(['first', 'second']); + expect(lifecycle.disposed).toEqual(['first']); + } finally { + view.unmount(); + } +}); + +it('returns focus to the owning thread when split panes contain the same message', () => { + vi.stubGlobal('CSS', { escape: (value: string) => value }); + const parent = message('shared-message'); + const Pane = () => ( + + {(state) => ( +
+
+
+
+ parent} /> +
+ )} + + ); + const view = render(() => ( + <> + + + + )); + try { + view.getAllByText('Exit reply')[1].click(); + expect(document.activeElement).toBe(view.getAllByTestId('card')[1]); + view.getAllByText('Exit reply')[0].click(); + expect(document.activeElement).toBe(view.getAllByTestId('card')[0]); + } finally { + view.unmount(); + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/src/features/email-thread/views/thread-reply-input.tsx b/apps/web/src/features/email-thread/views/thread-reply-input.tsx new file mode 100644 index 00000000000..9d6c82fc708 --- /dev/null +++ b/apps/web/src/features/email-thread/views/thread-reply-input.tsx @@ -0,0 +1,162 @@ +import { decodeBase64Utf8 } from '@app/features/email-compose/core/decode-base64'; +import { plainTextToHtml } from '@app/features/email-compose/core/plain-text-to-html'; +import { ReplyInputView } from '@app/features/email-compose/views/reply-input'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; +import { Layer } from '@ui'; +import { + type Accessor, + createMemo, + createSignal, + type Setter, + Show, +} from 'solid-js'; +import { isPersonalMessage } from '../../email-message/core/is-personal-message'; +import { useEmailThreadState } from '../context/email-thread-state-context'; +import { useEmailThreadViewContext } from '../context/email-thread-view-context'; +import { revealMessageAfterLayout } from '../primitives/scroll-to-message'; + +interface ThreadReplyInputProps { + replyingTo: Accessor; + draft?: EmailMessage; + setShowReply?: Setter; + markdownDomRef?: (ref: HTMLDivElement) => void | HTMLDivElement; + unframed?: boolean; + mobileDrawer?: { + onClose: () => void; + }; +} + +/** A reply target owns one editor/form lifetime; changing targets must reset the draft latch. */ +export function ThreadReplyInput(props: ThreadReplyInputProps) { + return ( + + {(_identity) => } + + ); +} + +function ThreadReplyInputSession(props: ThreadReplyInputProps) { + const ctx = useEmailThreadState(); + const viewContext = useEmailThreadViewContext(); + + // The seed identity of this composer: which version of which draft it + // mounts from. When the server sends a newer save of that draft (a thread + // opened from a cached snapshot revalidates, or the draft was edited on + // another device), the key changes and the input remounts, seeding from + // the newer draft through the ordinary mount path — but only until the + // user engages with the composer. From then on the mounted instance is + // authoritative (later fetches are typically echoes of its own saves), so + // the key latches and the input never remounts underneath the user. + const [engaged, setEngaged] = createSignal(false); + const seedKey = createMemo((prev) => + engaged() && prev !== undefined + ? prev + : props.draft + ? `${props.draft.db_id}:${props.draft.updated_at}` + : 'no-draft' + ); + + const draftHTML = createMemo(() => { + const encoded = props.draft?.body_html_sanitized; + if (!encoded) { + const plainText = props.draft?.body_text; + if (!plainText) return ''; + return plainTextToHtml(plainText); + } + const decodedHtml = decodeBase64Utf8(encoded); + return decodedHtml; + }); + + async function afterSend(newMessageId: string | null) { + // Collapse the input after sending (Gmail-style). + props.setShowReply?.(false); + + if (!newMessageId) return; + + ctx.messages.setFocused(newMessageId); + await ctx.query.refetch(); + revealMessageAfterLayout( + newMessageId, + ctx.messages.list(), + ctx.messagesListRef() + ); + } + + return ( + + + {(seed) => ( + + { + const message = props.replyingTo(); + return ( + !!message && + isPersonalMessage( + message, + viewContext.thread.viewerEmail(), + ctx.messages.personalSenders() + ) + ); + }, + onDraftRemoved: () => { + const id = props.replyingTo()?.db_id; + if (id) ctx.drafts.deleteDraftForMessage(id); + }, + replyRequest: { + replyType: () => + ctx.replyRequest.messageId() === props.replyingTo()?.db_id + ? ctx.replyRequest.replyType() + : undefined, + clear: ctx.replyRequest.clear, + }, + getMarkDoneNavigationTargetId: + ctx.getMarkDoneNavigationTargetId, + exitToThread: (target) => { + const id = + target === 'last' + ? ctx.messages.list().at(-1)?.db_id + : ctx.messages.focusedId(); + if (!id) return false; + ctx.messages.setFocused(id); + const message = ctx + .messagesContainerRef() + ?.querySelector( + `[data-message-body-id="${CSS.escape(id)}"]` + ); + const card = message?.closest('[tabindex="0"]'); + card?.focus(); + return !!card; + }, + }} + sourceEntityId={ + ctx.thread()?.db_id ?? + props.replyingTo()?.thread_db_id ?? + props.draft?.thread_db_id ?? + '' + } + replyingTo={props.replyingTo} + draft={props.draft} + preloadedHtml={draftHTML()} + formSeed={seed} + onEngaged={() => setEngaged(true)} + sideEffectOnSend={afterSend} + onMarkDone={ctx.archiveThread} + setShowReply={props.setShowReply} + markdownDomRef={props.markdownDomRef} + unframed={props.unframed} + mobileDrawer={props.mobileDrawer} + isEditingExisting={ + props.replyingTo() == null && props.draft != null + } + /> + + )} + + + ); +} diff --git a/apps/web/src/features/next-soup/utils.ts b/apps/web/src/features/next-soup/utils.ts index 6e88332d318..1c7c98670c7 100644 --- a/apps/web/src/features/next-soup/utils.ts +++ b/apps/web/src/features/next-soup/utils.ts @@ -1,4 +1,5 @@ import { isListViewID } from '@app/constants/list-views'; +import { URL_PARAMS as EMAIL_PARAMS } from '@app/features/email-thread/core/location'; import { scopeChannelNotificationsForEntity } from '@app/features/soup/entity-notifications'; import { globalSplitManager } from '@app/signal/splitLayout'; import { createCalendarBlockRange } from '@block-calendar/calendar-range'; @@ -13,7 +14,6 @@ import { goToChannelLatest, goToChannelMessage, } from '@block-channel/utils/link'; -import { URL_PARAMS as EMAIL_PARAMS } from '@block-email/constants'; import { URL_PARAMS as MD_PARAMS } from '@block-md/constants'; import { URL_PARAMS as PDF_PARAMS } from '@block-pdf/constants'; import type { diff --git a/apps/web/src/lib/core/component/AI/component/tool/email/ChatCompose.tsx b/apps/web/src/lib/core/component/AI/component/tool/email/ChatCompose.tsx index c037bd9f39e..f61fa45cb14 100644 --- a/apps/web/src/lib/core/component/AI/component/tool/email/ChatCompose.tsx +++ b/apps/web/src/lib/core/component/AI/component/tool/email/ChatCompose.tsx @@ -7,7 +7,7 @@ * (`callTool`) and writes the sent outcome back into the chat's message. */ -import type { EmailRecipient } from '@block-email/component/EmailContext'; +import type { EmailRecipient } from '@app/features/email-compose/core/email-recipient'; import { useChatContext } from '@core/component/AI/context'; import type { AssistantMessagePart } from '@core/component/AI/types'; import { toast } from '@core/component/Toast/Toast'; @@ -30,6 +30,7 @@ type ComposeToolProps = { }; type SendEmailSnapshot = { + body: string; bcc: Array<{ email: string; name: string | null }>; cc: Array<{ email: string; name: string | null }>; includeSignature: boolean | null; @@ -40,6 +41,7 @@ type SendEmailSnapshot = { function createSendEmailSnapshot(data: SendEmail): SendEmailSnapshot { return { + body: data.body ?? '', to: (data.to ?? []).map((item) => ({ email: item.email, name: item.name ?? null, @@ -229,6 +231,11 @@ export function ComposeTool(props: ComposeToolProps) { { + // Opening a Markdown draft must not count as a user edit merely + // because the editor imports it into its HTML representation. + lastPersistedSnapshot.body = bodyHtml; + }} recipientOptions={props.recipientOptions} header={props.header} readOnly={props.readOnly} diff --git a/apps/web/src/lib/core/component/AI/component/tool/email/DraftComposer.tsx b/apps/web/src/lib/core/component/AI/component/tool/email/DraftComposer.tsx index aecc49118b1..e73041a92af 100644 --- a/apps/web/src/lib/core/component/AI/component/tool/email/DraftComposer.tsx +++ b/apps/web/src/lib/core/component/AI/component/tool/email/DraftComposer.tsx @@ -8,23 +8,28 @@ * knows how to edit the draft and what Send means. */ +import { SignaturePreview } from '@app/features/email-compose/components/signature-preview'; +import { ComposeProvider } from '@app/features/email-compose/context/compose-context'; +import { decodeBase64Utf8 } from '@app/features/email-compose/core/decode-base64'; +import type { EmailRecipient } from '@app/features/email-compose/core/email-recipient'; +import { convertContactInfoToEmailRecipient } from '@app/features/email-compose/core/recipient-conversion'; +import { createComposeBodyActions } from '@app/features/email-compose/editor-adapter'; +import type { + ComposeContextValue, + ComposeValidationError, +} from '@app/features/email-compose/primitives/compose-view-state'; +import type { DraftFormAttachment } from '@app/features/email-compose/primitives/email-form-state'; +import { prepareEmailBody } from '@app/features/email-compose/primitives/prepare-email-body'; +import { ComposeLayout } from '@app/features/email-compose/views/compose-layout'; +import { EmailComposeToolbar } from '@app/features/email-compose/views/compose-toolbar'; import { useFeatureFlag } from '@app/lib/analytics/posthog'; +import { toast } from '@core/component/Toast/Toast'; import { - ComposeLayout, - EmailComposeToolbar, -} from '@block-email/component/compose'; -import { - type ComposeContextValue, - ComposeProvider, - type ComposeValidationError, -} from '@block-email/component/compose/ComposeContext'; -import { SignaturePreview } from '@block-email/component/compose/SignaturePreview'; -import type { DraftFormAttachment } from '@block-email/component/createEmailFormState'; -import type { EmailRecipient } from '@block-email/component/EmailContext'; -import { decodeBase64Utf8 } from '@block-email/util/decodeBase64'; -import { prepareEmailBody } from '@block-email/util/prepareEmailBody'; -import { convertContactInfoToEmailRecipient } from '@block-email/util/recipientConversion'; -import { enableEmailSignatures } from '@core/constant/featureFlags'; + ENABLE_EMAIL_SCHEDULED_SEND, + enableEmailSignatures, +} from '@core/constant/featureFlags'; +import { isMobile } from '@core/mobile/isMobile'; +import { interceptMailtoLinks } from '@core/util/interceptMailtoLinks'; import { useEmailLinksQuery, useEmailSignature } from '@queries/email/link'; import type { SendEmail } from '@service-cognition/generated/tools/types'; import { debounce } from '@solid-primitives/scheduled'; @@ -46,6 +51,8 @@ export type EmailDraftComposerProps = { readOnly?: boolean; /** Suffix for the body editor's debug name, unique per draft. */ debugName: string; + /** The imported body in the same encoding used for subsequent edits. */ + onBodyInitialized?: (bodyHtml: string) => void; }; function toEmailRecipients( @@ -70,7 +77,10 @@ export function EmailDraftComposer(props: EmailDraftComposerProps) { const emailLinksQuery = useEmailLinksQuery(); // The inbox this card sends from — always the first linked inbox (shown as // "from"); the backend resolves the same default at send time. - const sendingLink = createMemo(() => emailLinksQuery.data?.links?.[0]); + const sendingLink = createMemo(() => { + if (!emailLinksQuery.isSuccess && !emailLinksQuery.isError) return; + return emailLinksQuery.data?.links?.[0]; + }); const fromAddress = () => sendingLink()?.email_address; const signature = useEmailSignature(() => sendingLink()?.id); const emailSignaturesFlag = useFeatureFlag(enableEmailSignatures); @@ -191,6 +201,10 @@ export function EmailDraftComposer(props: EmailDraftComposerProps) { }; const ctx: ComposeContextValue = { + bodyActions: createComposeBodyActions(), + isMobile, + scheduleEnabled: ENABLE_EMAIL_SCHEDULED_SEND, + attachmentFailure: toast.failure, subject, attachments: () => [], sendTime: () => undefined, @@ -210,6 +224,9 @@ export function EmailDraftComposer(props: EmailDraftComposerProps) { onAddAttachments: (_: DraftFormAttachment[]) => {}, onRemoveAttachment: (_: DraftFormAttachment) => {}, captureEditor: setEditor, + onEditorInitialized: (editor) => { + props.onBodyInitialized?.(prepareEmailBody(editor)?.bodyHtml ?? ''); + }, onSend: handleSend, disabled: () => isSending() || uiDisabled(), isSending, @@ -234,6 +251,8 @@ export function EmailDraftComposer(props: EmailDraftComposerProps) { {(html) => ( { setIncludeSignature(false); diff --git a/apps/web/src/lib/core/component/LexicalMarkdown/utils.ts b/apps/web/src/lib/core/component/LexicalMarkdown/utils.ts index 409761db62c..266012f1117 100644 --- a/apps/web/src/lib/core/component/LexicalMarkdown/utils.ts +++ b/apps/web/src/lib/core/component/LexicalMarkdown/utils.ts @@ -1,6 +1,5 @@ import { ENABLE_MARKDOWN_SEARCH_TEXT } from '@core/constant/featureFlags'; import { $isCodeNode } from '@lexical/code'; -import { $generateNodesFromDOM } from '@lexical/html'; import { $createListItemNode, $createListNode, @@ -45,7 +44,6 @@ import { $getRoot, $getSelection, $insertNodes, - $isDecoratorNode, $isElementNode, $isLineBreakNode, $isParagraphNode, @@ -361,61 +359,7 @@ export function setEditorStateFromMarkdown( } } -/** - * Set the editor state from an HTML string. - * Uses Lexical's DOM import utilities to parse and insert nodes. - * Mirrors the behavior of setEditorStateFromMarkdown by updating inside - * an editor.update unless inUpdate is true. - */ -/** - * The root only accepts block nodes. HTML whose top level is bare text or - * inline elements (a plain-text string, `a
b`, a lone link) would otherwise - * make `root.append` throw and the whole import be discarded, so runs of - * inline nodes are gathered into paragraphs first. - */ -function $wrapInlineTopLevelNodes(nodes: LexicalNode[]): LexicalNode[] { - const wrapped: LexicalNode[] = []; - let paragraph: ParagraphNode | undefined; - for (const node of nodes) { - const isBlock = - ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline(); - if (isBlock) { - paragraph = undefined; - wrapped.push(node); - continue; - } - if (!paragraph) { - paragraph = $createParagraphNode(); - wrapped.push(paragraph); - } - paragraph.append(node); - } - return wrapped; -} - -function $replaceRootFromHtml(editor: LexicalEditor, html: string) { - const dom = new DOMParser().parseFromString(html, 'text/html'); - const nodes = $wrapInlineTopLevelNodes($generateNodesFromDOM(editor, dom)); - const root = $getRoot(); - root.clear(); - root.append(...nodes); -} - -export function setEditorStateFromHtml( - editor: LexicalEditor, - html: string, - inUpdate = false -) { - if (!inUpdate) { - editor.update(() => { - $replaceRootFromHtml(editor, html); - }); - editor.read(() => {}); - return editor.getEditorState(); - } else { - $replaceRootFromHtml(editor, html); - } -} +export { setEditorStateFromHtml } from './utils/setEditorStateFromHtml'; function $isEmpty() { const root = $getRoot(); diff --git a/apps/web/src/lib/core/component/LexicalMarkdown/utils/setEditorStateFromHtml.ts b/apps/web/src/lib/core/component/LexicalMarkdown/utils/setEditorStateFromHtml.ts new file mode 100644 index 00000000000..3db4ffcabf1 --- /dev/null +++ b/apps/web/src/lib/core/component/LexicalMarkdown/utils/setEditorStateFromHtml.ts @@ -0,0 +1,66 @@ +import { $generateNodesFromDOM } from '@lexical/html'; +import { + $createParagraphNode, + $getRoot, + $isDecoratorNode, + $isElementNode, + type LexicalEditor, + type LexicalNode, + type ParagraphNode, +} from 'lexical'; + +/** + * Set the editor state from an HTML string. + * Uses Lexical's DOM import utilities to parse and insert nodes. + * Mirrors the behavior of setEditorStateFromMarkdown by updating inside + * an editor.update unless inUpdate is true. + */ +/** + * The root only accepts block nodes. HTML whose top level is bare text or + * inline elements (a plain-text string, `a
b`, a lone link) would otherwise + * make `root.append` throw and the whole import be discarded, so runs of + * inline nodes are gathered into paragraphs first. + */ +function $wrapInlineTopLevelNodes(nodes: LexicalNode[]): LexicalNode[] { + const wrapped: LexicalNode[] = []; + let paragraph: ParagraphNode | undefined; + for (const node of nodes) { + const isBlock = + ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline(); + if (isBlock) { + paragraph = undefined; + wrapped.push(node); + continue; + } + if (!paragraph) { + paragraph = $createParagraphNode(); + wrapped.push(paragraph); + } + paragraph.append(node); + } + return wrapped; +} + +function $replaceRootFromHtml(editor: LexicalEditor, html: string) { + const dom = new DOMParser().parseFromString(html, 'text/html'); + const nodes = $wrapInlineTopLevelNodes($generateNodesFromDOM(editor, dom)); + const root = $getRoot(); + root.clear(); + root.append(...nodes); +} + +export function setEditorStateFromHtml( + editor: LexicalEditor, + html: string, + inUpdate = false +) { + if (!inUpdate) { + editor.update(() => { + $replaceRootFromHtml(editor, html); + }); + editor.read(() => {}); + return editor.getEditorState(); + } else { + $replaceRootFromHtml(editor, html); + } +} diff --git a/apps/web/src/lib/core/email/index.ts b/apps/web/src/lib/core/email/index.ts index 829568e7ea0..b0ca17b8fc1 100644 --- a/apps/web/src/lib/core/email/index.ts +++ b/apps/web/src/lib/core/email/index.ts @@ -1,15 +1,7 @@ -// Email body parsing utilities -export { - parseEmailContent, - sanitizeEmailHtml, - scrubActiveContent, - stripColorSchemeMediaQueries, -} from './parse-email-html'; - -// Image proxy utilities - -// Color transformation utilities +// Compatibility entry point for the editor's HTML decorator and quoted replies. +// The implementation lives in the framework-independent renderer package. +export { stripColorSchemeMediaQueries } from '@macro-inc/email-renderer'; export { processEmailColors, type ThemeColorParams, -} from './transform-email-colors'; +} from '@macro-inc/email-renderer/browser'; diff --git a/apps/web/src/lib/core/email/parse-email-html.ts b/apps/web/src/lib/core/email/parse-email-html.ts deleted file mode 100644 index 064e8bb2312..00000000000 --- a/apps/web/src/lib/core/email/parse-email-html.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { ENABLE_PROXY_EMAIL_IMAGES } from '../constant/featureFlags'; -import { proxyEmailImages } from './proxy-email-images'; - -/** - * Strips @media (prefers-color-scheme: ...) rules from CSS content. - * This prevents email dark mode styles from conflicting with our forced backgrounds. - */ -export function stripColorSchemeMediaQueries(cssContent: string): string { - try { - const sheet = new CSSStyleSheet(); - sheet.replaceSync(cssContent); - - const filteredRules: string[] = []; - for (const rule of Array.from(sheet.cssRules)) { - if (rule instanceof CSSMediaRule) { - if (rule.conditionText?.includes('prefers-color-scheme')) { - continue; - } - } - filteredRules.push(rule.cssText); - } - return filteredRules.join('\n'); - } catch { - // Fallback if parsing fails - return cssContent; - } -} - -export function trimTrailingBrs(element: Element) { - function removeTrailingContent(): boolean { - let removedSomething = false; - let currentElement: Element = element; - - // Follow the rightmost path down the tree - while (true) { - let lastChild = currentElement.lastChild; - - // Remove empty text nodes and br elements from the end - while (lastChild) { - if (lastChild.nodeType === Node.TEXT_NODE) { - if (lastChild.textContent?.trim() === '') { - // Remove empty text node - currentElement.removeChild(lastChild); - lastChild = currentElement.lastChild; - removedSomething = true; - } else { - // Found meaningful text content, stop - return removedSomething; - } - } else if (lastChild.nodeType === Node.ELEMENT_NODE) { - const lastElement = lastChild as Element; - const tag = lastElement.tagName.toLowerCase(); - if (tag === 'br') { - // Remove br element - currentElement.removeChild(lastChild); - lastChild = currentElement.lastChild; - removedSomething = true; - } else if (tag === 'img') { - return removedSomething; - } else { - // Found a non-br element, go deeper - currentElement = lastElement; - break; - } - } else { - return removedSomething; - } - } - - // If we removed all children, this element is now empty - if (!currentElement.lastChild) { - // If this is a meaningful leaf like , stop - if ((currentElement as Element).tagName?.toLowerCase() === 'img') { - return removedSomething; - } - // If this is the root element, we're done - if (currentElement === element) { - break; - } - // Otherwise, remove this empty element and go back up - const parent = currentElement.parentElement; - if (parent) { - parent.removeChild(currentElement); - currentElement = parent; - removedSomething = true; - } else { - break; - } - } - } - - return removedSomething; - } - - // Keep removing until no more changes are made - let changed = true; - while (changed) { - changed = removeTrailingContent(); - } - - return element; -} - -// Splits a trailing signature out of the body so the renderer can collapse it -// behind the "…" expander. Recognizes Gmail's `.gmail_signature` as well as the -// `.macro-email-signature` wrapper the backend injects into outgoing mail -// (our own signatures aren't otherwise tagged as Gmail's). -function parseGmailSignature(htmlElement: Element) { - const signaturePrefix = htmlElement.querySelector('.gmail_signature_prefix'); - const signatureElement = htmlElement.querySelector( - '.gmail_signature, .macro-email-signature' - ); - - if (signatureElement) { - const signature = signatureElement?.outerHTML; - signatureElement?.remove(); - signaturePrefix?.remove(); - - return { - mainContent: htmlElement.innerHTML, - signature: signature, - }; - } - - return { - mainContent: htmlElement.innerHTML, - signature: null, - }; -} - -/** Elements that can execute or embed active content, and are never legitimate - * email body markup. `svg` and `math` are here because their subtrees can - * mutate an attribute after we validate it (``), - * so a per-attribute check is not enough — matches the backend allowlist, - * which drops both. */ -const ACTIVE_ELEMENTS = - 'script,iframe,frame,frameset,object,embed,applet,base,meta,link,noscript,template,svg,math'; - -/** Attributes carrying a URL that must be scheme-checked. */ -const URL_ATTRIBUTES = ['href', 'src', 'action', 'background', 'poster']; - -/** Schemes the backend sanitizer allows; anything else is dropped. */ -const SAFE_SCHEMES = ['http:', 'https:', 'mailto:', 'cid:', 'tel:', 'sms:']; - -function isSafeUrl(value: string): boolean { - // Strip the whitespace and control characters browsers tolerate inside URLs - // ("java\nscript:") before looking for a scheme. - const trimmed = Array.from(value) - .filter((char) => char.charCodeAt(0) > 0x20) - .join(''); - const scheme = /^[a-z][a-z0-9+.-]*:/i.exec(trimmed)?.[0]?.toLowerCase(); - // No scheme means relative, anchor, or protocol-relative — all inert. - if (!scheme) return true; - // Inline images are inert as an source; other data: URLs are not. - if (trimmed.toLowerCase().startsWith('data:image/')) return true; - return SAFE_SCHEMES.includes(scheme); -} - -/** - * Removes script-capable markup from a freshly parsed, still-inert document. - * - * Defence in depth for the `innerHTML` render path: the backend sanitizes every - * body it writes today, but rows stored before that landed — and anything a - * future write path forgets — would otherwise execute in the reader's page. - * Must run on a `DOMParser` document, which neither executes scripts nor loads - * resources; scrubbing after an `innerHTML` assignment is already too late - * because `` fires on a detached element. - */ -export function scrubActiveContent(doc: Document) { - for (const element of Array.from(doc.querySelectorAll(ACTIVE_ELEMENTS))) { - element.remove(); - } - - for (const element of Array.from(doc.querySelectorAll('*'))) { - for (const name of element.getAttributeNames()) { - const lowered = name.toLowerCase(); - if ( - lowered.startsWith('on') || - lowered === 'srcdoc' || - lowered === 'formaction' || - lowered.endsWith(':href') - ) { - element.removeAttribute(name); - continue; - } - if ( - URL_ATTRIBUTES.includes(lowered) && - !isSafeUrl(element.getAttribute(name) ?? '') - ) { - element.removeAttribute(name); - } - } - } -} - -/** - * Parses `html`, scrubs it with {@link scrubActiveContent}, and re-serializes. - * - * For the paths that hand a stored body straight to another renderer (the - * composer's quoted reply, the html-render node) rather than going through - * {@link parseEmailContent}. - */ -export function sanitizeEmailHtml(html: string): string { - const doc = new DOMParser().parseFromString(html, 'text/html'); - scrubActiveContent(doc); - return doc.documentElement.innerHTML; -} - -interface ParsedEmailContent { - mainContent: string; - signature: string | null; - hasTable: boolean; -} - -export function parseEmailContent( - htmlContent: string, - removeSignature: boolean = true, - removeTrailingBrs: boolean = true -): ParsedEmailContent { - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlContent, 'text/html'); - - // Scrub while the document is still inert — everything below round-trips - // through innerHTML on the live document. - scrubActiveContent(doc); - - const hasTable = Boolean(doc.querySelector('table')); - - // Extract style tags from head, stripping prefers-color-scheme media queries - // to prevent email dark mode styles from conflicting with our forced backgrounds - const styleTags = Array.from(doc.head?.querySelectorAll('style') ?? []) - .map((style) => { - const filtered = stripColorSchemeMediaQueries(style.textContent ?? ''); - return filtered ? `` : ''; - }) - .filter(Boolean) - .join('\n'); - - let mainContent = doc.body?.innerHTML ?? doc.documentElement?.innerHTML; - let signature: string | null = null; - - if (removeSignature) { - const { mainContent: signatureMainContent, signature: signatureContent } = - parseGmailSignature(doc.body ?? doc.documentElement); - mainContent = signatureMainContent; - signature = signatureContent; - } - - // Trim trailing
elements from main content - const mainContentDiv = document.createElement('div'); - mainContentDiv.innerHTML = mainContent; - - if (removeTrailingBrs) { - trimTrailingBrs(mainContentDiv); - } - - // Prepend style tags to the main content - const finalContent = styleTags - ? `${styleTags}\n${mainContentDiv.innerHTML}` - : mainContentDiv.innerHTML; - - if (ENABLE_PROXY_EMAIL_IMAGES) { - mainContent = proxyEmailImages(finalContent); - signature = signature ? proxyEmailImages(signature) : null; - } else { - mainContent = finalContent; - } - - return { - mainContent, - signature, - hasTable, - }; -} diff --git a/apps/web/src/lib/core/email/proxy-email-images.ts b/apps/web/src/lib/core/email/proxy-email-images.ts deleted file mode 100644 index ae744d235f7..00000000000 --- a/apps/web/src/lib/core/email/proxy-email-images.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { proxyImageUrl } from '../util/imageProxy'; - -/** - * Serves images through image proxy service to avoid storing data. - * Rewrites external `` src attributes in HTML to route through the image proxy service. - * Skips non-HTTP(S) schemes (e.g. `data:`). - */ -export function proxyEmailImages(html: string): string { - const container = document.createElement('div'); - container.innerHTML = html; - - const images = container.querySelectorAll('img[src]'); - for (const img of images) { - const src = img.getAttribute('src')?.replace(/\s/g, ''); - if (!src) continue; - const proxied = proxyImageUrl(src); - if (proxied === src) continue; - - img.setAttribute('src', proxied); - } - - return container.innerHTML; -} diff --git a/apps/web/src/lib/core/email/tests/email-rendering.pw.ts b/apps/web/src/lib/core/email/tests/email-rendering.pw.ts deleted file mode 100644 index 55efdcd8a62..00000000000 --- a/apps/web/src/lib/core/email/tests/email-rendering.pw.ts +++ /dev/null @@ -1,204 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { expect, type Page, test } from '@playwright/test'; -import { DEFAULT_THEMES } from '@theme/constants'; -import { EMAIL_BODY_CONTAINMENT_CSS } from '../../../../features/block-email/util/emailBodyContainmentCss'; -import { fitToWidthZoom } from '../../../../features/block-email/util/fitToWidthZoom'; - -/** - * Email fixture format - matches the structure from email service. - * - * ## Adding a new fixture: - * 1. Create a JSON file in the fixtures/ directory (e.g., `my-email.json`) - * 2. Copy `body_html_sanitized` from an email API response into the file - * 3. From the repo root, run `just test-email-rendering-update` - * 4. Commit the fixture and snapshots - * - * Optional `container_widths` (default `[600]`) snapshots extra pane sizes. - * Use that only when a fixture needs a width the default pane cannot prove. - * - * ## Example fixture file: - * ```json - * { - * "name": "outlook-signature", - * "description": "Email with Outlook signature formatting", - * "body_html_sanitized": "

Hello...

...
" - * } - * ``` - */ -interface EmailFixture { - /** Unique name for the fixture (used in snapshot filenames) */ - name: string; - /** Description of what this fixture tests */ - description: string; - /** The sanitized HTML body - copy directly from email service `body_html_sanitized` field */ - body_html_sanitized: string; - /** Pane widths in CSS pixels. Omit to use the default 600px reader pane. */ - container_widths?: number[]; -} - -/** Themes to test - uses actual Macro theme definitions */ -const THEMES = ['Macro Dark', 'Macro Light'] as const; - -const DEFAULT_CONTAINER_WIDTH = 600; - -function containerWidths(fixture: EmailFixture): number[] { - return fixture.container_widths ?? [DEFAULT_CONTAINER_WIDTH]; -} - -function generateThemeCSS(themeName: string): string { - const theme = DEFAULT_THEMES.find((t) => t.name === themeName); - if (!theme) return ''; - - const vars = Object.entries(theme.colorTokens) - .map(([key, value]) => `--color-${key}: ${value};`) - .join('\n '); - - return `:root {\n ${vars}\n }`; -} - -function createTestHTML(args: { - themeName: string; - containerWidth: number; -}): string { - const themeCSS = generateThemeCSS(args.themeName); - - return ` - - - - - - - - - -`; -} - -function loadFixtures(): EmailFixture[] { - const fixturesDir = path.join(import.meta.dirname, 'fixtures'); - - if (!fs.existsSync(fixturesDir)) { - return []; - } - - const files = fs.readdirSync(fixturesDir).filter((f) => f.endsWith('.json')); - - return files.map((file) => { - const content = fs.readFileSync(path.join(fixturesDir, file), 'utf-8'); - return JSON.parse(content) as EmailFixture; - }); -} - -function snapshotName(args: { - fixtureName: string; - themeName: string; - containerWidth: number; -}): string { - const themeSuffix = args.themeName.toLowerCase().replace(/\s+/g, '-'); - if (args.containerWidth === DEFAULT_CONTAINER_WIDTH) { - return `${args.fixtureName}-${themeSuffix}.png`; - } - return `${args.fixtureName}-${args.containerWidth}-${themeSuffix}.png`; -} - -async function mountEmailBody(args: { - page: Page; - html: string; -}): Promise { - await args.page.locator('.email-host').evaluate( - (host, next) => { - const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' }); - root.replaceChildren(); - const styleEl = document.createElement('style'); - styleEl.textContent = next.css; - const messageDiv = document.createElement('div'); - messageDiv.innerHTML = next.html; - root.append(styleEl, messageDiv); - }, - { css: EMAIL_BODY_CONTAINMENT_CSS, html: args.html } - ); -} - -async function applyFitToWidth(page: Page): Promise { - const host = page.locator('.email-host'); - const measured = await host.evaluate((el) => { - const message = el.shadowRoot?.querySelector('div'); - if (!(message instanceof HTMLElement)) { - return undefined; - } - return { - containerWidth: el.clientWidth, - contentWidth: message.scrollWidth, - }; - }); - if (!measured) return; - const fit = fitToWidthZoom(measured); - if (!fit) return; - await host.evaluate((el, next) => { - const message = el.shadowRoot?.querySelector('div'); - if (!(message instanceof HTMLElement)) return; - message.style.zoom = String(next.zoom); - if (next.overflowsAfterZoom) { - message.style.overflowX = 'auto'; - } - }, fit); -} - -const fixtures = loadFixtures(); - -test.describe('Email Rendering', () => { - for (const fixture of fixtures) { - test(fixture.name, async ({ page }, testInfo) => { - for (const containerWidth of containerWidths(fixture)) { - await page.setViewportSize({ - width: containerWidth + 64, - height: 800, - }); - - for (const themeName of THEMES) { - await page.setContent(createTestHTML({ themeName, containerWidth })); - await mountEmailBody({ - page, - html: fixture.body_html_sanitized, - }); - await page.waitForLoadState('networkidle'); - await applyFitToWidth(page); - - const screenshot = await page.screenshot(); - await testInfo.attach(`${themeName}-${containerWidth}`, { - body: screenshot, - contentType: 'image/png', - }); - - await expect(page).toHaveScreenshot( - snapshotName({ - fixtureName: fixture.name, - themeName, - containerWidth, - }) - ); - } - } - }); - } -}); diff --git a/apps/web/src/lib/core/email/tests/fixtures/github-pr-review.json b/apps/web/src/lib/core/email/tests/fixtures/github-pr-review.json deleted file mode 100644 index d9625888307..00000000000 --- a/apps/web/src/lib/core/email/tests/fixtures/github-pr-review.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "github-pr-review", - "description": "Unwrapped
 review diff. Without pre-wrap this inflates scrollWidth and used to zoom the whole letter.",
-  "body_html_sanitized": "

cam requested changes on this pull request.

apps/web/src/features/block-email/component/EmailMessageBody.tsx

@@ -291,8 +291,20 @@ export function EmailMessageBody() {\n       const contentWidth = messageDiv.scrollWidth;\n-      messageDiv.style.zoom = `${container.clientWidth / contentWidth}`;\n+      const fit = fitToWidthZoom({ containerWidth: container.clientWidth, contentWidth });\n+      // One unwrapped pre line like this must not shrink the letter to dust: const leftoverWideCanvas = 'newsletter-table-width-836px-and-a-pathological-diff-hunk-that-would-be-two-thousand-pixels-if-white-space-were-pre';\n
" -} diff --git a/apps/web/src/lib/core/email/tests/fixtures/nested-quotes.json b/apps/web/src/lib/core/email/tests/fixtures/nested-quotes.json deleted file mode 100644 index 45684d8726a..00000000000 --- a/apps/web/src/lib/core/email/tests/fixtures/nested-quotes.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "nested-quotes", - "description": "Email thread with nested blockquotes", - "body_html_sanitized": "

Thanks for the update!

On Monday, John wrote:

Original message here with some longer content that might wrap to multiple lines.

My reply to the original.

And here's my final response.

" -} diff --git a/apps/web/src/lib/core/email/tests/fixtures/styled-email.json b/apps/web/src/lib/core/email/tests/fixtures/styled-email.json deleted file mode 100644 index fc1c90396d9..00000000000 --- a/apps/web/src/lib/core/email/tests/fixtures/styled-email.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "styled-email", - "description": "Email with inline styles and formatting", - "body_html_sanitized": "

Welcome!

Thank you for signing up. We're excited to have you on board.

Click here to get started
" -} diff --git a/apps/web/src/lib/core/email/tests/fixtures/wide-table.json b/apps/web/src/lib/core/email/tests/fixtures/wide-table.json deleted file mode 100644 index d015b31acc6..00000000000 --- a/apps/web/src/lib/core/email/tests/fixtures/wide-table.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "wide-table", - "description": "Fixed-width 836px table. At 360px the viewer should floor zoom at 0.7 and scroll. At 800px it should shrink slightly without hitting the floor.", - "container_widths": [360, 800], - "body_html_sanitized": "
Panelist invitation for a designed newsletter table.
" -} diff --git a/apps/web/src/lib/core/email/tests/parse-email-html.test.ts b/apps/web/src/lib/core/email/tests/parse-email-html.test.ts deleted file mode 100644 index 3fc37abf7b9..00000000000 --- a/apps/web/src/lib/core/email/tests/parse-email-html.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - parseEmailContent, - sanitizeEmailHtml, - trimTrailingBrs, -} from '../parse-email-html'; - -describe('trimTrailingBrs', () => { - it('removes trailing br elements', () => { - const div = document.createElement('div'); - div.innerHTML = '

Hello




'; - trimTrailingBrs(div); - expect(div.innerHTML).toBe('

Hello

'); - }); - - it('removes trailing empty text nodes and br elements', () => { - const div = document.createElement('div'); - div.innerHTML = '

Hello



'; - trimTrailingBrs(div); - expect(div.innerHTML).toBe('

Hello

'); - }); - - it('preserves trailing img elements', () => { - const div = document.createElement('div'); - div.innerHTML = '

Hello


'; - trimTrailingBrs(div); - expect(div.innerHTML).toBe('

Hello


'); - }); - - it('preserves meaningful text content', () => { - const div = document.createElement('div'); - div.innerHTML = '

Hello


World'; - trimTrailingBrs(div); - expect(div.innerHTML).toBe('

Hello


World'); - }); - - it('removes nested trailing br elements', () => { - const div = document.createElement('div'); - div.innerHTML = '

Hello



'; - trimTrailingBrs(div); - expect(div.innerHTML).toBe('

Hello

'); - }); - - it('handles empty element', () => { - const div = document.createElement('div'); - div.innerHTML = ''; - trimTrailingBrs(div); - expect(div.innerHTML).toBe(''); - }); -}); - -describe('parseEmailContent', () => { - it('parses simple HTML content', () => { - const html = '

Hello World

'; - const result = parseEmailContent(html); - expect(result.mainContent).toBe('

Hello World

'); - expect(result.signature).toBeNull(); - expect(result.hasTable).toBe(false); - }); - - it('detects tables in content', () => { - const html = '
Cell
'; - const result = parseEmailContent(html); - expect(result.hasTable).toBe(true); - }); - - it('extracts Gmail signature', () => { - const html = ` -

Hello

-
--
-
John Doe
- `; - const result = parseEmailContent(html, true, false); - expect(result.mainContent).not.toContain('gmail_signature'); - expect(result.signature).toContain('John Doe'); - }); - - it('preserves signature when removeSignature is false', () => { - const html = ` -

Hello

-
--
-
John Doe
- `; - const result = parseEmailContent(html, false, false); - expect(result.mainContent).toContain('gmail_signature'); - expect(result.signature).toBeNull(); - }); - - it('extracts a Macro signature appended to outgoing mail', () => { - const html = ` -

Hello

-
Jane Doe
- `; - const result = parseEmailContent(html, true, false); - expect(result.mainContent).not.toContain('macro-email-signature'); - expect(result.signature).toContain('Jane Doe'); - }); - - it('preserves style tags from head', () => { - const html = ` - - - - - - -

Hello

- - - `; - const result = parseEmailContent(html); - expect(result.mainContent).toContain('

hi

'; - const sanitized = sanitizeEmailHtml(html); - expect(sanitized).toContain('.a{color:red}'); - expect(sanitized).toContain('hi'); - expect(sanitized).not.toContain('onclick'); - }); -}); diff --git a/apps/web/src/lib/core/email/tests/playwright.config.ts b/apps/web/src/lib/core/email/tests/playwright.config.ts deleted file mode 100644 index 307be81901a..00000000000 --- a/apps/web/src/lib/core/email/tests/playwright.config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - testDir: '.', - testMatch: '*.pw.ts', - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: 'html', - - use: { - baseURL: 'about:blank', - trace: 'on-first-retry', - }, - - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], - - snapshotDir: './snapshots', - snapshotPathTemplate: '{snapshotDir}/{testFilePath}/{arg}{ext}', - - expect: { - toHaveScreenshot: { - maxDiffPixels: 100, - }, - }, -}); diff --git a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/github-pr-review-macro-dark.png b/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/github-pr-review-macro-dark.png deleted file mode 100644 index 4cdae37c508..00000000000 Binary files a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/github-pr-review-macro-dark.png and /dev/null differ diff --git a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/github-pr-review-macro-light.png b/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/github-pr-review-macro-light.png deleted file mode 100644 index f320e3f66e2..00000000000 Binary files a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/github-pr-review-macro-light.png and /dev/null differ diff --git a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/google-calendar-invite-macro-dark.png b/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/google-calendar-invite-macro-dark.png deleted file mode 100644 index 1edab39462c..00000000000 Binary files a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/google-calendar-invite-macro-dark.png and /dev/null differ diff --git a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/google-calendar-invite-macro-light.png b/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/google-calendar-invite-macro-light.png deleted file mode 100644 index 2b2a0211149..00000000000 Binary files a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/google-calendar-invite-macro-light.png and /dev/null differ diff --git a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/nested-quotes-macro-dark.png b/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/nested-quotes-macro-dark.png deleted file mode 100644 index ee87f8eff6c..00000000000 Binary files a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/nested-quotes-macro-dark.png and /dev/null differ diff --git a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/nested-quotes-macro-light.png b/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/nested-quotes-macro-light.png deleted file mode 100644 index 67fbc1014de..00000000000 Binary files a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/nested-quotes-macro-light.png and /dev/null differ diff --git a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/styled-email-macro-dark.png b/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/styled-email-macro-dark.png deleted file mode 100644 index 72ed6f38b89..00000000000 Binary files a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/styled-email-macro-dark.png and /dev/null differ diff --git a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/styled-email-macro-light.png b/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/styled-email-macro-light.png deleted file mode 100644 index a52921ace86..00000000000 Binary files a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/styled-email-macro-light.png and /dev/null differ diff --git a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-360-macro-dark.png b/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-360-macro-dark.png deleted file mode 100644 index 67fa0456af0..00000000000 Binary files a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-360-macro-dark.png and /dev/null differ diff --git a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-360-macro-light.png b/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-360-macro-light.png deleted file mode 100644 index 63878f5f891..00000000000 Binary files a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-360-macro-light.png and /dev/null differ diff --git a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-800-macro-dark.png b/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-800-macro-dark.png deleted file mode 100644 index 5aa93770f1c..00000000000 Binary files a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-800-macro-dark.png and /dev/null differ diff --git a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-800-macro-light.png b/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-800-macro-light.png deleted file mode 100644 index dd058a80a38..00000000000 Binary files a/apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-800-macro-light.png and /dev/null differ diff --git a/apps/web/src/lib/core/email/tests/transform-email-colors.test.ts b/apps/web/src/lib/core/email/tests/transform-email-colors.test.ts deleted file mode 100644 index e7342a1abf6..00000000000 --- a/apps/web/src/lib/core/email/tests/transform-email-colors.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - findClosestContrastingColor, - normalizeRGBA, - parseRGBA, - rgbaToOklch, - stripContentBackgrounds, -} from '../transform-email-colors'; - -describe('parseRGBA', () => { - it('parses rgb() format', () => { - const result = parseRGBA('rgb(255, 128, 64)'); - expect(result).toEqual({ r: 255, g: 128, b: 64, a: 1 }); - }); - - it('parses rgba() format with alpha', () => { - const result = parseRGBA('rgba(255, 128, 64, 0.5)'); - expect(result).toEqual({ r: 255, g: 128, b: 64, a: 0.5 }); - }); - - it('parses rgba() format with space syntax', () => { - const result = parseRGBA('rgba(255 128 64 / 0.5)'); - expect(result).toEqual({ r: 255, g: 128, b: 64, a: 0.5 }); - }); - - it('handles transparent', () => { - const result = parseRGBA('transparent'); - expect(result).toEqual({ r: 0, g: 0, b: 0, a: 0 }); - }); - - it('returns null for invalid format', () => { - const result = parseRGBA('invalid'); - expect(result).toBeNull(); - }); - - it('returns null for empty string', () => { - const result = parseRGBA(''); - expect(result).toBeNull(); - }); -}); - -describe('normalizeRGBA', () => { - it('normalizes 0-255 values to 0-1 range', () => { - const result = normalizeRGBA({ r: 255, g: 128, b: 0, a: 0.5 }); - expect(result).toEqual({ r: 1, g: 128 / 255, b: 0, a: 0.5 }); - }); - - it('clamps values to 0-1 range', () => { - const result = normalizeRGBA({ r: 300, g: -10, b: 255, a: 1 }); - expect(result?.r).toBe(1); - expect(result?.g).toBe(0); - expect(result?.b).toBe(1); - }); - - it('returns null for null input', () => { - const result = normalizeRGBA(null); - expect(result).toBeNull(); - }); -}); - -describe('rgbaToOklch', () => { - it('converts black to OKLCH', () => { - const result = rgbaToOklch({ r: 0, g: 0, b: 0, a: 1 }); - expect(result?.l).toBeCloseTo(0, 2); - expect(result?.c).toBeCloseTo(0, 2); - }); - - it('converts white to OKLCH', () => { - const result = rgbaToOklch({ r: 1, g: 1, b: 1, a: 1 }); - expect(result?.l).toBeCloseTo(1, 2); - expect(result?.c).toBeCloseTo(0, 2); - }); - - it('preserves alpha value', () => { - const result = rgbaToOklch({ r: 0.5, g: 0.5, b: 0.5, a: 0.75 }); - expect(result?.a).toBe(0.75); - }); - - it('converts red to OKLCH with correct hue range', () => { - const result = rgbaToOklch({ r: 1, g: 0, b: 0, a: 1 }); - expect(result?.l).toBeGreaterThan(0); - expect(result?.c).toBeGreaterThan(0); - expect(result?.h).toBeGreaterThanOrEqual(0); - expect(result?.h).toBeLessThan(360); - }); - - it('returns null for null input', () => { - const result = rgbaToOklch(null); - expect(result).toBeNull(); - }); -}); - -describe('stripContentBackgrounds', () => { - function render(html: string): HTMLElement { - const root = document.createElement('div'); - root.innerHTML = html; - document.body.appendChild(root); - return root; - } - - function bg(el: Element | null) { - return (el as HTMLElement).style.backgroundColor; - } - - it('strips light page-like backgrounds', () => { - const root = render( - '
text
' - ); - stripContentBackgrounds(root); - expect(bg(root.querySelector('#page'))).toBe('transparent'); - root.remove(); - }); - - it('keeps dark/colored backgrounds', () => { - const root = render( - 'button' - ); - stripContentBackgrounds(root); - expect(bg(root.querySelector('#btn'))).toBe('rgb(126, 130, 201)'); - root.remove(); - }); - - it('keeps a light background layered on a kept colored ancestor (1px-border button trick)', () => { - // Outer div is a colored "border", inner anchor is the light button face - // with text color matching the border. Stripping the face would leave - // same-on-same invisible text. - const root = render( - '
' + - 'Very disappointed' + - '
' - ); - stripContentBackgrounds(root); - expect(bg(root.querySelector('#border'))).toBe('rgb(81, 177, 231)'); - expect(bg(root.querySelector('#face'))).toBe('rgb(228, 236, 242)'); - root.remove(); - }); - - it('strips nested light backgrounds once the ancestor light bg is stripped', () => { - const root = render( - '
' + - '
text
' + - '
' - ); - stripContentBackgrounds(root); - expect(bg(root.querySelector('#outer'))).toBe('transparent'); - expect(bg(root.querySelector('#inner'))).toBe('transparent'); - root.remove(); - }); -}); - -describe('findClosestContrastingColor', () => { - const CONTRAST_THRESHOLD = 0.5; - - it('increases lightness when fg is lighter than bg but contrast is low', () => { - const fg = { l: 0.6, c: 0.1, h: 180 }; - const bgL = 0.5; - const result = findClosestContrastingColor(fg, bgL); - expect(Math.abs(result.l - bgL)).toBeGreaterThanOrEqual( - CONTRAST_THRESHOLD - 0.01 - ); - }); - - it('decreases lightness when fg is darker than bg but contrast is low', () => { - const fg = { l: 0.4, c: 0.1, h: 180 }; - const bgL = 0.5; - const result = findClosestContrastingColor(fg, bgL); - expect(Math.abs(result.l - bgL)).toBeGreaterThanOrEqual( - CONTRAST_THRESHOLD - 0.01 - ); - }); - - it('preserves chroma and hue', () => { - const fg = { l: 0.5, c: 0.15, h: 270, a: 0.8 }; - const bgL = 0.5; - const result = findClosestContrastingColor(fg, bgL); - expect(result.c).toBe(0.15); - expect(result.h).toBe(270); - expect(result.a).toBe(0.8); - }); - - it('handles edge case when candidate exceeds bounds', () => { - const fg = { l: 0.9, c: 0.1, h: 180 }; - const bgL = 0.8; - const result = findClosestContrastingColor(fg, bgL); - expect(result.l).toBeGreaterThanOrEqual(0); - expect(result.l).toBeLessThanOrEqual(1); - }); - - it('defaults alpha to 1 when not provided', () => { - const fg = { l: 0.5, c: 0.1, h: 180 }; - const bgL = 0.5; - const result = findClosestContrastingColor(fg, bgL); - expect(result.a).toBe(1); - }); -}); diff --git a/apps/web/src/lib/queries/email/attachment.test.tsx b/apps/web/src/lib/queries/email/attachment.test.tsx index e21ef0cbdd0..ee0e42f0b01 100644 --- a/apps/web/src/lib/queries/email/attachment.test.tsx +++ b/apps/web/src/lib/queries/email/attachment.test.tsx @@ -1,9 +1,7 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; import { err, ok, type Result } from 'neverthrow'; -import type { JSX } from 'solid-js'; -import { render } from 'solid-js/web'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useUploadDraftAttachmentsMutation } from './attachment'; +import { mountEmailMutation } from './tests/mutation'; const addDraftAttachmentMock = vi.hoisted(() => vi.fn()); const removeDraftAttachmentMock = vi.hoisted(() => vi.fn()); @@ -29,33 +27,6 @@ vi.mock('@core/component/Toast/Toast', () => ({ toast: { failure: toastFailureMock }, })); -let testQueryClient: QueryClient; -let dispose: (() => void) | undefined; - -function renderHook(factory: () => T): T { - let hook!: T; - dispose = render( - () => ( - - {(() => { - hook = factory(); - return null as unknown as JSX.Element; - })()} - - ), - document.body - ); - return hook; -} - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((r) => { - resolve = r; - }); - return { promise, resolve }; -} - // jsdom's File lacks arrayBuffer() const file = () => { const f = new File([new Uint8Array([1, 2, 3])], 'demo.pdf', { @@ -69,9 +40,6 @@ const file = () => { beforeEach(() => { vi.clearAllMocks(); - testQueryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); addDraftAttachmentMock.mockResolvedValue( ok({ attachment_id: 'att-1', @@ -82,19 +50,14 @@ beforeEach(() => { removeDraftAttachmentMock.mockResolvedValue(ok(undefined)); }); -afterEach(() => { - dispose?.(); - dispose = undefined; -}); - describe('useUploadDraftAttachmentsMutation', () => { it('assigns the attachment id before the content upload completes', async () => { - const upload = deferred>(); + const upload = Promise.withResolvers>(); uploadToPresignedUrlMock.mockReturnValue(upload.promise); const onAttachmentAdded = vi.fn(); const attachment = file(); - const mutation = renderHook(() => useUploadDraftAttachmentsMutation()); + const mutation = mountEmailMutation(useUploadDraftAttachmentsMutation); const pending = mutation.mutateAsync({ draftID: 'draft-1', attachments: [attachment], @@ -119,7 +82,7 @@ describe('useUploadDraftAttachmentsMutation', () => { const onAttachmentUploadFailed = vi.fn(); const attachment = file(); - const mutation = renderHook(() => useUploadDraftAttachmentsMutation()); + const mutation = mountEmailMutation(useUploadDraftAttachmentsMutation); await expect( mutation.mutateAsync({ draftID: 'draft-1', @@ -138,7 +101,7 @@ describe('useUploadDraftAttachmentsMutation', () => { const onAttachmentUploadFailed = vi.fn(); const attachment = file(); - const mutation = renderHook(() => useUploadDraftAttachmentsMutation()); + const mutation = mountEmailMutation(useUploadDraftAttachmentsMutation); await expect( mutation.mutateAsync({ draftID: 'draft-1', @@ -163,7 +126,7 @@ describe('useUploadDraftAttachmentsMutation', () => { const onAttachmentUploadFailed = vi.fn(); const attachment = file(); - const mutation = renderHook(() => useUploadDraftAttachmentsMutation()); + const mutation = mountEmailMutation(useUploadDraftAttachmentsMutation); await expect( mutation.mutateAsync({ draftID: 'draft-1', diff --git a/apps/web/src/lib/queries/email/draft-cache.ts b/apps/web/src/lib/queries/email/draft-cache.ts new file mode 100644 index 00000000000..9f8f3ad1b54 --- /dev/null +++ b/apps/web/src/lib/queries/email/draft-cache.ts @@ -0,0 +1,17 @@ +import { queryClient } from '../client'; +import { emailKeys } from './keys'; + +// Saving a draft must not invalidate an active thread resource: doing so can +// detach its Suspense subtree and reset the editor and scroll position. +const savedThreads = new Set(); + +export function markThreadDraftSaved(threadId: string) { + savedThreads.add(threadId); +} + +export function clearSavedDraftThreadCache(threadId: string) { + if (!savedThreads.delete(threadId)) return; + queryClient.removeQueries({ + queryKey: emailKeys.threadMessages(threadId).queryKey, + }); +} diff --git a/apps/web/src/lib/queries/email/draft.ts b/apps/web/src/lib/queries/email/draft.ts index 3bfd9fa20cb..c105e5aab0c 100644 --- a/apps/web/src/lib/queries/email/draft.ts +++ b/apps/web/src/lib/queries/email/draft.ts @@ -1,5 +1,6 @@ import { toast } from '@core/component/Toast/Toast'; import { throwOnErr } from '@core/util/result'; +import { Telemetry } from '@macro-inc/observability'; import { invalidateAllSoup, refetchSoupEntity } from '@queries/soup/cache'; import { emailClient } from '@service-email/client'; import type { @@ -46,19 +47,29 @@ export function useSaveDraftMutation( toast.failure('Failed to save draft'); }, onSuccess(data, vars) { - queryClient.invalidateQueries({ - queryKey: emailKeys.previews._def, - }); - const threadId = data.draft.thread_db_id; - if (!threadId) return; - if (!vars.skipSoupRefetch) { - refetchSoupEntity(threadId, 'emailThread'); + try { + void queryClient + .invalidateQueries({ + queryKey: emailKeys.previews._def, + }) + .catch(Telemetry.error); + const threadId = data.draft.thread_db_id; + if (!threadId) return; + if (!vars.skipSoupRefetch) { + void refetchSoupEntity(threadId, 'emailThread').catch( + Telemetry.error + ); + } + // Reopening the thread reads the messages cache; drop it so the + // saved draft body isn't served stale. + void queryClient + .invalidateQueries({ + queryKey: emailKeys.threadMessages(threadId).queryKey, + }) + .catch(Telemetry.error); + } catch (error) { + Telemetry.error(error); } - // Reopening the thread reads the messages cache; drop it so the - // saved draft body isn't served stale. - queryClient.invalidateQueries({ - queryKey: emailKeys.threadMessages(threadId).queryKey, - }); }, }, callbacks @@ -96,21 +107,29 @@ export function useDeleteDraftMutation( toast.failure('Failed to delete draft'); }, onSuccess(_data, vars) { - queryClient.invalidateQueries({ - queryKey: emailKeys.previews._def, - }); - if (vars.skipSoupRefetch) return; - // Refetch the thread (not the deleted draft) so its draft-derived - // fields settle. No-op for compose drafts, whose thread is deleted - // along with the draft, so the refetch finds nothing to update. - if (vars.threadId) { - refetchSoupEntity(vars.threadId, 'emailThread'); + try { + void queryClient + .invalidateQueries({ + queryKey: emailKeys.previews._def, + }) + .catch(Telemetry.error); + if (vars.skipSoupRefetch) return; + // Refetch the thread (not the deleted draft) so its draft-derived + // fields settle. No-op for compose drafts, whose thread is deleted + // along with the draft, so the refetch finds nothing to update. + if (vars.threadId) { + void refetchSoupEntity(vars.threadId, 'emailThread').catch( + Telemetry.error + ); + } + // Discarding a draft changes view membership — the thread leaves + // Signal/Drafts and a noise thread re-enters Noise — which a + // single-entity patch can't express, so the soup list queries must + // refetch. Mirrors the archive flow in EmailContext. + invalidateAllSoup(); + } catch (error) { + Telemetry.error(error); } - // Discarding a draft changes view membership — the thread leaves - // Signal/Drafts and a noise thread re-enters Noise — which a - // single-entity patch can't express, so the soup list queries must - // refetch. Mirrors the archive flow in EmailContext. - invalidateAllSoup(); }, }, callbacks diff --git a/apps/web/src/lib/queries/email/integration.ts b/apps/web/src/lib/queries/email/integration.ts new file mode 100644 index 00000000000..3ad5a95a464 --- /dev/null +++ b/apps/web/src/lib/queries/email/integration.ts @@ -0,0 +1,37 @@ +import { throwOnErr } from '@core/util/result'; +import { emailClient } from '@service-email/client'; +import { storageServiceClient } from '@service-storage/client'; + +/** Provider operations used by email's production adapters alongside shared mutations. */ +export const scheduleEmailMessage = ( + ...args: Parameters +) => throwOnErr(() => emailClient.scheduleMessage(...args)); +export const archiveEmailThread = ( + ...args: Parameters +) => throwOnErr(() => emailClient.flagArchived(...args)); +export const unscheduleEmailMessage = ( + ...args: Parameters +) => emailClient.unscheduleMessage(...args); +export const restoreEmailDraft = ( + ...args: Parameters +) => emailClient.createDraft(...args); +export const getEmailAttachmentDocument = (id: string) => + emailClient.getOrCreateAttachmentDocumentId({ id }); +export const getEmailAttachmentMetadata = (documentId: string) => + storageServiceClient.getDocumentMetadata({ documentId }); + +export async function ensureEmailAttachmentPublic(attachmentId: string) { + const permissions = await storageServiceClient.getDocumentPermissions({ + document_id: attachmentId, + }); + if ( + permissions.isOk() && + permissions.value.linkShare === 'PUBLIC' && + permissions.value.linkShareAccessLevel === 'view' + ) + return undefined; + return storageServiceClient.editDocument({ + documentId: attachmentId, + sharePermission: { linkShare: 'PUBLIC', linkShareAccessLevel: 'view' }, + }); +} diff --git a/apps/web/src/lib/queries/email/link.test.tsx b/apps/web/src/lib/queries/email/link.test.tsx index 95cdb86279a..b9e2d7dfa68 100644 --- a/apps/web/src/lib/queries/email/link.test.tsx +++ b/apps/web/src/lib/queries/email/link.test.tsx @@ -1,11 +1,10 @@ import type { Link as EmailLink } from '@service-email/generated/schemas'; -import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; +import { QueryClient } from '@tanstack/solid-query'; import { err, ok } from 'neverthrow'; -import type { JSX } from 'solid-js'; -import { render } from 'solid-js/web'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { emailKeys } from './keys'; import { useDisableCalendarMutation } from './link'; +import { mountEmailMutation } from './tests/mutation'; const disableLinkCalendarMock = vi.hoisted(() => vi.fn()); const invalidateCalendarViewsMock = vi.hoisted(() => vi.fn()); @@ -48,24 +47,6 @@ const cachedLinks = () => const cachedLink = (id: string) => cachedLinks().find((it) => it.id === id); -let dispose: (() => void) | undefined; - -function renderHook(factory: () => T): T { - let hook!: T; - dispose = render( - () => ( - - {(() => { - hook = factory(); - return null as unknown as JSX.Element; - })()} - - ), - document.body - ); - return hook; -} - beforeEach(() => { vi.clearAllMocks(); testQueryClient = new QueryClient({ @@ -76,16 +57,13 @@ beforeEach(() => { }); }); -afterEach(() => { - dispose?.(); - dispose = undefined; - testQueryClient.clear(); -}); - describe('useDisableCalendarMutation', () => { it('marks only the target inbox as deliberately calendar-less', async () => { disableLinkCalendarMock.mockResolvedValue(ok({})); - const disable = renderHook(() => useDisableCalendarMutation()); + const disable = mountEmailMutation( + useDisableCalendarMutation, + testQueryClient + ); await disable.mutateAsync('inbox-a'); @@ -109,7 +87,10 @@ describe('useDisableCalendarMutation', () => { disableLinkCalendarMock.mockResolvedValue( err([{ code: 'HTTP_ERROR' as const, message: 'nope' }]) ); - const disable = renderHook(() => useDisableCalendarMutation()); + const disable = mountEmailMutation( + useDisableCalendarMutation, + testQueryClient + ); await expect(disable.mutateAsync('inbox-a')).rejects.toThrow(); diff --git a/apps/web/src/lib/queries/email/tests/mutation.tsx b/apps/web/src/lib/queries/email/tests/mutation.tsx new file mode 100644 index 00000000000..bf99878b594 --- /dev/null +++ b/apps/web/src/lib/queries/email/tests/mutation.tsx @@ -0,0 +1,30 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; +import { render } from 'solid-js/web'; +import { onTestFinished } from 'vitest'; + +/** Mount a real mutation observer; only the service/cache boundaries are mocked by callers. */ +export function mountEmailMutation( + factory: () => T, + client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) +): T { + let result!: T; + function Probe() { + result = factory(); + return null; + } + const dispose = render( + () => ( + + + + ), + document.createElement('div') + ); + onTestFinished(() => { + dispose(); + client.clear(); + }); + return result; +} diff --git a/apps/web/src/lib/queries/email/thread.ts b/apps/web/src/lib/queries/email/thread.ts index dca11dfafa5..7ea80fdc7a4 100644 --- a/apps/web/src/lib/queries/email/thread.ts +++ b/apps/web/src/lib/queries/email/thread.ts @@ -7,6 +7,7 @@ import { } from '@core/constant/featureFlags'; import { DEFAULT_THREAD_MESSAGES_LIMIT } from '@core/constant/pagination'; import { catchToResult, throwOnErr } from '@core/util/result'; +import { Telemetry } from '@macro-inc/observability'; import ArrowCounterClockwise from '@phosphor-icons/core/regular/arrow-counter-clockwise.svg?component-solid'; import { emailClient } from '@service-email/client'; import type { @@ -561,21 +562,35 @@ export function useSendMessageMutation( ...withCallbacks( { onSuccess: (data, vars) => { - analytics.track('email_message_sent'); - const threadID = data.message.thread_db_id; - if (threadID) { - queryClient.invalidateQueries({ - queryKey: emailKeys.threadMessages(threadID).queryKey, - }); - // Refresh the thread's soup item so inbox views stop showing it - // as a draft once the message is sent. - if (!vars.skipSoupRefetch) { - refetchSoupEntity(threadID, 'emailThread'); + try { + analytics.track('email_message_sent'); + } catch (error) { + Telemetry.error(error); + } + try { + const threadID = data.message.thread_db_id; + if (threadID) { + void queryClient + .invalidateQueries({ + queryKey: emailKeys.threadMessages(threadID).queryKey, + }) + .catch(Telemetry.error); + // Refresh the thread's soup item so inbox views stop showing it + // as a draft once the message is sent. + if (!vars.skipSoupRefetch) { + void refetchSoupEntity(threadID, 'emailThread').catch( + Telemetry.error + ); + } } + void queryClient + .invalidateQueries({ + queryKey: emailKeys.previews._def, + }) + .catch(Telemetry.error); + } catch (error) { + Telemetry.error(error); } - queryClient.invalidateQueries({ - queryKey: emailKeys.previews._def, - }); }, }, callbacks @@ -653,9 +668,15 @@ export function useUnscheduleMessageMutation( ...withCallbacks( { onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: emailKeys.previews._def, - }); + try { + void queryClient + .invalidateQueries({ + queryKey: emailKeys.previews._def, + }) + .catch(Telemetry.error); + } catch (error) { + Telemetry.error(error); + } }, }, callbacks diff --git a/apps/web/src/lib/queries/email/write-completion.test.tsx b/apps/web/src/lib/queries/email/write-completion.test.tsx new file mode 100644 index 00000000000..3a59d982404 --- /dev/null +++ b/apps/web/src/lib/queries/email/write-completion.test.tsx @@ -0,0 +1,113 @@ +import { err, ok } from 'neverthrow'; +import { beforeEach, expect, it, vi } from 'vitest'; +import { useSaveDraftMutation } from './draft'; +import { mountEmailMutation } from './tests/mutation'; +import { useSendMessageMutation, useUnscheduleMessageMutation } from './thread'; + +const mocks = vi.hoisted(() => ({ + save: vi.fn(), + send: vi.fn(), + unschedule: vi.fn(), + invalidate: vi.fn(), + refetch: vi.fn(), + track: vi.fn(), + report: vi.fn(), + failure: vi.fn(), +})); +vi.mock('@service-email/client', () => ({ + emailClient: { + createDraft: mocks.save, + sendMessage: mocks.send, + unscheduleMessage: mocks.unschedule, + }, +})); +vi.mock('../client', () => ({ + queryClient: { invalidateQueries: mocks.invalidate }, +})); +vi.mock('../soup/cache', () => ({ + refetchSoupEntity: mocks.refetch, + optimisticUpdateSoupEntity: vi.fn(), +})); +vi.mock('../soup/normalized-cache', () => ({ invalidateAllSoup: vi.fn() })); +vi.mock('../undo', () => ({ useUndoableMutation: vi.fn() })); +vi.mock('./graphql/thread', () => ({ + createGraphqlEmailThreadQuery: vi.fn(), + fetchGraphqlEmailThread: vi.fn(), + mapGraphqlThreadError: vi.fn(), +})); +vi.mock('@app/lib/analytics/analytics-context', () => ({ + useAnalytics: () => ({ track: mocks.track }), +})); +vi.mock('@app/lib/analytics/posthog', () => ({ useFeatureFlag: vi.fn() })); +vi.mock('@macro-inc/observability', () => ({ + Telemetry: { error: mocks.report }, +})); +vi.mock('@core/component/Toast/Toast', () => ({ + toast: { failure: mocks.failure }, +})); + +beforeEach(() => { + vi.resetAllMocks(); + mocks.invalidate.mockResolvedValue(undefined); + mocks.refetch.mockResolvedValue(undefined); +}); + +it('returns the saved identity when a subsequent cache refresh rejects', async () => { + const response = { + draft: { db_id: 'draft', thread_db_id: 'thread', link_id: 'inbox' }, + }; + const failure = new Error('Refresh failed'); + mocks.save.mockResolvedValue(ok(response)); + mocks.refetch.mockRejectedValue(failure); + const mutation = mountEmailMutation(useSaveDraftMutation); + await expect( + mutation.mutateAsync({ draft: { subject: 'Saved' } }) + ).resolves.toEqual(response); + expect(mocks.report).toHaveBeenCalledWith(failure); + expect(mocks.failure).not.toHaveBeenCalled(); + expect(mocks.save).toHaveBeenCalledOnce(); +}); + +it('keeps send successful and reconciles caches when analytics throws', async () => { + const response = { + message: { db_id: 'sent', thread_db_id: 'thread', link_id: 'inbox' }, + }; + const failure = new Error('Analytics failed'); + mocks.send.mockResolvedValue(ok(response)); + mocks.track.mockImplementation(() => { + throw failure; + }); + const mutation = mountEmailMutation(useSendMessageMutation); + await expect( + mutation.mutateAsync({ message: { subject: 'Sent' } }) + ).resolves.toEqual(response); + expect(mocks.refetch).toHaveBeenCalledWith('thread', 'emailThread'); + expect(mocks.report).toHaveBeenCalledWith(failure); + expect(mocks.send).toHaveBeenCalledOnce(); +}); + +it('keeps unschedule successful when invalidation throws synchronously', async () => { + mocks.unschedule.mockResolvedValue(ok(undefined)); + const failure = new Error('Cache failed'); + mocks.invalidate.mockImplementation(() => { + throw failure; + }); + const mutation = mountEmailMutation(useUnscheduleMessageMutation); + await expect( + mutation.mutateAsync({ draftID: 'draft' }) + ).resolves.toBeUndefined(); + expect(mocks.report).toHaveBeenCalledWith(failure); +}); + +it('rejects a failed send without running success effects', async () => { + mocks.send.mockResolvedValue( + err([{ code: 'SERVER_ERROR', message: 'Offline' }]) + ); + const mutation = mountEmailMutation(useSendMessageMutation); + await expect( + mutation.mutateAsync({ message: { subject: 'Unsent' } }) + ).rejects.toThrow(); + expect(mocks.send).toHaveBeenCalledOnce(); + expect(mocks.track).not.toHaveBeenCalled(); + expect(mocks.refetch).not.toHaveBeenCalled(); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 6068f979d40..b28f9d92fd7 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ test: { exclude: [...configDefaults.exclude], projects: [ + '../../packages/email-renderer/vitest.config.ts', '../../packages/collaboration/vitest.collab.config.ts', '../../packages/collaboration/vitest.transport.config.ts', { @@ -142,13 +143,13 @@ export default defineConfig({ }, }, { - // tsconfigPaths so tests can resolve `@`-aliased imports (e.g. a util - // that imports `@core/util/url`). Per-file `@vitest-environment jsdom` - // opts a test into a DOM; the default here stays node. - plugins: [tsconfigPaths()], + extends: './src/lib/core/vitest.config.ts', test: { - include: ['src/features/block-email/**/*.{test,spec}.{ts,tsx}'], - name: 'block-email', + environment: 'jsdom', + include: [ + 'src/features/{block-email,email-message,email-thread,email-compose}/**/*.{test,spec}.{ts,tsx}', + ], + name: 'email', }, }, { @@ -172,7 +173,7 @@ export default defineConfig({ environment: 'jsdom', exclude: [ ...configDefaults.exclude, - 'src/features/{theme,block-channel,block-call,block-pr,block-md,channel,notifications,block-email}/**/*', + 'src/features/{theme,block-channel,block-call,block-pr,block-md,channel,notifications,block-email,email-message,email-thread,email-compose}/**/*', ], include: [ 'src/components/**/*.{test,spec}.{ts,tsx}', diff --git a/bun.lock b/bun.lock index 5d184656033..507ffcfa7b9 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "@livekit/track-processors": "^0.7.2", "@lukemorales/query-key-factory": "^1.3.4", "@macro-inc/collaboration": "workspace:*", + "@macro-inc/email-renderer": "workspace:*", "@macro-inc/lexical-core": "workspace:*", "@macro-inc/observability": "workspace:*", "@normy/query-core": "^0.21.0", @@ -198,6 +199,23 @@ "ws": "^8.18.0", }, }, + "packages/email-renderer": { + "name": "@macro-inc/email-renderer", + "version": "0.0.1", + "dependencies": { + "css-tree": "2.3.1", + "parse5": "7.3.0", + }, + "devDependencies": { + "@fontsource-variable/inter": "5.3.0", + "@playwright/test": "1.62.0", + "@types/css-tree": "^2.3.0", + "@types/node": "^24.8.0", + "typescript": "^5.9.3", + "vite": "^6.4.1", + "vitest": "^3.2.4", + }, + }, "packages/lexical-core": { "name": "@macro-inc/lexical-core", "version": "0.2.2", @@ -1106,6 +1124,8 @@ "@macro-inc/collaboration": ["@macro-inc/collaboration@workspace:packages/collaboration"], + "@macro-inc/email-renderer": ["@macro-inc/email-renderer@workspace:packages/email-renderer"], + "@macro-inc/lexical-core": ["@macro-inc/lexical-core@workspace:packages/lexical-core"], "@macro-inc/observability": ["@macro-inc/observability@workspace:packages/observability"], @@ -1590,6 +1610,8 @@ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/css-tree": ["@types/css-tree@2.3.11", "", {}, "sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], diff --git a/docs/AGENT_GUIDE/surfaces.md b/docs/AGENT_GUIDE/surfaces.md index 5aba981f618..f372003c40d 100644 --- a/docs/AGENT_GUIDE/surfaces.md +++ b/docs/AGENT_GUIDE/surfaces.md @@ -45,6 +45,45 @@ Full email client. Tabs: `Signal` / `Noise` / `Sent` / `Calendar` / `Drafts` / ` shows `Connect your email` (Gmail/Google Workspace OAuth) — most functionality needs a connected account. Search is `Ctrl+F` within the surface. +Threads open at `/app/email/`. Click a message header to expand or +collapse it; `Show N hidden messages` reveals the collapsed middle of a longer +conversation. A link with `?email_message_id=` reveals that message. +Collapsed thread cards use a compact text snippet; expanding mounts the message +body and its attachments. +Replies appear inline on desktop and in a composer drawer on touch devices. +`R` and `Alt+R` (`Option+R` on macOS) open reply-all for the selected message, +or the latest message when none is selected. `F` opens a forward and focuses To. +While an editable field is focused, Escape is handled by that field before the +close-reply shortcut. +An edited reply remains a draft when navigating away and returning. Standalone +compose also flushes pending edits when leaving through app navigation. During +send or discard, its sender and scheduling controls cannot change the operation. +Attachments that can be opened are buttons named by their filename; Tab to one +and press Enter or Space. Removal is a separate button named `Remove `. +Removing a forwarded file keeps the received original. +AI email tool drafts persist body-only edits; changing recipients or the subject +is not required to save the body. +The three-dot button beneath a body reveals quoted content and a trimmed +signature. Plaintext and Macro Markdown use the existing Markdown renderer; +Macro Markdown messages retain document mentions. Ordinary HTML bodies use an +open shadow root: Playwright text locators can reach them, but a card's ordinary +`innerText` or `querySelector` does not traverse that root. + +After a successful send, the `Email sent` notice offers `Undo`. Undo restores the +sent envelope and editable content, including when the reply used another inbox; +a slow background refresh must not keep the restored editor disabled. A rejected +send reports failure and restores its original reply editor if it is still mounted. +A failure from an older, unmounted editor must not overwrite a newer edited reply. +A presentation or refresh error after successful delivery is not a reason to send +again. + +While a schedule change is pending, immediate send and further schedule changes +are disabled. Reply recipients cannot be edited or dragged during scheduling, +sending, or discarding. A failed schedule or unschedule keeps the last confirmed time. +If scheduling succeeds but marking the thread done fails, the email remains +scheduled and a notice explains the separate failure. Check the confirmed time +before retrying; do not treat that notice as a failed schedule. + With the new app views enabled, mobile and tablet Email use a floating, horizontally scrolling row of those tabs, with `Open email filters` at the left. The rest of the view is the email list, which scrolls beneath the header and supports pull to refresh diff --git a/docs/EMAIL_FEATURE_ARCHITECTURE.md b/docs/EMAIL_FEATURE_ARCHITECTURE.md new file mode 100644 index 00000000000..6cfbd71bfb2 --- /dev/null +++ b/docs/EMAIL_FEATURE_ARCHITECTURE.md @@ -0,0 +1,423 @@ +# Email feature architecture + +Email applies the stronger composition and capability +boundaries in [Frontend feature architecture](FRONTEND_FEATURE_ARCHITECTURE.md). +Its reusable packages live under `apps/web/src/features/`. The application still +opens `/app/email/:threadId`, and existing drafts and mailto composition use the +same application routes. + +## Ownership + +| Package | Owns | Does not own | +| --- | --- | --- | +| `packages/email-renderer` | Deterministic preparation of HTML/plaintext bodies; framework-independent browser containment, colors, layout, and resource lifecycle | Solid, message DTOs, threads, app services, sender classification, block state | +| `email-message` | A received or sent message, its sender/header, body renderer lifecycle and policy, Macro Markdown, quote expansion controls, and attachment presentation | Thread ordering, pagination, selection policy, drafts, reply placement, navigation, block state | +| `email-thread` | A conversation, chronological ordering, hidden middle messages, pagination, draft association, reading stops, selection, scroll coordination, and where a reply appears | Rendering the internals of an email, editing or sending a draft, block lifecycle | +| `email-compose` | Form state, recipient rules, editor content, attachments, draft persistence, sending, scheduling, signatures, and undo recovery | Thread pagination/rendering, block identity, app routes or split navigation | +| `block-email` | The document-block host: load gate, read marker, block hotkeys/focus, location registration, header, modals, side panel, and host actions | Reusable thread, message, or composer state | + +An email thread and an individual email are independent features. A message can be +rendered without a thread provider. A composer can run without a thread: the +standalone new-email route and the AI compose surface are examples. A reply takes +a narrow `EmailReplySession` describing only the conversation information it +needs. + +Dependency arrows mean “imports or consumes”: + +```mermaid +flowchart TD + Block[block-email adapter] --> ThreadRoot[email-thread production entry] + ThreadRoot --> Thread[email-thread surface and state] + ThreadRoot --> Adapters[production adapters and shared queries] + Thread --> Message[email-message view] + Thread --> Compose[email-compose view] + Compose --> Model[email-message core types] + Message --> Model + Message --> RendererBrowser[email-renderer/browser] + RendererBrowser --> RendererCore[email-renderer core] + Adapters --> Contracts[feature-owned contracts] + Thread --> Contracts + Compose --> Contracts +``` + +`email-message` must never import `email-thread` or `email-compose`. +`email-compose` must never import `email-thread`. Thread composition may import +both features' reusable views and contracts. No module in these three packages +may import a block package, `@core/block`, or block-related signal modules. + +## Production entry points and reusable surfaces + +- `email-message/views/email-message.tsx` receives one `EmailMessage` plus + resolved presentation values and event callbacks. Its caller provides the + rendering context, selection, expansion, reply actions, and footer. +- `email-thread/email-thread.tsx` constructs the existing shared thread query, + source adapter, viewer/contact capabilities, thread commands, composer capabilities, + notification subscription, rendering adapters, and host-independent cache + cleanup. `views/email-thread-surface.tsx` builds thread state and providers from + supplied contexts. It imports no production entry point for a nested + message or composer. +- `email-compose/email-compose.tsx` supplies the production compose context and split-host + callbacks to `views/email-compose.tsx`. Inline replies use + `views/reply-input.tsx` with explicit capabilities and a reply session. + `email-thread/views/thread-reply-input.tsx` owns the keyed reply lifetime; + `email-compose/primitives/reply-composer.ts` owns the compose workflow. +- `block-email/EmailBlockAdapter.tsx` translates block focus, keyboard scope, + location parameters, and block methods into `EmailThreadHost` callbacks and + slots. The thread does not read a block ID or register a block method itself. + The adapter captures block signal accessors during setup; event callbacks use + those captured functions instead of resolving a provider after setup. + +Capability contracts and provider/consumer modules live in `context/`. Prepared +view-state types live in `primitives/`; views mount the providers. +These scoped UI providers have no production fallback. A missing required +provider throws a specific error instead of silently initializing the app. + +A host frame receives a **content factory**, `() => JSX.Element`. It must call +that factory beneath its providers. Constructing the content first and then +passing an already-created element to the frame can make nested side-panel +sections execute before their layout provider exists. The surface regression +test exercises this ordering. + +## Contracts and layer responsibilities + +| Contract | Consumer needs | +| --- | --- | +| `EmailThreadSource` | Requested identity, available domain thread, request status, pagination availability, and refresh/page completion | +| `EmailThreadCommands` | Thread actions and their availability; no soup collection or mutation objects | +| `EmailThreadContext` | Thread source, viewer and device state, recipients, and the command factory used by thread state and navigation | +| `EmailThreadViewContext` | Thread and compose contexts, plus view callbacks and optional compose host behavior | +| `EmailThreadHost` | Optional location target, focus, activation status, and keyboard registration | +| `EmailRenderingContextValue` | Theme values, explicit image policy, link preparation, and image resolution with an abortable resource lifetime | +| `EmailFormContextInputs` | Viewer address and available inbox identities for recipient selection | +| `EmailReplySession` | Thread identity, recipient options, personal-reply classification, a targeted reply request, and host intents for leaving the composer or removing its draft | +| `EmailDraftStorage` | Save/delete and restore an undone draft; inputs use domain inbox IDs and completion intent | +| `EmailAttachmentStorage` | Upload, forward, and remove draft attachments | +| `EmailDelivery` | Send, undo, schedule, unschedule, and archive operations | +| `EmailComposeFeedback` | User notices and error reporting | +| `EmailComposeAccounts` | Inbox identities, availability, and the primary inbox | +| `EmailComposePresentation` | View-only device state, signature visibility, upgrade action, and link preparation | +| `EmailEditorFiles` | View-owned editor file upload and sharing integration | +| `EmailComposeContext` | Compose capabilities supplied to views, which pass the narrow inputs each controller needs | +| `PersistedEmailIdentity` | Successful save/send result: draft, thread, and inbox identity without a transport envelope | +| `EmailComposeHost` | Optional navigation, back handling, and focus movement supplied by the host | + +Core types are owned by the features. Generated email service schemas and concrete +query results stop at adapters. `email-thread/queries/thread-source.ts` uses +`toEmailThread` to explicitly project typed transport values and guards Solid +resource reads. This projection does not validate unknown input: reserve names +such as `decode` or `parse` for transformations that actually do that work. +The projection selects the fields the features consume and copies nested contacts, +labels, and attachment records. It preserves absent versus empty body content, +provider IDs needed for replies, attachment/CID identities, and project navigation +metadata. Sync headers and unused transport display settings stay out of the models. `email-compose/queries/inbox-source.ts` +projects linked-account metadata. Actual service-client operations remain in +`src/lib/queries/email`, alongside the existing mutations and cache conventions. + +Compose controllers receive named `drafts`, `attachmentStorage`, `delivery`, +`notices`, and `accounts` capabilities plus the values their workflow needs. They +do not receive `EmailComposeContext`, `presentation`, or `editorFiles`. The +view wires file-paste/drop plugins, document sharing, upgrade actions, device +layout, and signature-link preparation. The reply controller receives a focus +policy accessor and reports content edits; it does not choose a device layout. + +Primitives accept these domain capabilities; they do not construct shared queries, +import production adapters, or return JSX. The compose controllers use the real +Lexical editor API. That is an intentional editor dependency, not an application +service dependency. The narrow shared `utils/setEditorStateFromHtml.ts` helper +avoids the broad editor utility barrel and its plugin/application side effects. + +Components receive values, slots, and handlers. For example, inbox names and +watermark upgrade actions are supplied from production wiring; the inbox selector +and signature button do not resolve the current user themselves. Clipboard +feedback, uploads, signature link interception, and editor focus traversal are +also supplied capabilities or host actions. + +### Responsibilities within a feature + +A feature boundary is not sufficient if a controller still owns every concern +inside it. Keep a primitive around one invariant or lifetime, and share it when +two controllers implement that same behavior. Avoid splitting a workflow into +helpers that need the entire controller passed back to them. + +The compose controllers now assemble these smaller responsibilities: + +| Module | Responsibility and boundary | +| --- | --- | +| `attachment-persistence.ts` | Upload/remove operations and completion tracking. Receives attachment state and three transport capabilities. A saved attachment ID does not mean its content upload has finished; every save waits for outstanding uploads. | +| `email-send-schedule.ts` | Confirmed send time, pending changes, unscheduling and archive feedback. Scheduling saves the current draft and waits for its attachments even when a draft ID already exists; each operation retains its selected inbox. | +| `draft-autosave.ts` | One debounce and serialized write queue used by reply and standalone compose. Captures editor values before queueing, flushes pending edits on disposal, and exposes cancellation and completion for send/discard. | +| `reply-recipient-fields.ts` | Recipient field expansion, drag/drop and outside interaction. Receives values, a setter and a change callback; it knows nothing about saving or sending. | +| `reply-composer-focus.ts` | Deferred editor/recipient focus and the forward focus guard. Receives DOM accessors and an editor `focus()` capability. Its timers, animation frames and event listeners end with its owner. | +| `views/reply-envelope.tsx` | Sender, recipients and subject presentation. One recipient input implementation supplies the desktop/mobile layouts while the parent keeps a single editor mounted. | + +A reply retains the draft ID and the thread returned by persistence together. +Changing the sender can move the draft to another inbox's thread; the displayed +conversation still owns focus, completion and local undo recovery. Each serialized +save reports its previous persisted thread to the production adapter, which marks +both affected message caches for cleanup on disposal. Undo retains the selected +inbox and envelope and reconciles the actual sent thread, even after navigation. +Discard and scheduling also address the persisted thread. + +The reply controller still owns draft collection, sending, reset and undo as one +coordinated workflow: they share editor snapshots, draft identity and pending +operation guards. Breaking that sequence into mutually dependent controllers +would make ordering harder to inspect. Its view receives named actions and +pending accessors instead of mutation objects, and derives layout details itself. + +The shared autosave primitive wraps `@solid-primitives/scheduled` with explicit +pending-edit tracking and a disposal flush. Debounce cancellation alone would +lose the last edit. The queue captures body/envelope/inbox values before waiting; +each write uses the draft ID allocated by the preceding write. An ID is retained +before uploads finish so a failed upload can still be retried or discarded. +Attachment membership is reconciled after the save, so a forwarded file removed +while saving is not added back from an old snapshot. + +Cached forms contain values and an edit revision. They retain no editor, +controller callback, focus flag, or timer. Reset/clear restore values without +emitting a user edit. The mounted reply controller observes edits and owns focus +and quote commands, including cancellation of deferred work. Showing an existing +quote is idempotent across editor remounts. DOM listeners use the installed +`@solid-primitives/event-listener` cleanup. + +```mermaid +flowchart TD + View[Mounted compose view] --> Controller[Reply or standalone controller] + View --> Presentation[Device and signature presentation] + View --> FileIntegration[Editor upload and sharing capabilities] + Controller --> Form[Form values and edit revision] + Controller --> Editor[Mounted Lexical editor and focus lifetime] + Controller --> Save[Shared draft autosave queue] + Controller --> Schedule[Schedule workflow] + Schedule --> Save + Save --> DraftContract[Draft storage contract] + Controller --> Attachment[Attachment persistence] + Attachment --> AttachmentContract[Attachment storage contract] + Schedule --> DeliveryContract[Delivery contract] + Controller --> Host[Host intent callbacks] + Controller --> Feedback[Feedback capability] + Production[Production adapter] --> DraftContract + Production --> AttachmentContract + Production --> DeliveryContract + Production --> Queries[Shared queries, inbox headers and cache reconciliation] +``` + +These are dependency edges, not event flow. Cached form values do not point back +to the editor or controller. A reply's host owns message selection and DOM focus; +the composer requests `exitToThread('last' | 'selected')`. The thread resolves +that request inside its own container, including when two split panes contain +the same message. Standalone compose receives a draft seed directly, rather than +receiving an entire thread session to look it up. + +Production adapters translate domain inbox IDs to transport headers. They also +own preview invalidation, old-thread reconciliation when a draft changes inbox, +and restoring sent-message caches during undo. A composer supplies the saved +content and intended thread completion; it does not name query keys, choose +between cache implementations, or sequence cache repair calls. + +Thread state composes `thread-drafts.ts` for stale-response reconciliation and +`thread-recipients.ts` for contact aggregation. `thread-navigation.ts` owns reading +stops, focus and scrolling; `thread-reply-area.ts` owns bottom/drawer reply +placement. Thread reset and cached-draft auto-open remain in one effect so reset +cannot overwrite an immediately available draft. Production read/unread and +completion/undo wiring live in separate adapters, with one shared link-header +converter created by `thread-action-adapter.tsx`. `EmailThreadViewContext` supplies +the `EmailThreadContext` consumed by state and navigation, alongside the compose +context and view callbacks. State creates one retained thread snapshot +and passes that accessor to the injected command factory, so commands and reading +state cannot disagree because they retained separate snapshots. + +## Async completion and naming + +TanStack remains in the production/query adapters. A write capability is a plain +async function: it resolves when its write succeeds and rejects when the write +fails. Do not wrap it in another mutation object with its own result, callbacks, +error state, or `start()` method. `createComposeOperation` was removed. The shared +query layer owns requests and cache conventions; feature workflows own ordering +between draft persistence, attachments, send, schedule, and undo. + +Each composer keeps a local `idle | preparing | sending` phase because those +phases span multiple writes and multiple composers can share the same production +capabilities. This is workflow state, not a second server-state cache. Autosave's +serialized queue, schedule's exclusion guard, and the attachment set of outstanding +uploads each protect a concrete ordering invariant. + +A successful server write stays successful if analytics, cache refresh, toast, +or navigation work fails afterward. Query callbacks catch/report their own +post-write errors, including detached refresh rejections. Moving a throwing +callback into TanStack's lifecycle callbacks alone does not establish that +separation: the installed mutation implementation awaits those callbacks within +its failure handling. Adapter tests therefore exercise real TanStack mutations. + +| Failure | Owner and behavior | +| --- | --- | +| Draft save/delete or attachment write | Existing shared mutation reports the write failure; callers do not add a second schedule/save notice. | +| Send | Composer reports the failed send once. A failed reply restores only its original still-mounted editor; a newer editor's work is preserved. | +| Schedule/unschedule | Schedule workflow reports the failed request and keeps the last confirmed time. | +| Post-send refresh/navigation/analytics | Report the presentation/cache error; do not report that delivery failed or enable a duplicate send. | +| Archive after scheduling | Keep the confirmed schedule and identify the archive failure separately. | + +Replies still clear optimistically when dispatch starts. After successful send, +the controller establishes the mark-done undo handle before starting a detached, +error-reported refresh. A slow refresh must not delay Undo or leave an +Undo-restored editor disabled. Each send owns its mentions, completion target, +and undo handle; sending again must preserve the earlier notification's undo +action. Standalone compose marks completion before its navigation callback, so +disposal cannot autosave or resend the successful message. + +`EmailThreadSource.refresh()` and `fetchOlder()` require `Promise`. Their +adapters await the underlying query, and callers that need fresh messages await +that completion. They must not launch the request and resolve early. + +Feature capability parameters use `draftId`, `threadId`, `attachmentId`, and +`inboxId`; scheduling uses `sendTime`. `compose-adapter.ts` translates these to +transport `draftID`, `attachmentID`, `linkId` headers, and `send_time`. A domain +inbox ID is not a precomputed header: primary-inbox omission belongs to the adapter. +Existing message/thread model fields retain their established snake_case spelling; +this cleanup does not rename generated schemas or persisted URLs. In particular, +the host still reads/writes the existing `draftID` compose route parameter. + +`EmailThreadStateProvider` and `useEmailThreadState` name mounted thread state. +`ThreadReplyInput` names the thread-owned reply entry point, while +`createReplyComposer` names the compose controller. Import a module that owns the +value directly; the old root compose-layout barrel and provider type re-exports +were removed. The frontend feature skill remains deleted while these rules are +refined in documentation. + +## State and lifetime rules + +Ordinary email body rendering is now owned by +[`packages/email-renderer`](../packages/email-renderer/README.md). Its default +entry point prepares serializable HTML from a narrow content input without DOM, +Solid, flags, or services. Its `/browser` entry point owns Shadow DOM, computed +styles, containment, width fitting, and resource cleanup. `email-message` only +translates reactive values and registers the renderer's disposal with Solid. +Production adapters supply theme, image proxy policy, CID resolution, native +authenticated image fetching, and mailto interception. The package never imports +the app to obtain those capabilities. + +Macro Markdown and the existing plaintext fallback remain app Markdown rendering +paths because document mentions and editor semantics belong to the app. Neither +branch mounts an invisible HTML renderer or starts its resource requests. The +standalone package also offers literal plaintext preparation, but adopting that +policy in the app would be a separate behavior change. Missing replyless HTML +falls back to recognized quote removal or the full body instead of a blank body. +The shared editor HTML decorator still owns its Lexical/Solid lifecycle; its +sanitization/color helpers delegate to the package through `@core/email`. + +1. Query availability and display policy are separate. The adapter can expose + cached data even when a completed request failed. A pending resource is never + read eagerly. `primitives/thread-snapshot.ts` decides to retain a readable + snapshot during transient reloads and rejects a snapshot for another ID. The + block's load gate continues to prioritize structural errors over cached data. +2. Refresh and pagination preserve asynchronous completion. A caller awaiting a + refresh must wait for the underlying query, particularly before revealing a + newly sent message. Paging stops when the target is found, no progress is made, + the source identity changes, or the owning view is disposed. +3. Selection, expansion, hover, reply placement, scrolling, and initial-load + state belong to each mounted thread. There is no block signal or global scroll + flag in a feature package. +4. A newer saved draft wins over an older response. Missing drafts in a stale + response do not collapse an open editor, and locally discarded drafts are not + resurrected by delayed responses. +5. An engaged reply editor latches its seed while the same message is being + edited. Server echoes must not remount it and lose focus. A different reply + target owns a new composer lifetime, including on mobile. That lifetime binds + its target, draft seed, form, and thread identity before disposal can observe + the next target. Flushing an old editor must never retarget its body. +6. Undo recovery survives navigation but is keyed by draft and reply identity. + One composer cannot overwrite another's snapshot or restoration callback, and + an older owner's cleanup cannot unregister a newer owner. Recovery history is + bounded; it is not a global reactive feature-state singleton. +7. Scheduling and immediate sending are mutually exclusive while a scheduling + operation is pending. A rejected schedule keeps the previous confirmed time. + If scheduling succeeds and archiving fails, the confirmed time remains and the + user receives accurate feedback. A failed unschedule also retains that time. + Scheduling may take precedence while an immediate send is saving its draft; + it cannot start during the actual send or discard. Sender changes are also + blocked while those operations own the composer. +8. Renderer resources follow their Solid owner. Source changes or disposal release + image blob URLs, resize observers and image listeners, and abort pending adapter + work. + +## Shared UI and explicit exceptions + +Isolation of state and contracts does not mean every existing shared widget is +application-free. Message views still compose the shared Markdown renderer, +user tooltips, image galleries, and UI controls; compose views use the +shared rich editor, recipient selector, and mobile chrome. Their application +integration remains outside the controllers. Sender avatars are slots in both +expanded and collapsed message presentation. `sender-icon-adapter.tsx` supplies +the app's profile lookup and user card through `UserIcon`; the reusable message +view does not import that navigation/DM integration. Some other shared widgets +still import app services: thread participants use `UserIcon`, which creates a +direct-message mutation, and `EntityIcon` imports the block registry. Removing +the UI test mocks exposes missing app providers and WebSocket initialization. +Consequently, the contexts isolate controller behavior, but the complete thread +and composer UI still require additional application providers. + +Controller tests supply fake capabilities through their contexts. Their `vi.fn` +spies and `vi.mocked` type helpers do not substitute imported modules. The view +tests still use module mocks for the thread view, reply editor, and attachment +icon; they verify provider ordering, reply lifetime/focus handoff, and keyboard +behavior respectively, not complete view isolation. The native image adapter +test substitutes platform detection and transport to exercise Tauri behavior. + +Two narrow shared pure utilities are allowed: `@core/util/base64` for the existing +codec semantics and `@core/user/macroId` for validated identity formatting. The +attachment pill also reuses the static MIME/file-type map; its shared +`EntityIcon` component has the app coupling described above. Do not generalize +the pure utility exceptions to the corresponding barrels. + +## Enforcement and verification + +The three features are registered in both TypeScript and TSX versions of all four +`feature-*` ast-grep families. Additional error-level `email-no-block-dependencies` +rules reject block imports. Review cross-feature imports and transitive +dependencies when changing feature boundaries. + +Run from `apps/web`: + +```sh +bun run test src/features/email +bun run check +``` + +Run from the repository root: + +```sh +bunx --yes @ast-grep/cli@0.44.1 scan apps/web/src/features/email-message apps/web/src/features/email-thread apps/web/src/features/email-compose +just test-email-rendering +``` + +Regression coverage includes message parsing/containment and cleanup, chronological +selection and reading stops, pagination geometry, retained snapshots, draft +precedence, independent thread state, provider/frame ownership, reply target +lifetimes, account failures, secondary-inbox recipients, editor draft persistence, +scheduling failures and concurrency, mentions, and isolated undo recovery. + +Browser verification must still exercise the mounted application: hidden-message +expansion, message headers and quoted content, deep-link reveal, keyboard reply, +inline draft persistence, standalone compose, and mobile reply presentation. A +local account without a real mail-provider connection can verify local drafts +and UI behavior; actual provider delivery requires its own integration environment. + +### Renderer regression coverage + +The renderer's fixture viewer and Chromium suite call the same public preparation +and mounting API as the app. Node tests cover preparation, resource policy, CSS +recovery, quote/signature selection, colors, and width fitting. A separate +TypeScript build excludes DOM libraries from core; import checks reject app and +framework dependencies. The package Node tests also run through the app's default +Vitest projects. Browser tests cover actual layout, delayed attachment, color +round trips, collapse/expansion, URL handling, CSS cascade, and resource cleanup. + +The visual fixtures include personal calendar responses and announcements in both +themes. Personal fixtures explicitly enable color adaptation even when they +contain tables. Screenshots have a zero differing pixel tolerance within the +controlled Chromium/font environment. Standalone fixture snapshots complement +mounted-app interaction checks; they do not establish full application parity. + +Use `bun run --cwd packages/email-renderer viewer` to inspect fixtures without an +account or backend. Do not regenerate visual expectations simply to make a +refactor pass: reproduce the baseline in the same browser and explain each +remaining difference before accepting it. diff --git a/docs/FRONTEND_FEATURE_ARCHITECTURE.md b/docs/FRONTEND_FEATURE_ARCHITECTURE.md new file mode 100644 index 00000000000..2898a393b5b --- /dev/null +++ b/docs/FRONTEND_FEATURE_ARCHITECTURE.md @@ -0,0 +1,664 @@ +# Frontend feature architecture + +New frontend features use the layered structure established by +`apps/web/src/features/activity` in commit `f598574d7` (PR #6176). +Use the same structure when +restructuring an existing feature, with the stronger production-composition and +feature-contract boundaries defined here. Activity is a worked example with +documented migration gaps, not a complete implementation of every rule below. +This is the detailed companion to FE-33 in +[the style guide](STYLE_GUIDE.md). + +The goal is to make ownership visible: feature logic can run without the app, +reactive behavior can be tested without rendering, and the same visual component +can serve different surfaces. Folder names express these boundaries; moving files +without changing their dependencies does not complete a migration. + +Existing features need not all migrate in one change. Apply the boundaries to the +feature or use case being created or restructured, and keep unrelated migrations +out of scope. Create only directories that have an actual responsibility to own. + +## Contents + +- [What the activity restructure establishes](#what-the-activity-restructure-establishes) +- [Layout and dependencies](#layout-and-dependencies) +- [Layer responsibilities](#layer-responsibilities) +- [Separate production composition](#separate-production-composition) +- [Feature-owned contracts](#feature-owned-contracts) +- [Context, props, and host actions](#context-props-and-host-actions) +- [Testing the boundaries](#testing-the-boundaries) +- [Adopting the structure](#adopting-the-structure) +- [Enforcement and reference limitations](#enforcement-and-reference-limitations) +- [Review checklist](#review-checklist) + +## What the activity restructure establishes + +Before the restructure, activity rows combined generated GraphQL types, entity +display lookups, split navigation, and markup. The merge separates these concerns: + +| Before | Implementation in the reviewed merge | +| --- | --- | +| Feature vocabulary tied to GraphQL fragments and `__typename` | `core/event.ts` owns `ActivityEvent` and action unions; `queries/decode.ts` translates transport data. | +| Query flags interpreted directly throughout screens | `primitives/my-activity.ts` exposes feed and overview states plus `loadMore`. | +| A row looks up entity metadata and opens documents itself | `views/activity-timeline-row.tsx` resolves display and handlers; `components/activity-timeline-row.tsx` renders supplied values. | +| Shared app dependencies imported at use sites | `context/activity-context.tsx` defines the capability contract and production wiring. | +| Navigation coupled to the reusable row | The host passes `onOpen`; `open-entity-in-split.ts` implements the app's split behavior. | +| Activity-specific fetching under shared queries | The owning feature contains its query factories and decoding; shared clients and query infrastructure remain shared. | + +The feed, entity side panel, and AI tool results demonstrate different compositions +of the same feature. The AI tool renderer translates its own response into +`ActivityEvent`; the reusable row does not need to understand the tool's transport. + +Two aspects of that merge need a further boundary: its context still includes +production wiring, and its state primitives construct concrete GraphQL query +factories using an injected client. The target below separates production +composition and supplies narrow feature-owned sources to reactive logic. + +## Layout and dependencies + +```text +apps/web/src/features// + core/ Feature vocabulary and pure transformations + queries/ Wire decoding and feature-owned query/mutation orchestration + primitives/ Reactive state and actions, without JSX + components/ Presentational JSX driven by props + views/ Use-case composition of context, primitives, and components + context/ Feature contracts and provider/consumer mechanism + tests/ Shared test clients, context builders, and wire helpers + .tsx App-facing production composition and provider mounting + use-.ts Optional rollout gate used at the mounting boundary + .ts Optional app integration, such as open-entity-in-split.ts +``` + +Tests normally live beside the module they exercise as `*.test.ts` or +`*.test.tsx`. `tests/` holds helpers shared across those tests. Activity also keeps +transport fixtures in `queries/fixtures.ts`. + +In this diagram, **an arrow means “imports or depends on.”** + +```mermaid +flowchart TD + Host[Host / route] --> Wiring[Production entry point] + Wiring --> Views[views] + Wiring --> Queries[queries: adapters] + Wiring --> Context[context: provider and consumer] + Wiring --> Action[Host action adapter] + Wiring --> App[App capabilities] + Views --> Primitives[primitives] + Views --> Components[components] + Views --> Context + Views --> Core[core] + Primitives --> Core + Queries --> Core + Components --> Core + Context --> Contracts[Feature contracts] + Contracts --> Core + Primitives -. type contract .-> Contracts + Queries -. implements contract .-> Contracts + Components -. display types only .-> Contracts + Action --> App +``` + +This is a dependency direction, not a requirement to pass every value through +every layer. A view can use a core type directly. A small primitive can return an +accessor without creating a query. A static feature may need only core and +components. Feature layers must not introduce dependency cycles, including through +barrels or aliases. + +Contracts can live in `context/` or a small dedicated module. They must not import +the adapters that implement them. The graph represents the target dependency +direction; the reviewed activity merge still imports queries from primitives. + +Use the reference's descriptive kebab-case module names and plural layer names +when introducing this layout. Exported Solid components remain PascalCase; +reactive factories commonly use `create…`. There is no requirement for a root +`index.ts` or a barrel for every directory. Direct imports make the chosen layer +visible, including the distinction between a presentational row and its composed +view with the same name. + +## Layer responsibilities + +### `core/`: feature vocabulary and pure computation + +Own domain types, discriminated unions, grouping, descriptions, statistics, and +other transformations of explicit inputs. Core must not import Solid, JSX, +query clients, generated GraphQL types, app contexts, or the feature's other +layers. Framework-independent libraries such as `date-fns` and `ts-pattern` are +appropriate here. + +Define only the vocabulary the feature uses. Activity's domain model has +`{ kind: 'created' }`, not `{ __typename: 'GraphqlActivityCreated' }`. Unknown +actions and unsupported entities have explicit representations so the display +can degrade gracefully. Untyped property payloads remain `unknown` until the +appropriate adapter parses them; a type assertion does not validate wire data. + +Keep JSX and visual choices outside core. For example, core computes an intensity +level; a component chooses the corresponding color and icon. Tests of grouping +or descriptions should need ordinary inputs and assertions, without an app +provider or mocked service. + +See [event.ts](../apps/web/src/features/activity/core/event.ts), +[describe-action.ts](../apps/web/src/features/activity/core/describe-action.ts), and +[intensity.ts](../apps/web/src/features/activity/core/intensity.ts). + +### `queries/`: adapt transport and own query mechanics + +Keep feature-specific query factories, request variables, pagination, selectors, +decoders, and related mutation/cache orchestration here. Shared server-state +operations remain in `src/lib/queries`; network transport and generated clients +remain in `src/lib/service-clients`. Reuse that infrastructure instead of adding +`fetch` calls to a view or inventing a second cache. + +Query adapters receive their concrete dependencies from production composition. +An urql client is appropriate as an adapter input; it should not become the +reactive feature consumer's capability contract. Adapters implement the narrow +sources described in [Feature-owned contracts](#feature-owned-contracts). + +The current [feed-query.ts](../apps/web/src/features/activity/queries/feed-query.ts) +illustrates query mechanics and decoding but still accepts the old context's +`graphql` field. Generated documents and transport types are appropriate in this +layer. Return feature models to consumers instead of leaking fragments into +rendering logic. Project typed DTOs explicitly with names such as `toEmailThread`; +reserve `decode`/`parse` for actual decoding or validation. Keep only consumed +fields, and return small domain results instead of unnecessary transport envelopes. +Keep differences such as missing entity versus found entity with no history +explicit, as in +[select-entity-activity.ts](../apps/web/src/features/activity/queries/select-entity-activity.ts). + +Query inputs that can change should stay reactive. Pause unsupported or incomplete +requests through the existing query wrapper's enabled mechanism. Choose stale-data +behavior deliberately: activity retains feed pages while loading more, but its +entity query uses `keepPreviousData: false` to avoid showing another entity's +history after an ID change. + +The layout does not mandate a query-library migration. Activity uses the existing +urql Solid wrappers. For TanStack queries, preserve the repository's key, cache, +and invalidation conventions. Do not add a second generic mutation wrapper over +that library. Controllers can own workflow phases and ordering across operations; +adapters retain mutation/cache mechanics. Separate request failure from errors in +post-success presentation or cache work, and catch detached promise rejections. +Refresh/pagination capabilities used for sequencing return promises that cover +the underlying request. Queries and primitives must not import rendering code. + +### `primitives/`: reactive behavior without rendering + +Compose feature-owned sources, core transformations, and injected capabilities +into the state and actions a use case needs. Use Solid accessors, signals, and memos where +appropriate; do not return JSX or import components/views. + +Reactive decisions depend on source contracts rather than importing concrete query +adapters. A primitive can receive an already-created source directly; it does not +need the whole feature context. Small pure/reactive helpers need no data-source +interface when they have no infrastructure dependency to separate. + +Screen-sized primitives expose explicit view-state unions and named actions: + +```ts +type FeedView = + | { t: 'loading' } + | { t: 'error' } + | { t: 'empty' } + | { t: 'ready'; groups: FeedGroup[]; hasMore: boolean; loadingMore: boolean }; + +type MyActivityState = { + feed: Accessor; + loadMore: () => void; +}; +``` + +Choose states that represent the actual use case; not every primitive needs all +four variants. `createActorName` returns a string accessor, and +`createEntityOpener` returns a display/handler accessor. A name resolver does not +need a fabricated loading/error state machine. + +Keep precedence decisions here so every consumer agrees about loading, errors, +empty data, and existing data during refetch. Activity's feed and overview retain +available content through background failures; its entity section handles query +errors and missing entities as unavailable. These are deliberate use-case +decisions, not a universal ordering of query flags. + +Accept sources/capabilities explicitly and narrow dependency records with `Pick` +where practical. Keep changing entity IDs and other inputs as accessors instead +of eagerly snapshotting props. +Use derived accessors for cheap computations and memos for expensive derivations +or referential stability. Follow the existing Solid guidance on avoiding effects +for derived state and guarding resource reads. Activity's urql behavior does not +override the warnings about eager TanStack `query.data` reads in +[apps/web/AGENTS.md](../apps/web/AGENTS.md). + +See [my-activity.ts](../apps/web/src/features/activity/primitives/my-activity.ts) and +[entity-opener.ts](../apps/web/src/features/activity/primitives/entity-opener.ts). + +### `components/`: props in, JSX out + +Render domain values, resolved display data, slots/children, and event callbacks +provided by the caller. Components can use Solid control flow and local +presentation derivations; “presentational” does not mean “cannot use Solid.” +For example, `ActionGraph` computes its grid from a supplied overview. + +Components must not create queries, invoke feature primitives, consume the +feature's capability context at runtime, or decide how the app navigates. +Type-only imports of display contracts from `context/` are allowed. Reuse shared +UI components, icons, formatting, and clearly scoped presentation contexts. +A reusable control must not require a consuming use case's context to function. + +Prefer composition and event handlers over mode flags that embed several host +workflows. A row can take resolved `display`, `propertyDefinition`, and `rowProps`; +it should not take an entity ID and secretly resolve all its app dependencies. +Resolve shared metadata once in the composed owner and pass it to its children. + +See the presentational +[activity-timeline-row.tsx](../apps/web/src/features/activity/components/activity-timeline-row.tsx) +and [top-entities.tsx](../apps/web/src/features/activity/components/top-entities.tsx). + +### `views/`: compose a use case + +Read the feature context, invoke its source factories under the consuming Solid +owner, pass sources to primitives, and render components from their state. +Views own the composition for a feed, side panel, dialog, or +tool row; they need not be full pages. Keep query-result interpretation and +reusable domain computation in primitives/core. + +Views may compose shared layout and presentation providers. Small layout helpers +and local UI state can remain with their only consumer: activity's side panel +owns its “Show all” toggle locally. The architecture does not require a separate +primitive for every signal or a separate exported component for every wrapper. + +The two `activity-timeline-row.tsx` files show the intended split. The +[view](../apps/web/src/features/activity/views/activity-timeline-row.tsx) obtains +entity display, property definitions, and callback-based handlers, then passes +them to the presentational row. External hosts choose the view when they want +that wiring, and the component when they already have resolved values. + +### `context/`: the feature's capability contract + +Define the ambient capabilities each consumer needs. A production entry point may +group them into a context for views to wire, but reusable controllers receive +only their named contracts. Do not pass a complete screen context into every +helper or controller. Keep provider/consumer modules under `context/`. Keep the contracts and provider/consumer free +of production imports. The provider transports capabilities; production composition +constructs them. Direct arguments also work when context adds no value. + +Name a feature context for what it is: `EmailComposeContext`, `composeContext`, +and `createEmailComposeContext`. Use `context` for its component prop and +`use…Context` for its context consumer. Avoid `deps` and `environment` aliases for +these objects. A source, storage operation, or command should retain its specific +name; calling the containing object a context does not require passing all of it +to every consumer or adding another provider. + +Use accessors and resolver functions to preserve reactivity. Activity's +`currentUserId`, `displayName`, `entityDisplay`, and `propertyDefinition` illustrate +useful capability signatures. Replace its raw `graphql` capability for state +consumers with narrow feature sources as described below. Display contracts can +include a resolved icon accessor; they do not have core's purity requirement. + +The existing [activity-context.tsx](../apps/web/src/features/activity/context/activity-context.tsx) +combines this contract with a production fallback. That is a migration gap. + +## Separate production composition + +The app-facing entry point assembles the real adapters and supplies them to the +feature. Importing the feature context or state must not initialize app services, +sockets, workers, or global listeners. A fallback such as +`useContext(Context) ?? appContext()` delays a function call but still imports the +module's production dependencies, even when a test supplies its own context. + +The following snippets illustrate the target; they are not the current activity +implementation. Imports and unrelated display capabilities are omitted for focus. + +```tsx +// context/activity-context.tsx +const Context = createContext(); +export const ActivityProvider = Context.Provider; + +export function useActivityContext(): ActivityContext { + const context = useContext(Context); + if (!context) throw new Error('ActivityProvider is required'); + return context; +} +``` + +```tsx +// activity.tsx — production entry point +export function Activity() { + const context = createAppActivityContext(); + return ( + + + + ); +} +``` + +`createAppActivityContext` is production wiring: it connects query adapters +to the real client and display capabilities to the app's resolvers. It may live +in the entry-point module or a separate production module as its size warrants. +The context, views, and primitives must not import it. Production callers mount +one convenient ``; tests import `MyActivityView` and supply their own +provider. Missing provider setup fails clearly instead of reaching real services. + +Run hook-based construction under its intended Solid owner. Gate the mounting +boundary before initializing feature resources. Separating composition needs no +DI framework or class hierarchy, and it is useful even before replacing raw-client +injection with source contracts. Shared widgets can still import app infrastructure; +inspect those dependencies separately rather than assuming this change removes +all test module stubs. + +## Feature-owned contracts + +Introduce a contract where meaningful feature behavior needs independence from +its infrastructure. The feature defines the domain values, operations, and status +it needs. The adapter implements them using the existing query library. This is +dependency inversion: the consumer owns the interface, and the implementation +depends on that interface. + +For example, activity feed behavior needs events, request status, and pagination. +It does not need a generic GraphQL executor. An illustrative contract is: + +```ts +export type ActivityFeedSource = { + /** Undefined until data is available for the current input; [] is a loaded empty feed. */ + events: Accessor; + /** Initial loading, distinct from loading another page. */ + isLoading: Accessor; + /** May coexist with available events after a background failure. */ + error: Accessor; + hasMore: Accessor; + isLoadingMore: Accessor; + loadMore(): void; +}; + +export type ActivityContext = { + createFeed(): ActivityFeedSource; +}; +``` + +The adapter maps decoded events and query state into this contract. Documents, +variables, transport errors, opaque cursor handling, and cache mechanics stay in +the adapter. Retain the existing client and query wrappers; do not add a second +cache. If the feature distinguishes failure categories, define those categories +in its contract and translate errors in the adapter instead of exposing urql errors. + +The primitive receives a source and owns presentation decisions. The view connects +the source factory to the primitive: + +```tsx +const context = useActivityContext(); +const state = createMyActivityState(context.createFeed()); +``` + +Here `createMyActivityState(feed: ActivityFeedSource)` groups events and decides +whether to show loading, errors, empty content, or existing rows. The adapter +reports that data is available and a background request failed; the primitive +decides to keep showing the rows. Do not put grouped rows or final screen view-state +in the source contract and thereby move feature policy into the adapter. + +Accept Solid accessors, factories, and ownership as the reactive foundation. +Do not abstract Solid into a custom framework. A source contract must preserve +updates, enabled inputs, pagination, resource-read semantics, and cleanup. Describe +whether `undefined` means no data yet and whether existing data can coexist with +an error. For sources with changing parameters, accept reactive inputs. Instantiate +factories under the consuming owner and ensure disposal releases subscriptions; +do not create global feature-state singletons or replace a live source with a +promise that loses its lifecycle. + +Keep contracts narrow and selective. Do not expose concrete clients, generated +GraphQL response types, or library-specific query results to state consumers. +Pass a source directly or a small `Pick` of dependencies. Existing resolver +functions can already be adequate contracts; they need no additional wrapper. +A generic `Repository` or an interface around every helper adds no value when +there is no independent behavior to protect. A static/pass-through feature does +not need a manufactured state layer and source interface. + +The practical test is whether a change stays with its owner: + +| Change | Expected scope when semantics are otherwise unchanged | +| --- | --- | +| GraphQL response field or cursor encoding changes | Adapter and adapter tests | +| Feed changes its handling of background errors | Primitive and behavior tests | +| Row appearance changes | Presentation | +| A different app surface embeds the feature | Host composition and callbacks | + +Activity's current primitives still construct concrete query factories and their +tests drive a fake GraphQL client. Keep those as useful integration coverage while +adding source-based unit tests when migrating the boundary. + +## Context, props, and host actions + +Classify a new dependency by what it means: + +| Value or behavior | Owner | Activity example | +| --- | --- | --- | +| Ambient capability used consistently on every surface | Feature context contract; separate production wiring | Activity source factory, viewer identity, display-name lookup | +| Data identifying this instance | Props, passed as accessors to reactive factories | Entity ID and entity type | +| Derived state for a screen | Primitive | Grouped feed and pagination state | +| Already resolved display for one leaf | Component props | Actor name or property definition | +| Policy chosen by the embedding surface | Callback prop supplied by the host | `onOpen(target)` | +| Environment-derived request value | Compute at the relevant boundary | Browser time zone in overview query options | +| Rollout decision | Mounting wrapper or host | `EntityActivitySectionConditional` | + +Do not add `feedRows`, `actorLabel`, or another single consumer's prepared result +to context when it can be derived from existing capabilities. Do not inject every +environment value speculatively; add only capabilities the feature actually needs +to replace. Activity computes the browser time zone in the query layer. + +For navigation, the primitive translates an interaction into a target and the +host decides what to do with it. `createEntityOpener` passes `block`, `id`, +`params`, and `newSplit` to `onOpen`; the host's +[open-entity-in-split.ts](../apps/web/src/features/activity/open-entity-in-split.ts) +calls the app navigation API. Ordinary feature layers do not import that API. +Shared event-translation helpers are allowed in primitives when they delegate +the actual action to the supplied callback. + +When a reusable row's `onOpen` is omitted, it must not install the host's row-open +handlers. This is a contract about that row action; it does not prove that nested +shared widgets have no interactions of their own. If a surface must be entirely +inert, verify the rendered descendants as well. + +Feature flags stay outside the capability contract and gate the component that +creates state. Hiding JSX after creating queries is too late. The activity side +panel mounts no query while disabled; the feed's route wrapper handles its own +rollout state. Preserve route registration and fallback behavior during migrations. + +## Testing the boundaries + +Framework-independent behavior can live in a standalone workspace library when +it should run without a feature's reactive runtime. Keep deterministic +transformations in core and browser effects behind a separate browser export; +the Solid feature translates accessors to plain values and registers disposal. +Supply policy and host capabilities explicitly. Do not let the library import +app flags, clients, routes, or block signals through a convenience barrel. + +[`packages/email-renderer`](../packages/email-renderer/README.md) demonstrates +this split: HTML/CSS preparation compiles without DOM libraries and runs under +Node; the browser layer handles Shadow DOM, computed colors, layout and resource +lifetimes; the email-message adapter retains Solid and Macro Markdown integration. +Its vanilla fixture viewer and browser tests call the exact production API. +Pure output determinism and pixel determinism are different guarantees: browser, +fonts, viewport, theme, and resources must also be controlled for screenshots. + +Test observable behavior at the layer that owns it. Dependency injection should +make feature behavior testable without module-mocking the real query factories, +current-user hook, or display resolvers. + +| Layer | Test shape | Useful assertions | +| --- | --- | --- | +| Core | Ordinary Vitest tests with domain inputs | Grouping, descriptions, calendar boundaries, unknown cases | +| Projection/decode/select | Wire fixtures passed to pure adapters | Domain output, missing versus empty, unsupported variants, malformed property payloads | +| Query adapters | `createRoot` with a fake client; dispose roots after each test | Request variables, enabled gates, decoding, cursors, cache behavior, source lifecycle | +| Primitives | `createRoot` with fake feature sources and controlled domain data/status | Loading/error/ready decisions, grouping, background failures, pagination actions, changing inputs | +| Components/views | Testing Library; feature provider for composed views | Visible states, emitted callbacks, pagination controls, interaction behavior | +| Host integration | Browser verification on the relevant surfaces | Flag gates, mounting, real mentions, navigation, focus/scroll behavior | + +The reference's +[mock-context.ts](../apps/web/src/features/activity/tests/mock-context.ts) supplies +defaults and overrides for the capability record. +[mock-graphql.ts](../apps/web/src/features/activity/tests/mock-graphql.ts) exposes +pending operations that tests resolve or fail, exercising the real query and +primitive code. Its fake implements the query methods those tests need; extend a +fake for new operations rather than assuming it is a complete urql client. + +Source-based primitive tests should not name GraphQL operations or construct wire +responses to exercise a feature decision. A fake source with event and error +signals can test retention of rows through background failure. Adapter tests +separately verify wire behavior; retain integration tests that exercise the real +adapter and primitive together. Test the real implementation of the layer under +test and replace its dependencies through the contract. + +Check that context/state imports work without production-service quarantine and +that omitted provider setup fails clearly. Use existing import smoke tests where +available, or inspect and exercise the import boundary when migrating it. + +See [my-activity.test.ts](../apps/web/src/features/activity/primitives/my-activity.test.ts) +and [my-activity-view.test.tsx](../apps/web/src/features/activity/views/my-activity-view.test.tsx). +The current view test still stubs shared layout/Markdown UI and quarantines +websocket import-time effects with `vi.mock`. Those are integration limitations; +feature data and capability behavior are supplied through the provider. Do not +turn those stubs into a pattern for replacing the feature logic under test. + +For a frontend implementation change, run the relevant tests and checks from +`apps/web` with dependencies installed: + +```bash +\cd apps/web +bun run test src/features/activity +bun run check +``` + +Replace the test path with the feature under change. Exercise user-visible changes +in a browser using [the frontend instructions](../apps/web/AGENTS.md). Documentation +changes alone do not require bringing up a frontend or backend stack. + +## Adopting the structure + +1. **Map the existing use cases and consumers.** Find routes, side panels, tool + renderers, flags, tests, and external imports. Identify app capabilities and + behavior that varies by host. Review imports as well as the folder tree. +2. **Establish the domain boundary.** Extract feature vocabulary and pure + transformations into core. Decode generated transport types at their source + boundary, including non-query inputs such as AI tool responses. +3. **Separate production composition.** Keep contracts and the provider/consumer + free of production imports. Wire real adapters in an app-facing entry point + and make missing provider setup fail clearly. Build a test provider without + importing the production entry point. This step is useful independently of + changing the data-source contract. +4. **Separate query mechanics and reactive behavior.** Define narrow feature-owned + sources for meaningful state decisions, implement them with adapters in + `queries/`, and pass sources to primitives. Retain shared query infrastructure. + Preserve enabled conditions, cache behavior, cursors, resource-read semantics, + owner cleanup, and entity-switch behavior. Keep view-state decisions in primitives. +5. **Separate presentation and composition.** Make leaves render supplied values; + let views connect primitives and context. Move host-specific actions to callback + props and wire app implementations at the mounting boundary. +6. **Update all consumers.** Follow moved imports in production, tests, lazy route + loaders, and tool renderers. Preserve required presentation providers. Remove + obsolete modules once callers are migrated; avoid barrels that reintroduce + dependencies on the entire feature. +7. **Register enforcement and verify.** Add the adopting feature's layer globs to + both TypeScript and TSX feature rules below. Test the changed behavior, inspect + warnings, and exercise affected UI surfaces. Update + [the agent interaction guide](AGENT_GUIDE/README.md) if routes or interaction + behavior changed; a file move alone does not change that guide. + +## Enforcement and reference limitations + +The rules live in [rules/ast-grep](../rules/ast-grep), configured by +[sgconfig.yml](../sgconfig.yml). Each family has a `ts-` and `tsx-` rule because +the scanner treats those languages separately. When a feature adopts the layout, +update `files` in all eight rule files with the corresponding layer paths: + +| Rule family | Layer globs to register | Main check | +| --- | --- | --- | +| `feature-core-pure` | `core/**` | No framework, generated GraphQL, app, or sibling-layer dependencies | +| `feature-components-presentational` | `components/**` | No query/primitives/views or navigation-helper imports | +| `feature-data-no-ui` | `queries/**`, `primitives/**` | No rendering-layer imports | +| `feature-layers-use-context` | `queries/**`, `primitives/**`, `components/**`, `views/**` | No direct imports of the listed ambient app capabilities | + +From the repository root, the existing scanner invocation can be scoped to a feature: + +```bash +bunx --yes @ast-grep/cli@0.44.1 scan apps/web/src/features/activity +``` + +These rules currently have warning severity and their `files` lists cover +activity and the three email features. They do not automatically enforce every +feature directory, resolve the whole transitive import graph, or prove that a +component is presentational. +Passing the scan is supporting evidence, not a complete architecture review. +The current rules also do not enforce separation of production composition or +the dependency inversion between primitives and query adapters. Those remain +explicit review obligations until structural checks cover them. This document +defines the target; it does not claim the existing checks enforce all of it. +Inspect aliases, re-exports, and runtime behavior too. Keep new exceptions narrow +and documented instead of weakening a rule to permit a new dependency leak. + +The reference has specific qualifications worth preserving accurately: + +- `context/activity-context.tsx` imports production capabilities and supplies an + automatic fallback. Migrate that wiring into an app-facing entry point; the + shared contract/provider should have no production imports or fallback. +- `primitives/my-activity.ts` constructs concrete query factories using the + context's urql client. Its tests therefore drive GraphQL operations. Migrate + meaningful reactive decisions to feature-owned sources and keep those existing + tests as integration coverage. This is not full dependency inversion today. +- `core/group-events.ts` imports the pure `dateBucket` module from soup so feed + labels match other views. The rule documents this exception. It does not permit + importing the soup barrel or arbitrary feature infrastructure into core. +- `components/property-change.tsx` imports pure wire-value parsing/formatting from + `queries/property-value.ts`. The presentational rules explicitly allow that + path. It is not permission to execute a query from a component. For new code, + prefer returning display-ready data at the adapter boundary rather than copying + this exception by default. +- Shared UI may carry its own runtime requirements. Activity's entity mention uses + `StaticMarkdown` and requires `StaticMarkdownContext`; the view tests stub it. + The branch establishes a feature dependency seam, not complete isolation from + every transitive app module or shared widget. +- View-state unions describe use-case state, while small helpers can return simple + accessors. Activity does not establish a mandatory union for every primitive. +- The navigation adapter and flag hooks intentionally remain at the feature root. + The layout does not require every file to live inside a layer directory. + +These qualifications describe the reviewed merge. They should not grow into +general exceptions without a concrete need and an updated explanation. + +## Review checklist + +- Does each changed module's responsibility match its layer, beyond its path? +- Can core run without Solid, generated transport types, or app setup? +- Are transport inputs decoded before feature logic and presentation use them? +- Are contracts and provider/consumer modules free of production imports, with + real adapter construction owned by an explicit app-facing entry point? +- Does missing provider setup fail clearly instead of reaching production services? +- Do meaningful reactive decisions depend on feature-owned sources instead of + concrete query adapters, generated responses, or a raw client? +- Do adapters report data availability while primitives own presentation policy? +- Does each new interface protect independent behavior rather than add forwarding? +- Does context contain ambient capabilities, while instance data and host policy + stay in props and callbacks? +- Do components render supplied values and handlers without querying, consuming + feature capabilities, or choosing app navigation? +- Do primitives own meaningful data-state transitions, and do views compose them + without rebuilding those decisions? +- Are enabled gates, background updates, pagination, and entity changes preserved + and tested where the change can affect them, including owner cleanup and + resource-read semantics? +- Can tests replace capabilities through the contract while exercising real + feature logic? Can primitive unit tests use domain data/status while separate + adapter tests verify wire behavior? Are any remaining integration stubs explained? +- Are external consumers, route wrappers, presentation providers, and both lint + language variants covered by the migration? + + +## Email application of the stronger boundaries + +[Email feature architecture](EMAIL_FEATURE_ARCHITECTURE.md) documents the complete +`block-email` experiment: separate `email-thread`, `email-message`, and +`email-compose` ownership, a thin block adapter, production entry points, domain +sources, and tests of imports and instance lifetimes. Use it alongside activity +when applying the composition and source-contract rules. + +The four feature-rule families now cover these email packages as well as activity. +Email additionally has error-level rules against block dependencies and an import +graph regression test. Its two narrow shared pure utility exceptions are the +base64 codec and validated Macro identity helper; see the email document for the +remaining shared UI integration limits. diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md index 8e447bb85ee..234e9cfce2c 100644 --- a/docs/STYLE_GUIDE.md +++ b/docs/STYLE_GUIDE.md @@ -257,29 +257,40 @@ TypeScript · `[ui]` UI / UX conventions - **FE-32** `[ui]` Prefer styling in the component (Tailwind on the markup). Reserve `@utility` in `apps/web/src/index.css` for styles widely shared across many components — not one-off or two-callsite layouts. (#6038 · also: apps/web/AGENTS.md) -- **FE-33** `[arch]` Layered feature layout. A feature that adopts it (today: - `features/activity`) is split into `core/` (pure TS: types and functions, no - Solid, urql, generated GraphQL, or app modules), `queries/` (decode wire types - and build query factories that take the feature's context), `primitives/` - (reactive view models: Solid primitives, no JSX; each returns a view-state - union such as `loading | error | empty | ready` plus actions), `components/` - (props in, JSX out; no queries, primitives, or navigation), `views/` (compose - context, primitives, and components), `context/` (the injection seam), and - `tests/` (mocks shared by the feature's tests). Every ambient capability the - feature needs the same way on every surface (GraphQL client, viewer id, display - names, entity display, property definitions) is a field on one `Context` type - in `context/`. `useContext()` reads an optional Solid context and falls back to - the app wiring defined in the same file, so production mounts no provider; - tests mount `ContextProvider` with mocks. Keep the record to what the feature - must swap in tests; a value derivable from the environment (time zone) or - already shaped for one consumer does not belong in it. Behavior that varies - per surface (what a row click opens) is a callback prop from the host, so an - inert surface simply omits it. `primitives/` run under `createRoot` against a - mock client and `views/` render without `vi.mock`. The import graph is - one-way: `core` → `queries` → `primitives` → `views` and - `core` → `components` → `views`; `components` may import types from - `context/`. Feature flags gate mounting at the root and stay outside the - context. To adopt, add the feature's layer paths to `files` in each - `*-feature-*` rule. (enforced: ast-grep `ts-/tsx-feature-core-pure`, +- **FE-33** `[arch]` New features and feature restructures use the + [layered feature architecture](FRONTEND_FEATURE_ARCHITECTURE.md), with + `features/activity` as the reference. Use `core/` for pure feature types and + functions, `queries/` for wire adapters and query factories, `primitives/` for + reactive state/actions without JSX, `components/` for presentation from props, + and `views/` for use-case composition. `context/` holds feature-owned capability + contracts and the provider/consumer, without production imports or fallback. + An app-facing entry point constructs real adapters and supplies the provider; + missing provider setup fails clearly. Meaningful reactive decisions depend on + narrow feature sources, not concrete query adapters or raw clients. Adapters + implement those contracts using existing query/cache infrastructure; Solid + accessors and ownership remain part of the reactive foundation. Per-host behavior + belongs in callback props, and flags gate mounting outside the capability contract. + Primitives and adapters depend on feature-owned contracts; views depend on + primitives and components, and core stays independent. Components may import + context display types only. Keep data availability in source adapters and + presentation decisions in primitives; avoid interfaces with no independent + behavior to protect. + Controllers receive only their named capabilities; production environments and + presentation wiring stay in views. Reuse query/mutation infrastructure rather + than adding a generic async wrapper. Keep request failures distinct from errors + after a successful write, and require promises when callers depend on completion. + Use view-state unions when the use case has distinct states; small helpers can + return accessors. + Test feature behavior through injected capabilities, with shared helpers in + `tests/`. Use fake sources for primitive unit tests and fake clients for adapter + tests, retaining integration coverage of both. Activity still has combined + context/production wiring, raw-client injection, and UI/import-side-effect + stubs; these are documented migration gaps. + The detailed document covers layer responsibilities, narrow reference exceptions, + adoption, and review. + Register adopting feature paths in both language variants of every feature + rule. (enforced: ast-grep `ts-/tsx-feature-core-pure`, `ts-/tsx-feature-components-presentational`, `ts-/tsx-feature-data-no-ui`, - `ts-/tsx-feature-layers-use-context`, warning) + `ts-/tsx-feature-layers-use-context`, warning; currently scoped to activity. + Separate production composition and source-contract inversion still require + explicit review; the existing rules do not enforce them.) diff --git a/justfile b/justfile index 5734d43df0c..4f989ac1794 100644 --- a/justfile +++ b/justfile @@ -51,8 +51,8 @@ local-e2e-seed: just initialize_dbs just tooling/seed_cli/local-e2e-smoke -# Email rendering snapshots (Playwright HTML fixtures, not inbox e2e). -# Add a fixture under apps/web/src/lib/core/email/tests/fixtures, then +# Email rendering Node tests and Chromium fixtures (not inbox e2e). +# Add a fixture under packages/email-renderer/tests/fixtures, then # `just test-email-rendering-update`. test-email-rendering: just apps/web/test-email-rendering diff --git a/nix-support/node_modules-hashes.json b/nix-support/node_modules-hashes.json index dc53cab7797..d87e2abdba9 100644 --- a/nix-support/node_modules-hashes.json +++ b/nix-support/node_modules-hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { - "aarch64-darwin": "sha256-ZeIdCH43BVjKXMZke9G+v/RVR3mGik30lOB118ILMC8=", - "x86_64-linux": "sha256-uJmtOAhsabQMR3kWlGG5mAgOOjr46eGbvFSq+nvCD9M=" + "aarch64-darwin": "sha256-7ZvwLUFReCp0oAJEtXXYQn7EGnEFsmb3MGJNmKxOKtY=", + "x86_64-linux": "sha256-N4Tnz5s9gMuoIig8nNQ1h1sjlitPR2+OX4xNjzgpWZU=" } } diff --git a/package.json b/package.json index 6f96f58c5cb..c05f3a40f14 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "packages": [ "apps/web", "packages/observability", + "packages/email-renderer", "packages/collaboration", "packages/loro-mirror", "packages/lexical-core", diff --git a/packages/email-renderer/.gitignore b/packages/email-renderer/.gitignore new file mode 100644 index 00000000000..55624f78c3f --- /dev/null +++ b/packages/email-renderer/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +dist/ diff --git a/packages/email-renderer/README.md b/packages/email-renderer/README.md new file mode 100644 index 00000000000..c9aaa485a94 --- /dev/null +++ b/packages/email-renderer/README.md @@ -0,0 +1,171 @@ +# Email renderer + +This package owns rendering an individual HTML or plaintext email body. It has no +Solid, application, thread, block, query, authentication, or transport dependency. + +There are two entry points: + +- `@macro-inc/email-renderer`: deterministic HTML/CSS preparation, quote and + signature selection, and serializable output. It runs in Node without DOM APIs. +- `@macro-inc/email-renderer/browser`: Shadow DOM mounting, computed color + adaptation, font normalization, width fitting, image visibility, and cleanup. + It requires a browser, but no framework. + +```ts +import { prepareEmailBody } from '@macro-inc/email-renderer'; +import { mountEmailBody } from '@macro-inc/email-renderer/browser'; + +const body = prepareEmailBody( + { html, replylessHtml, text }, + { images: { remote: 'block' } } +); +const options = { + theme: { + inkL: 0.2, inkC: 0, inkH: 0, panelL: 0.98, + accentL: 0.5, accentC: 0.15, accentH: 250, + }, + adaptColors: !body.hasTable, + normalizeFonts: false, +}; +const renderer = mountEmailBody(host, body, options); +renderer.setExpanded(false); +renderer.update(prepareEmailBody({ html }, { showQuotedContent: true }), options); +renderer.dispose(); +``` + +## Responsibility and dependency rules + +`src/core` may import other core modules and the pinned HTML/CSS parsers. It must +not import the browser entry point or read ambient theme, origin, flags, time, +network, or storage. Its TypeScript configuration excludes DOM libraries; its +tests use the Node environment. The package's import-boundary test checks every +production import and re-export, including literal dynamic imports. +These Node tests also run in the web app's default Vitest project list, including +the existing CI job. A regression test compiles core without DOM libraries, so +the framework boundary is checked during the ordinary frontend test run. + +`src/browser` consumes prepared content. Only pass output from `prepareEmailBody` +to `mountEmailBody` or `update`; prepared HTML is a trusted intermediate value, +not another untrusted input boundary. The browser layer owns one host's shadow +tree and all listeners, resize observers, and resource lifetimes it starts. +The host can be attached before mounting or inserted later by the framework. +Color preparation waits until it is connected and runs once per content update; +resizes and image loads then only refit layout. A microtask handles synchronous +insertion, and ResizeObserver handles later attachment without polling. Color +preparation does not depend on animation frames being scheduled. + +`update` replaces the current content and aborts its resource generation. It +restarts color processing from the original prepared HTML, so toggling themes +does not accumulate transformations. `setExpanded(false)` hides images and applies +a three-line CSS text clamp inside its containment boundary. Tables and other +atomic layouts may remain taller; this is not a fixed-height preview. Expansion +removes the text clamp and restores width fitting. These toggles preserve content +and resource identity. `dispose` is idempotent, aborts work, disconnects observers, +removes listeners, and empties the shadow tree. Updating a disposed +renderer does nothing. Mount a new instance into a new host. + +The host supplies `resolveImages(root, lifetime)` for authenticated or CID image +resolution, and optionally `prepareLinks(container)` for application navigation. +An asynchronous adapter must check `lifetime.signal.aborted` after each await +before mutating nodes. Register owned blob URLs or other handles through +`lifetime.onDispose`; registration after disposal releases them immediately. +Handle an image failure in the adapter or supply `onResourceError` for reporting. +There is no implicit fetch client or native-platform detection in this package. + +## Content policy + +- Nonempty HTML takes precedence over plaintext. Plaintext is escaped and rendered + literally with preserved line breaks when using this package directly. The + application's plaintext fallback retains its existing Markdown renderer. +- Prefer supplied replyless HTML. If absent/empty, derive it by removing the + first recognized `.macro_quote`; otherwise show the full body. Missing + generated replyless data must never blank a valid HTML message. +- Collapse recognized Gmail and Macro signatures and trailing breaks by default. + `showQuotedContent` restores the original body, signature, and trailing breaks. + `showFullContent` chooses the full body while retaining signature trimming. +- Parse with parse5 before DOM insertion. Drop active/embedded markup, form + controls, handlers, dangerous URL schemes, and application-sensitive attributes. + Excessive nesting is flattened before serialization to bound tree depth while + preserving readable content. Excessively deep containers are unwrapped, so + their wrapper styles and layout relationships are not preserved. + CSS is parsed with css-tree. Drop imports, external fonts, animations, host + selectors, and unparsed/unsupported constructs. Recover at declaration or rule + boundaries: a malformed declaration must not erase unrelated valid styling. + Keep supported grouping rules, custom-property names, values, and fallbacks. + The normal browser cascade, including inherited variables, still applies. + Reader preparation removes only top-level `prefers-color-scheme` media rules + in head styles, matching the previous reader. Nested rules and body styles + remain. Shared sanitization used by replies/forwards preserves safe theme rules; + display policy must not modify outgoing quoted HTML. Shared sanitization also + preserves inert `data-*` metadata needed to reimport authored quotes into the + editor (mentions, indentation and scaled media). Reader preparation strips + those attributes before mounting; active markup and event handlers are removed + in both paths. Encoded `data-html` is excluded because the editor interprets it + as HTML rather than inert metadata; sanitized child markup remains available. +- `images.remote` is explicit and defaults to `allow` for existing reader behavior. + `block` removes remote image references from HTML and CSS before insertion. + CID references and base64 raster images remain supported. Relative URLs retain + their original spelling and resolve in the browser; protocol-relative URLs + normalize to HTTPS. Safe navigation includes image-map areas and CID links. + `srcset` and SVG data images are excluded. CSS image-set is supported with the + default allow policy. The stricter opt-in block policy excludes image-set and + declarations using `var()`, whose resolved values could hide resource URLs; + it can therefore change sender styling. `proxyUrl` rewrites + HTTP(S) `img[src]` images to the supplied endpoint, matching the native + authenticated-image adapter. CSS URLs and HTML background attributes follow + the same allow/block policy but keep direct URLs; authenticated backgrounds + are not supported by that native adapter. Escaped CSS URL functions that the + parser cannot normalize as URL nodes are excluded. +- Links open with `target="_blank"` and `rel="noopener noreferrer"`; the host + may intercept mailto links. Shadow containment bounds layout and paint. It is + not an iframe security boundary; sanitization remains essential. +- The app decides when to adapt colors and normalize fonts. Its current policy + adapts personal/table-less email and preserves designed newsletters on a white + background. Sender classification and Macro-specific rendering stay in the app. + +The app sends ordinary HTML through this package. Plaintext and Macro Markdown +retain the app's existing Markdown renderer, including document mentions and +other app semantics. Those paths do not start an invisible HTML renderer or its +resources. This package does not render a thread, message header, attachments +list, editor, reply composer, or quote-expansion button. + +## Verification and fixture viewer + +From this directory in the repository's Nix shell: + +```sh +bun run test +bun run type-check +bun run lint +bun run test:browser +bun run viewer +``` + +The viewer is a vanilla TypeScript/Vite page with fixture, theme, width, quote, +and expansion controls. It calls the same `prepareEmailBody` and `mountEmailBody` +exports as production. It requires no backend or account. Fixtures live under +`tests/fixtures`; use synthetic or redacted messages. Remote resources are blocked +by preparation, and screenshot tests assert that no external requests occurred. +Fixtures can set `adaptColors` and `normalizeFonts` to exercise personal-message +policy, including calendar invitations that contain tables. Without an override, +the viewer preserves table email on white and adapts table-less email. +Inter is loaded locally. The browser suite covers actual CSS/layout, resource +replacement, late cleanup, error handling, theme round trips, and image visibility. +It also constructs detached hosts, inserts them after the initial frames have +elapsed, and verifies that theme colors are applied once after attachment. A +separate test pauses animation frames to ensure color preparation still runs. + +From the repository root, `just test-email-rendering` runs both the Node and +Chromium suites. `just test-email-rendering-update` regenerates visual baselines; +inspect changed images before committing them. Screenshots use a zero differing +pixel budget, so update and compare with the same Chromium, Linux environment, +and fonts. Other OS/font/browser builds need their own comparison environment. +These tests do not establish native iOS/Tauri behavior or provider delivery. +The Chromium suite starts its own viewer on port 24821 and fails if that port is +occupied; it never silently reuses another worktree's server. The interactive +viewer uses Vite's normal development port. + +Prepared output is deterministic for the same input, policy, and parser versions. +Pixel output also depends on browser version, fonts, viewport, loaded resources, +and theme. Keep those controlled in visual tests; do not claim universal pixel +determinism from a pure preparation function. diff --git a/packages/email-renderer/biome.jsonc b/packages/email-renderer/biome.jsonc new file mode 100644 index 00000000000..2fc3ef6c0ae --- /dev/null +++ b/packages/email-renderer/biome.jsonc @@ -0,0 +1,5 @@ +{ + "root": true, + "extends": ["../../biome.base.jsonc"], + "files": { "includes": ["**/*.ts", "!test-results", "!playwright-report"] } +} diff --git a/packages/email-renderer/index.html b/packages/email-renderer/index.html new file mode 100644 index 00000000000..c47e75f1582 --- /dev/null +++ b/packages/email-renderer/index.html @@ -0,0 +1,16 @@ + + + Email renderer fixtures + +
+ + + + + +
+

+
+ + + diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json new file mode 100644 index 00000000000..946df6534d3 --- /dev/null +++ b/packages/email-renderer/package.json @@ -0,0 +1,30 @@ +{ + "name": "@macro-inc/email-renderer", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/core/index.ts", + "./browser": "./src/browser/index.ts" + }, + "scripts": { + "test": "vitest run", + "test:browser": "playwright test", + "viewer": "vite --host 127.0.0.1", + "type-check": "tsc --noEmit && tsc --noEmit -p tsconfig.core.json", + "lint": "biome check ." + }, + "dependencies": { + "css-tree": "2.3.1", + "parse5": "7.3.0" + }, + "devDependencies": { + "@fontsource-variable/inter": "5.3.0", + "@playwright/test": "1.62.0", + "@types/css-tree": "^2.3.0", + "@types/node": "^24.8.0", + "typescript": "^5.9.3", + "vite": "^6.4.1", + "vitest": "^3.2.4" + } +} diff --git a/packages/email-renderer/playwright.config.ts b/packages/email-renderer/playwright.config.ts new file mode 100644 index 00000000000..85b3943a9c3 --- /dev/null +++ b/packages/email-renderer/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + testMatch: '*.pw.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + workers: process.env.CI ? 2 : 4, + reporter: 'list', + use: { baseURL: 'http://127.0.0.1:24821', trace: 'retain-on-failure' }, + webServer: { + command: 'bun run viewer --port 24821 --strictPort', + url: 'http://127.0.0.1:24821', + reuseExistingServer: false, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + snapshotPathTemplate: '{testDir}/snapshots/{arg}{ext}', + expect: { toHaveScreenshot: { maxDiffPixels: 0, animations: 'disabled' } }, +}); diff --git a/apps/web/src/lib/core/email/transform-email-colors.ts b/packages/email-renderer/src/browser/colors.ts similarity index 71% rename from apps/web/src/lib/core/email/transform-email-colors.ts rename to packages/email-renderer/src/browser/colors.ts index 47e66774f27..cfd811577e5 100644 --- a/apps/web/src/lib/core/email/transform-email-colors.ts +++ b/packages/email-renderer/src/browser/colors.ts @@ -1,3 +1,12 @@ +import { + findClosestContrastingColor, + normalizeRGBA, + type OKLCH, + parseRGBA, + type RGBA, + rgbaToOklch, +} from '../core/colors'; + interface TextNodeContrast { text: string; fg: OKLCH | null; @@ -22,9 +31,6 @@ const EPSILON = 0.0001; // from intentionally chromatic colors (e.g. #1a73e8) const CHROMATIC_THRESHOLD = 0.04; -type RGBA = { r: number; g: number; b: number; a: number }; -type OKLCH = { l: number; c: number; h: number; a?: number }; - /** * Process the colors of the email content so that 1) the text colors are in line with our theme colors, and 2) the text colors have enough contrast with the background colors. * @param root - The root node of the email content. @@ -107,7 +113,7 @@ const LIGHT_BG_THRESHOLD = 0.85; /** Remove light/white backgrounds from all elements, preserving colored/dark backgrounds (buttons, banners). * Uses getComputedStyle to resolve all color formats (named colors, hex, rgb, etc.) */ -export function stripContentBackgrounds(root: Node) { +function stripContentBackgrounds(root: Node) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); let el = walker.nextNode(); while (el) { @@ -153,79 +159,6 @@ function hasOpaqueAncestorBackground(el: HTMLElement, root: Node): boolean { return false; } -export function rgbaToOklch(rgba: RGBA | null): OKLCH | null { - if (!rgba) return null; - const { r, g, b, a } = rgba; - function inverseGammaCorrection(component: number): number { - return component <= 0.04045 - ? component / 12.92 - : Math.pow((component + 0.055) / 1.055, 2.4); - } - - const linearR = inverseGammaCorrection(r); - const linearG = inverseGammaCorrection(g); - const linearB = inverseGammaCorrection(b); - - const okLabLCubed = - linearR * 0.4122214708 + linearG * 0.5363325363 + linearB * 0.0514459929; - const okLabMCubed = - linearR * 0.2119034982 + linearG * 0.6806995451 + linearB * 0.1073969566; - const okLabSCubed = - linearR * 0.0883024619 + linearG * 0.2817188376 + linearB * 0.6299787005; - - const okLabL = Math.cbrt(okLabLCubed); - const okLabM = Math.cbrt(okLabMCubed); - const okLabS = Math.cbrt(okLabSCubed); - - const lightness = - okLabL * 0.2104542553 + okLabM * 0.793617785 - okLabS * 0.0040720468; - const a_ = - okLabL * 1.9779984951 - okLabM * 2.428592205 + okLabS * 0.4505937099; - const b_ = - okLabL * 0.0259040371 + okLabM * 0.7827717662 - okLabS * 0.808675766; - - const chroma = Math.sqrt(a_ * a_ + b_ * b_); - const hueInRadians = Math.atan2(b_, a_); - const hueInDegrees = (hueInRadians * 180) / Math.PI; - - return { - l: lightness, - c: chroma, - h: hueInDegrees < 0 ? hueInDegrees + 360 : hueInDegrees, - a: a, - }; -} - -// parses the result of getComputedStyle().color -export function parseRGBA(color: string): RGBA | null { - if (!color) return null; - const s = color.trim().toLowerCase(); - if (s === 'transparent') return { r: 0, g: 0, b: 0, a: 0 }; - - const m = s.match( - /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?\s*\)$/ - ); - if (m) { - const r = parseFloat(m[1]); - const g = parseFloat(m[2]); - const b = parseFloat(m[3]); - const a = m[4] !== undefined ? parseFloat(m[4]) : 1; - return { r, g, b, a }; - } - return null; -} - -export function normalizeRGBA(rgba: RGBA | null) { - if (!rgba) return null; - const clamp01 = (x: number) => Math.max(0, Math.min(1, x)); - return { - r: clamp01(rgba.r / 255), - g: clamp01(rgba.g / 255), - b: clamp01(rgba.b / 255), - a: rgba.a, - }; -} - function computeTextNodeColor(root: Node): TextNodeContrast[] { const out: TextNodeContrast[] = []; @@ -285,14 +218,3 @@ function computeTextNodeColor(root: Node): TextNodeContrast[] { return out; } - -export function findClosestContrastingColor(fg: OKLCH, bgL: number): OKLCH { - const dir = fg.l > bgL ? 1 : -1; - const candidate = bgL + dir * CONTRAST_THRESHOLD; - const value = - candidate >= 0 && candidate <= 1 - ? candidate - : bgL - dir * CONTRAST_THRESHOLD; - - return { l: value, c: fg.c, h: fg.h, a: fg.a ?? 1 }; -} diff --git a/apps/web/src/features/block-email/util/emailBodyContainmentCss.ts b/packages/email-renderer/src/browser/email-body-containment-css.ts similarity index 100% rename from apps/web/src/features/block-email/util/emailBodyContainmentCss.ts rename to packages/email-renderer/src/browser/email-body-containment-css.ts diff --git a/packages/email-renderer/src/browser/index.ts b/packages/email-renderer/src/browser/index.ts new file mode 100644 index 00000000000..a261788d5b6 --- /dev/null +++ b/packages/email-renderer/src/browser/index.ts @@ -0,0 +1,8 @@ +export type { ThemeColorParams } from './colors'; +export { processEmailColors } from './colors'; +export type { + BrowserOptions, + EmailBodyRenderer, + ResourceLifetime, +} from './renderer'; +export { mountEmailBody } from './renderer'; diff --git a/packages/email-renderer/src/browser/renderer.ts b/packages/email-renderer/src/browser/renderer.ts new file mode 100644 index 00000000000..6e3657a7b1f --- /dev/null +++ b/packages/email-renderer/src/browser/renderer.ts @@ -0,0 +1,154 @@ +import { fitToWidthZoom } from '../core/fit-to-width-zoom'; +import type { PreparedEmailBody } from '../core/html'; +import { processEmailColors, type ThemeColorParams } from './colors'; +import { EMAIL_BODY_CONTAINMENT_CSS } from './email-body-containment-css'; + +export interface ResourceLifetime { + readonly signal: AbortSignal; + /** Late registration after disposal runs cleanup immediately. */ + onDispose(cleanup: () => void): void; +} +export interface BrowserOptions { + theme: ThemeColorParams; + adaptColors: boolean; + normalizeFonts: boolean; + expanded?: boolean; + prepareLinks?: (container: HTMLElement) => void; + resolveImages?: ( + root: ShadowRoot, + lifetime: ResourceLifetime + ) => Promise; + onResourceError?: (error: unknown) => void; +} +export interface EmailBodyRenderer { + update(body: PreparedEmailBody, options: BrowserOptions): void; + setExpanded(expanded: boolean): void; + dispose(): void; +} + +/** Owns one empty host's shadow tree; visual preparation waits for attachment. */ +export function mountEmailBody( + host: HTMLElement, + body: PreparedEmailBody, + options: BrowserOptions +): EmailBodyRenderer { + const shadow = host.attachShadow({ mode: 'open' }); + let disposed = false; + let cleanup = () => {}; + let applyExpanded = (_expanded: boolean) => {}; + + function update(prepared: PreparedEmailBody, settings: BrowserOptions) { + if (disposed) return; + cleanup(); + const abort = new AbortController(); + const cleanups: (() => void)[] = []; + const lifetime: ResourceLifetime = { + signal: abort.signal, + onDispose(fn) { + if (abort.signal.aborted) fn(); + else cleanups.push(fn); + }, + }; + cleanup = () => { + abort.abort(); + for (const fn of cleanups.splice(0)) fn(); + }; + const style = document.createElement('style'); + const font = settings.normalizeFonts + ? '*:not(code):not(pre):not(code *):not(pre *):not([data-macro-btn]){font-family:system-ui,sans-serif!important;font-size:inherit!important;line-height:1.5!important;}' + : ''; + style.textContent = `:host{display:block;contain:content}${EMAIL_BODY_CONTAINMENT_CSS}${font}`; + const content = document.createElement('div'); + content.innerHTML = prepared.html; + for (const anchor of content.querySelectorAll< + HTMLAnchorElement | HTMLAreaElement + >('a, area')) { + if (anchor.tagName === 'A' && anchor.style.backgroundColor) { + anchor.dataset.macroBtn = ''; + for (const child of anchor.querySelectorAll('*')) + child.dataset.macroBtn = ''; + } + anchor.target = '_blank'; + anchor.rel = 'noopener noreferrer'; + } + content.style.userSelect = 'text'; + content.style.setProperty('-webkit-user-select', 'text'); + content.style.cursor = 'auto'; + shadow.replaceChildren(style, content); + settings.prepareLinks?.(content); + + let expanded = settings.expanded !== false; + let colorsPrepared = false; + const refresh = () => { + // Solid can create a host well before inserting it. Computed colors are + // unavailable while detached; retry on attachment via ResizeObserver. + if (abort.signal.aborted || !host.isConnected) return; + if (!colorsPrepared) { + if (settings.adaptColors) processEmailColors(shadow, settings.theme); + colorsPrepared = true; + } + content.style.zoom = ''; + content.style.overflowX = ''; + content.style.overflow = expanded ? '' : 'hidden'; + if (!expanded) return; + const fit = fitToWidthZoom({ + containerWidth: host.clientWidth, + contentWidth: content.scrollWidth, + }); + if (!fit) return; + content.style.zoom = `${fit.zoom}`; + if (fit.overflowsAfterZoom) content.style.overflowX = 'auto'; + }; + applyExpanded = (value) => { + expanded = value; + // Host containment prevents an ancestor's line clamp from reaching this + // content, so the collapsed summary must be clipped inside the boundary. + content.style.display = value ? '' : '-webkit-box'; + content.style.webkitBoxOrient = value ? '' : 'vertical'; + content.style.webkitLineClamp = value ? '' : '3'; + host.style.setProperty( + '--macro-email-img-display', + value ? 'initial' : 'none' + ); + refresh(); + }; + applyExpanded(expanded); + const observer = new ResizeObserver(refresh); + observer.observe(host); + lifetime.onDispose(() => observer.disconnect()); + // Capture catches non-bubbling load events, including resolved CID images. + shadow.addEventListener('load', refresh, true); + lifetime.onDispose(() => shadow.removeEventListener('load', refresh, true)); + // Cover synchronous framework insertion without depending on a paint frame + // (which can be suspended in a background tab or embedded browser). + queueMicrotask(refresh); + if (!settings.adaptColors) { + content.style.setProperty('background-color', 'white', 'important'); + content.style.color = 'black'; + } + // Call the adapter only while this generation is current. Errors belong to + // the host's resource policy, never an unhandled fire-and-forget promise. + Promise.resolve() + .then(async () => { + if (!abort.signal.aborted) + await settings.resolveImages?.(shadow, lifetime); + }) + .catch((error: unknown) => { + if (!abort.signal.aborted) settings.onResourceError?.(error); + }); + } + update(body, options); + return { + update, + setExpanded(value) { + if (!disposed) applyExpanded(value); + }, + dispose() { + if (disposed) return; + disposed = true; + cleanup(); + shadow.replaceChildren(); + host.style.removeProperty('--macro-email-img-display'); + }, + }; +} diff --git a/packages/email-renderer/src/core/colors.test.ts b/packages/email-renderer/src/core/colors.test.ts new file mode 100644 index 00000000000..26a2cbd7696 --- /dev/null +++ b/packages/email-renderer/src/core/colors.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { + findClosestContrastingColor, + normalizeRGBA, + parseRGBA, + rgbaToOklch, +} from './colors'; + +describe('parseRGBA', () => { + it('parses rgb() format', () => { + const result = parseRGBA('rgb(255, 128, 64)'); + expect(result).toEqual({ r: 255, g: 128, b: 64, a: 1 }); + }); + + it('parses rgba() format with alpha', () => { + const result = parseRGBA('rgba(255, 128, 64, 0.5)'); + expect(result).toEqual({ r: 255, g: 128, b: 64, a: 0.5 }); + }); + + it('parses rgba() format with space syntax', () => { + const result = parseRGBA('rgba(255 128 64 / 0.5)'); + expect(result).toEqual({ r: 255, g: 128, b: 64, a: 0.5 }); + }); + + it('handles transparent', () => { + const result = parseRGBA('transparent'); + expect(result).toEqual({ r: 0, g: 0, b: 0, a: 0 }); + }); + + it('returns null for invalid format', () => { + const result = parseRGBA('invalid'); + expect(result).toBeNull(); + }); + + it('returns null for empty string', () => { + const result = parseRGBA(''); + expect(result).toBeNull(); + }); +}); + +describe('normalizeRGBA', () => { + it('normalizes 0-255 values to 0-1 range', () => { + const result = normalizeRGBA({ r: 255, g: 128, b: 0, a: 0.5 }); + expect(result).toEqual({ r: 1, g: 128 / 255, b: 0, a: 0.5 }); + }); + + it('clamps values to 0-1 range', () => { + const result = normalizeRGBA({ r: 300, g: -10, b: 255, a: 1 }); + expect(result?.r).toBe(1); + expect(result?.g).toBe(0); + expect(result?.b).toBe(1); + }); + + it('returns null for null input', () => { + const result = normalizeRGBA(null); + expect(result).toBeNull(); + }); +}); + +describe('rgbaToOklch', () => { + it('converts black to OKLCH', () => { + const result = rgbaToOklch({ r: 0, g: 0, b: 0, a: 1 }); + expect(result?.l).toBeCloseTo(0, 2); + expect(result?.c).toBeCloseTo(0, 2); + }); + + it('converts white to OKLCH', () => { + const result = rgbaToOklch({ r: 1, g: 1, b: 1, a: 1 }); + expect(result?.l).toBeCloseTo(1, 2); + expect(result?.c).toBeCloseTo(0, 2); + }); + + it('preserves alpha value', () => { + const result = rgbaToOklch({ r: 0.5, g: 0.5, b: 0.5, a: 0.75 }); + expect(result?.a).toBe(0.75); + }); + + it('returns null for null input', () => { + const result = rgbaToOklch(null); + expect(result).toBeNull(); + }); +}); + +describe('findClosestContrastingColor', () => { + it.each([ + { + name: 'lightens text above the background', + lightness: 0.6, + background: 0.5, + expected: 1, + alpha: 0.8, + }, + { + name: 'darkens text below the background', + lightness: 0.4, + background: 0.5, + expected: 0, + alpha: 0.8, + }, + { + name: 'switches to dark text when lightening would exceed white', + lightness: 0.9, + background: 0.8, + expected: 0.3, + alpha: 0.8, + }, + { + name: 'switches to light text when darkening would exceed black', + lightness: 0.1, + background: 0.2, + expected: 0.7, + alpha: undefined, + }, + ])( + '$name while preserving hue, chroma and opacity', + ({ lightness, background, expected, alpha }) => { + const result = findClosestContrastingColor( + { l: lightness, c: 0.15, h: 270, a: alpha }, + background + ); + expect(result.l).toBeCloseTo(expected); + expect(result.c).toBe(0.15); + expect(result.h).toBe(270); + expect(result.a).toBe(alpha ?? 1); + } + ); +}); diff --git a/packages/email-renderer/src/core/colors.ts b/packages/email-renderer/src/core/colors.ts new file mode 100644 index 00000000000..8c4c49a612d --- /dev/null +++ b/packages/email-renderer/src/core/colors.ts @@ -0,0 +1,86 @@ +export type RGBA = { r: number; g: number; b: number; a: number }; +export type OKLCH = { l: number; c: number; h: number; a?: number }; +const CONTRAST_THRESHOLD = 0.5; +export function rgbaToOklch(rgba: RGBA | null): OKLCH | null { + if (!rgba) return null; + const { r, g, b, a } = rgba; + function inverseGammaCorrection(component: number): number { + return component <= 0.04045 + ? component / 12.92 + : Math.pow((component + 0.055) / 1.055, 2.4); + } + + const linearR = inverseGammaCorrection(r); + const linearG = inverseGammaCorrection(g); + const linearB = inverseGammaCorrection(b); + + const okLabLCubed = + linearR * 0.4122214708 + linearG * 0.5363325363 + linearB * 0.0514459929; + const okLabMCubed = + linearR * 0.2119034982 + linearG * 0.6806995451 + linearB * 0.1073969566; + const okLabSCubed = + linearR * 0.0883024619 + linearG * 0.2817188376 + linearB * 0.6299787005; + + const okLabL = Math.cbrt(okLabLCubed); + const okLabM = Math.cbrt(okLabMCubed); + const okLabS = Math.cbrt(okLabSCubed); + + const lightness = + okLabL * 0.2104542553 + okLabM * 0.793617785 - okLabS * 0.0040720468; + const a_ = + okLabL * 1.9779984951 - okLabM * 2.428592205 + okLabS * 0.4505937099; + const b_ = + okLabL * 0.0259040371 + okLabM * 0.7827717662 - okLabS * 0.808675766; + + const chroma = Math.sqrt(a_ * a_ + b_ * b_); + const hueInRadians = Math.atan2(b_, a_); + const hueInDegrees = (hueInRadians * 180) / Math.PI; + + return { + l: lightness, + c: chroma, + h: hueInDegrees < 0 ? hueInDegrees + 360 : hueInDegrees, + a: a, + }; +} + +// parses the result of getComputedStyle().color +export function parseRGBA(color: string): RGBA | null { + if (!color) return null; + const s = color.trim().toLowerCase(); + if (s === 'transparent') return { r: 0, g: 0, b: 0, a: 0 }; + + const m = s.match( + /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?\s*\)$/ + ); + if (m) { + const r = parseFloat(m[1]); + const g = parseFloat(m[2]); + const b = parseFloat(m[3]); + const a = m[4] !== undefined ? parseFloat(m[4]) : 1; + return { r, g, b, a }; + } + return null; +} + +export function normalizeRGBA(rgba: RGBA | null) { + if (!rgba) return null; + const clamp01 = (x: number) => Math.max(0, Math.min(1, x)); + return { + r: clamp01(rgba.r / 255), + g: clamp01(rgba.g / 255), + b: clamp01(rgba.b / 255), + a: rgba.a, + }; +} + +export function findClosestContrastingColor(fg: OKLCH, bgL: number): OKLCH { + const dir = fg.l > bgL ? 1 : -1; + const candidate = bgL + dir * CONTRAST_THRESHOLD; + const value = + candidate >= 0 && candidate <= 1 + ? candidate + : bgL - dir * CONTRAST_THRESHOLD; + + return { l: value, c: fg.c, h: fg.h, a: fg.a ?? 1 }; +} diff --git a/packages/email-renderer/src/core/css.ts b/packages/email-renderer/src/core/css.ts new file mode 100644 index 00000000000..e2eb1bb520c --- /dev/null +++ b/packages/email-renderer/src/core/css.ts @@ -0,0 +1,148 @@ +import { + type CssNode, + generate, + ident, + type List, + type ListItem, + parse, + walk, +} from 'css-tree'; +import { type ImagePolicy, imageUrl } from './resource-policy'; + +interface CssOptions { + stripColorScheme?: boolean; +} + +/** Sanitize CSS without applying the reader's theme policy to outgoing HTML. */ +export function prepareCss( + css: string, + inline: boolean, + images: ImagePolicy, + options: CssOptions = {} +): string { + try { + const ast = parse(css, { + context: inline ? 'declarationList' : 'stylesheet', + parseCustomProperty: true, + }); + // Match the existing reader: only root-level media rules are theme + // overrides. Nested rules remain part of the sender's stylesheet. + if (options.stripColorScheme && ast.type === 'StyleSheet') { + ast.children.forEach((node, item, list) => { + if ( + node.type === 'Atrule' && + ident.decode(node.name).toLowerCase() === 'media' && + node.prelude && + /prefers-color-scheme/i.test(ident.decode(generate(node.prelude))) + ) + list.remove(item); + }); + } + walk(ast, { + enter(node: CssNode, item: ListItem, list: List) { + // CSS parsers recover at declaration/rule boundaries. Discard only the + // unparsed construct so one sender typo cannot erase the whole sheet. + if (node.type === 'Raw' && list) { + list.remove(item); + return walk.skip; + } + if (node.type === 'Atrule') { + const name = ident.decode(node.name).toLowerCase(); + if ( + ![ + 'media', + 'supports', + 'layer', + 'scope', + 'namespace', + 'container', + 'page', + ].includes(name) || + (name === 'scope' && + node.prelude && + /:host|::slotted|:global/i.test( + ident.decode(generate(node.prelude)) + )) + ) { + list.remove(item); + return walk.skip; + } + } + if ( + node.type === 'Rule' && + (node.prelude.type === 'Raw' || + /:host|::slotted|:global/i.test( + ident.decode(generate(node.prelude)) + )) + ) { + list.remove(item); + return walk.skip; + } + if (node.type === 'Declaration') { + let unsafe = /^(?:behavior|-moz-binding|animation|transition)/i.test( + ident.decode(node.property) + ); + walk(node.value, (value) => { + if (value.type === 'Raw') unsafe = true; + if ( + value.type === 'Function' && + /^(?:expression|url)$/i.test(ident.decode(value.name)) + ) + unsafe = true; + if ( + value.type === 'Function' && + /^(?:-webkit-)?image-set$/i.test(ident.decode(value.name)) + ) { + // image-set also accepts bare CSS strings as URLs. Unlike type() + // MIME strings, these direct children can initiate image loads. + value.children.forEach((candidate) => { + if (candidate.type === 'String') { + const url = imageUrl(candidate.value, { + remote: images.remote, + }); + if (!url || /^cid:/i.test(url)) unsafe = true; + else candidate.value = url; + } + }); + // Functions such as var()/env() can expand strings into URLs. + // The blocked policy excludes image-set rather than trying to + // reproduce the browser's dynamic CSS value evaluation. + if (images.remote === 'block') unsafe = true; + } + if ( + value.type === 'Function' && + ident.decode(value.name).toLowerCase() === 'var' + ) { + // Preserve browser cascade semantics in the normal reader. + // The opt-in blocked policy cannot trust inherited variable + // values to be free of resource URLs. + if (images.remote === 'block') unsafe = true; + } + if (value.type === 'Url') { + const url = imageUrl(value.value, { remote: images.remote }); + if (!url || /^cid:/i.test(url)) unsafe = true; + else value.value = url; + } + }); + if (unsafe) { + list.remove(item); + return walk.skip; + } + } + }, + }); + // Keep style text safe when embedded into HTML, including CSS strings. + return generate(ast).replace(/ { it('does not zoom when content fits', () => { diff --git a/apps/web/src/features/block-email/util/fitToWidthZoom.ts b/packages/email-renderer/src/core/fit-to-width-zoom.ts similarity index 100% rename from apps/web/src/features/block-email/util/fitToWidthZoom.ts rename to packages/email-renderer/src/core/fit-to-width-zoom.ts diff --git a/packages/email-renderer/src/core/html.test.ts b/packages/email-renderer/src/core/html.test.ts new file mode 100644 index 00000000000..6a71d0e8a51 --- /dev/null +++ b/packages/email-renderer/src/core/html.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it } from 'vitest'; +import { prepareEmailBody, sanitizeEmailHtml } from './html'; + +describe('prepareEmailBody in Node, without a DOM', () => { + it('preserves inert editor metadata in outgoing HTML while stripping it from the reader', () => { + const html = + '

Spec

'; + const outgoing = sanitizeEmailHtml(html); + expect(outgoing).toContain('data-lexical-indent="1"'); + expect(outgoing).toContain('data-document-mention="true"'); + expect(outgoing).toContain('data-document-id="document-1"'); + expect(outgoing).toContain('data-scale="0.5"'); + expect(outgoing).not.toMatch(/onclick|onerror/); + const reader = prepareEmailBody({ html }).html; + expect(reader).not.toMatch(/data-|onclick|onerror/); + expect(reader).toContain('href="https://example.com/spec"'); + expect(reader).toContain('>Spec'); + }); + it('drops encoded HTML metadata that could bypass scrubbing during editor import', () => { + const html = + '
Safe fallback
'; + const outgoing = sanitizeEmailHtml(html); + expect(outgoing).toContain('data-html-render="true"'); + expect(outgoing).not.toContain('data-html='); + expect(outgoing).not.toContain('onerror'); + expect(outgoing).toContain('Safe fallback'); + }); + it('normalizes frameset documents to an empty inert body', () => { + const html = ''; + expect(prepareEmailBody({ html })).toMatchObject({ + html: '', + hasTable: false, + }); + expect(sanitizeEmailHtml(html)).toBe(''); + }); + it.each([ + ['body preparation', (html: string) => prepareEmailBody({ html }).html], + ['HTML sanitization', sanitizeEmailHtml], + ])( + '%s handles deeply nested content without exposing removed markup', + (_, render) => { + const html = + '

Before

' + + '
left'.repeat(5000) + + 'Middle

Hidden form

' + + 'right
'.repeat(5000) + + '

After

'; + const result = render(html); + expect(result.replace(/<[^>]*>/g, '')).toBe( + 'Before' + + 'left'.repeat(5000) + + 'Middle' + + 'right'.repeat(5000) + + 'After' + ); + expect(result).toContain(''); + expect(result).not.toMatch(/Hidden| { + const input = Object.freeze({ + html: '

Hello

Signature
', + }); + const first = prepareEmailBody(input); + expect(first).toEqual(prepareEmailBody(input)); + expect(JSON.parse(JSON.stringify(first))).toEqual(first); + expect(first.html).toBe('

Hello

'); + expect(first.hasHiddenContent).toBe(true); + expect('document' in globalThis).toBe(false); + }); + it('falls back to actual content while replyless HTML is unavailable', () => { + expect(prepareEmailBody({ html: '

New message

' }).html).toBe( + '

New message

' + ); + expect( + prepareEmailBody({ html: '

New message

', replylessHtml: '' }).html + ).toBe('

New message

'); + }); + it('removes recognized quotes on fallback and restores the full body on request', () => { + const input = { + html: '

New

Old
Me
', + }; + expect(prepareEmailBody(input)).toMatchObject({ + html: '

New

', + hasHiddenContent: true, + }); + expect(prepareEmailBody(input, { showQuotedContent: true }).html).toBe( + input.html + ); + }); + it('uses backend replyless content and detects differences with equal lengths', () => { + const input = { html: '

Old

', replylessHtml: '

New

' }; + expect(prepareEmailBody(input)).toMatchObject({ + html: '

New

', + hasHiddenContent: true, + }); + }); + it('renders plaintext literally, including Markdown syntax and angle brackets', () => { + const body = prepareEmailBody({ + text: '**Hello**\n & goodbye', + }); + expect(body.kind).toBe('text'); + expect(body.html).toContain( + '**Hello**\n<img src=x onerror=alert(1)> & goodbye' + ); + expect(body.hasHiddenContent).toBe(false); + }); + it('handles malformed HTML using the HTML parser and preserves tables', () => { + expect(prepareEmailBody({ html: '
AB' })).toMatchObject({ + html: '
AB
', + hasTable: true, + }); + }); + it('retains document styles and signatures according to explicit options', () => { + const html = + '

A

--
Me
'; + expect(prepareEmailBody({ html })).toMatchObject({ + html: '\n

A

', + hasHiddenContent: true, + }); + expect( + prepareEmailBody({ html }, { showQuotedContent: true }).html + ).toContain('
Me
'); + }); + it.each(['

A

', '

A


'])( + 'trims trailing breaks and empty wrappers: %s', + (html) => { + const body = prepareEmailBody({ html }); + expect(body.html).not.toContain('
'); + expect(body.html).toContain('A'); + } + ); + it('keeps trailing images and meaningful text', () => { + expect( + prepareEmailBody({ html: '

A

' }).html + ).toContain('A
B

' }).html).toContain( + 'A
B' + ); + }); + it('trims trailing breaks through empty styles without discarding meaningful styles', () => { + const body = prepareEmailBody({ + html: '

Hello


', + }); + expect(body.html).toBe('

Hello

'); + expect( + prepareEmailBody({ html: '

Hello

' }).html + ).toContain(''); + }); + it.each(['\u00a0', '\u000b'])( + 'does not mistake a class containing %j for a signature or quote', + (separator) => { + const body = prepareEmailBody({ + html: `

Message body

More content
`, + }); + expect(body.html).toContain('Message body'); + expect(body.html).toContain('More content'); + expect(body.hasHiddenContent).toBe(false); + } + ); +}); + +describe('content and resource policy', () => { + it('preserves spaces in mailto parameters and HTTP image paths', () => { + const result = sanitizeEmailHtml( + 'Mail

X

' + ); + expect(result).toContain('subject=Hello World&body=See you soon'); + expect(result).toContain('src="https://example.com/my image.png"'); + expect(result).not.toContain('myimage.png'); + const proxied = prepareEmailBody( + { html: '' }, + { images: { remote: 'allow', proxyUrl: 'https://proxy.test' } } + ); + expect(proxied.html).toContain('my%20image.png'); + }); + it('recognizes CSS escapes in host selectors and image-set functions', () => { + const result = sanitizeEmailHtml( + '', + { remote: 'block' } + ); + expect(result).not.toContain('color:red'); + expect(result).not.toContain('evil.test'); + }); + it('rejects escaped URL functions the CSS parser cannot normalize as URL nodes', () => { + const html = + '

Text

'; + expect( + prepareEmailBody({ html }, { images: { remote: 'block' } }).html + ).not.toContain('evil.test'); + expect( + prepareEmailBody( + { html }, + { images: { remote: 'allow', proxyUrl: 'https://proxy.test' } } + ).html + ).not.toContain('evil.test'); + }); + it('normalizes source schemes for CID and native HTTPS adapters', () => { + expect( + sanitizeEmailHtml('') + ).toContain('src="cid:part"'); + expect(sanitizeEmailHtml('')).toContain( + 'src="https://example.com/a"' + ); + }); + it.each([ + '', + '', + 'xx', + 'x', + '
', + '', + ])('removes active markup before insertion: %s', (html) => { + const result = sanitizeEmailHtml('

Safe content

' + html); + expect(result).toContain('

Safe content

'); + expect(result).not.toMatch( + /onerror|alert\(| { + const result = sanitizeEmailHtml( + 'mailwebdatarelativepart' + ); + expect(result).toContain('href="mailto:a@example.com"'); + expect(result).toContain('href="https://example.com"'); + expect(result).not.toContain('href="data:'); + expect(result).toContain('href="/document"'); + expect(result).toContain('href="cid:part"'); + }); + it('blocks every automatic remote resource path, including styles and source sets', () => { + const result = prepareEmailBody( + { + html: '

Text

x
', + }, + { images: { remote: 'block' } } + ); + expect(result.html).not.toContain('evil.test'); + expect(result.html).not.toContain('srcset'); + expect(result.html).toContain('Text'); + }); + it('proxies img sources while retaining direct background URLs for native compatibility', () => { + const result = prepareEmailBody( + { + html: '

X

X
', + }, + { images: { remote: 'allow', proxyUrl: 'https://proxy.test/image' } } + ); + expect(result.html).toContain( + 'https://proxy.test/image?url=https%3A%2F%2Fexample.com%2Fa' + ); + expect(result.html).toContain('url(https://example.com/b)'); + expect(result.html).toContain('url(https://example.com/rule)'); + expect(result.html).toContain('background="https://example.com/table"'); + expect(result.html.match(/proxy\.test/g)).toHaveLength(1); + }); + it('keeps CID and raster data images, excluding SVG data images', () => { + const result = sanitizeEmailHtml( + '' + ); + expect(result).toContain('src="cid:part"'); + expect(result).toContain('src="data:image/png;base64,AAAA"'); + expect(result).not.toContain('svg+xml'); + }); + it('preserves nested dark rules while stripping host selectors and unsafe CSS escapes', () => { + const result = prepareEmailBody({ + html: '', + }).html; + expect(result).not.toMatch(/:host|javascript/); + expect(result).toContain('prefers-color-scheme'); + expect(result).toContain('color:red'); + expect(result).toContain('color:blue'); + }); + it('applies the legacy theme policy only to top-level media rules in head styles', () => { + const html = + '

Hello

'; + const result = prepareEmailBody({ html }).html; + expect(result).not.toContain('.head'); + expect(result).toContain('.nested{color:green}'); + expect(result).toContain('.body{color:blue}'); + }); + it('preserves safe dark-mode rules in quoted HTML and applies reader policy separately', () => { + const html = + '

Quoted

'; + expect(sanitizeEmailHtml(html)).toContain('prefers-color-scheme'); + expect(prepareEmailBody({ html }).html).not.toContain( + 'prefers-color-scheme' + ); + }); + it('preserves unrelated valid declarations when CSS contains a recoverable error', () => { + const result = sanitizeEmailHtml( + '

Hello

' + ); + expect(result).toContain('.preheader{display:none}'); + expect(result).toContain('.message{font-size:32px;padding:24px}'); + expect(result).toContain('style="padding:12px;margin:8px"'); + expect(result).not.toContain('red !'); + }); + it('preserves native CSS variables and fallbacks under the normal reader policy', () => { + const html = + '

Hello

'; + expect(sanitizeEmailHtml(html)).toContain( + 'color:var(--tone,var(--missing,blue))' + ); + const body = prepareEmailBody({ html }); + expect(body.html).toContain('color:var(--tone,var(--missing,blue))'); + const blocked = prepareEmailBody( + { html }, + { images: { remote: 'block' } } + ).html; + expect(blocked).not.toContain('example.com'); + expect(blocked).not.toContain('var('); + }); + it('normalizes protocol-relative resources and keeps safe image-map links', () => { + const html = + 'Open'; + const result = sanitizeEmailHtml(html); + expect(result).toContain('href="https://example.com/open"'); + expect(result).toContain(' { + const html = + '

Hello

'; + const result = prepareEmailBody({ html }).html; + expect(result).toContain('@namespace h url(http://www.w3.org/1999/xhtml)'); + expect(result).toContain( + '@layer email{@scope (.message){h|p{font-size:32px;color:red}}}' + ); + }); + it('retains static image-set URLs under the allow policy and blocks indirect loads', () => { + const html = + '

A

B

'; + expect(sanitizeEmailHtml(html)).toContain( + 'image-set("https://example.com/a"1x,url(https://example.com/b)2x)' + ); + const blocked = prepareEmailBody({ html }, { images: { remote: 'block' } }); + expect(blocked.html).not.toContain('background:'); + }); + it('never allows CSS strings to break out of a style element', () => { + const result = sanitizeEmailHtml( + '' + ); + expect(result).not.toContain('content:"<'); + }); +}); diff --git a/packages/email-renderer/src/core/html.ts b/packages/email-renderer/src/core/html.ts new file mode 100644 index 00000000000..39ee31a484c --- /dev/null +++ b/packages/email-renderer/src/core/html.ts @@ -0,0 +1,301 @@ +import { + type DefaultTreeAdapterMap, + defaultTreeAdapter, + html as htmlConstants, + parse, + serialize, + serializeOuter, +} from 'parse5'; +import { prepareCss } from './css'; +import { type ImagePolicy, imageUrl, linkUrl } from './resource-policy'; + +type Node = DefaultTreeAdapterMap['node']; +type Element = DefaultTreeAdapterMap['element']; +type Parent = DefaultTreeAdapterMap['parentNode']; +const ACTIVE = new Set( + 'script iframe frame frameset object embed applet base meta link noscript template svg math form input button select textarea audio video source track'.split( + ' ' + ) +); +const IMAGES_ALLOWED: ImagePolicy = { remote: 'allow' }; + +interface ScrubOptions { + stripColorScheme?: boolean; + preserveDataAttributes?: boolean; +} + +function isElement(node: Node): node is Element { + return 'tagName' in node; +} +function elements(root: Parent): Element[] { + const result: Element[] = []; + const pending = [...root.childNodes].reverse(); + while (pending.length) { + const child = pending.pop()!; + if (isElement(child)) { + result.push(child); + for (let index = child.childNodes.length - 1; index >= 0; index--) + pending.push(child.childNodes[index]); + } + } + return result; +} +function remove(node: Element) { + if (node.parentNode) + node.parentNode.childNodes = node.parentNode.childNodes.filter( + (child) => child !== node + ); +} +function hasClass(node: Element, name: string) { + return node.attrs + .find((attr) => attr.name === 'class') + ?.value.split(/[\t\n\f\r ]+/) + .includes(name); +} +function scrub(root: Parent, images: ImagePolicy, options: ScrubOptions = {}) { + for (const node of elements(root)) { + if ( + ACTIVE.has(node.tagName) || + node.namespaceURI !== 'http://www.w3.org/1999/xhtml' + ) { + remove(node); + continue; + } + node.attrs = node.attrs.flatMap((attr) => { + const name = attr.name.toLowerCase(); + if ( + attr.namespace || + attr.prefix || + name.startsWith('on') || + [ + 'srcdoc', + 'srcset', + 'action', + 'formaction', + 'ping', + 'poster', + 'autofocus', + 'contenteditable', + 'is', + 'slot', + // The editor interprets this legacy attribute as unsanitized HTML. + // Its current exporter uses child markup, which we scrub normally. + 'data-html', + ].includes(name) || + (name.startsWith('data-') && !options.preserveDataAttributes) + ) + return []; + if (name === 'href') { + const value = ['a', 'area'].includes(node.tagName) + ? linkUrl(attr.value) + : undefined; + return value ? [{ ...attr, value }] : []; + } + if (name === 'src' || name === 'background') { + const value = + name === 'background' || node.tagName === 'img' + ? imageUrl( + attr.value, + name === 'background' ? { remote: images.remote } : images + ) + : undefined; + return value ? [{ ...attr, value }] : []; + } + if (name === 'style') { + const value = prepareCss(attr.value, true, images, options); + return value ? [{ ...attr, value }] : []; + } + return [attr]; + }); + if (node.tagName === 'style') { + const css = node.childNodes + .map((child) => + child.nodeName === '#text' + ? (child as DefaultTreeAdapterMap['textNode']).value + : '' + ) + .join(''); + node.childNodes = [ + { + nodeName: '#text', + value: prepareCss(css, false, images, { + stripColorScheme: + options.stripColorScheme && node.parentNode?.nodeName === 'head', + }), + parentNode: node, + }, + ]; + } + } +} + +// Native HTML parsing bounds nesting at roughly 512 levels. parse5 does not, +// and its serializer recurses. Unwrap excessive containers after sanitization +// so readable content keeps its order without exposing discarded active markup. +function boundNesting(root: Parent) { + const pending = [{ node: root, depth: 0 }]; + while (pending.length) { + const { node, depth } = pending.pop()!; + if (depth < 512) { + for (const child of node.childNodes) + if (isElement(child)) pending.push({ node: child, depth: depth + 1 }); + continue; + } + const flattened: typeof node.childNodes = []; + const descendants = [...node.childNodes].reverse(); + while (descendants.length) { + const child = descendants.pop()!; + if (isElement(child) && child.childNodes.some(isElement)) { + for (let index = child.childNodes.length - 1; index >= 0; index--) + descendants.push(child.childNodes[index]); + } else { + child.parentNode = node; + flattened.push(child); + } + } + node.childNodes = flattened; + } +} + +function documentParts( + html: string, + images: ImagePolicy, + options?: ScrubOptions +) { + const document = parse(html); + scrub(document, images, options); + boundNesting(document); + const nodes = elements(document); + let body = nodes.find((node) => node.tagName === 'body'); + if (!body) { + // A frameset document has no body; active markup was removed above. + body = defaultTreeAdapter.createElement('body', htmlConstants.NS.HTML, []); + defaultTreeAdapter.appendChild( + nodes.find((node) => node.tagName === 'html')!, + body + ); + } + const head = nodes.find((node) => node.tagName === 'head')!; + const styles = elements(head) + .filter((node) => node.tagName === 'style') + .map((node) => serializeOuter(node)) + .join('\n'); + return { document, body, styles }; +} + +function trim(root: Parent) { + while (root.childNodes.length) { + const last = root.childNodes[root.childNodes.length - 1]; + if (last.nodeName === '#text') { + if ((last as DefaultTreeAdapterMap['textNode']).value.trim()) return; + } else if (isElement(last)) { + if (last.tagName === 'img') return; + if (last.tagName !== 'br') { + trim(last); + if (last.childNodes.length) return; + } + } else return; + root.childNodes.pop(); + } +} + +export function sanitizeEmailHtml( + html: string, + images: ImagePolicy = IMAGES_ALLOWED +): string { + // Outgoing quoted HTML round-trips through the editor using inert data + // attributes (mentions, indentation and embedded HTML). Reader preparation + // strips these separately before mounting application-visible content. + const { document } = documentParts(html, images, { + preserveDataAttributes: true, + }); + return serialize(elements(document).find((node) => node.tagName === 'html')!); +} + +function parseEmailContent( + html: string, + images: ImagePolicy, + showQuotedContent = false +) { + const { body, styles } = documentParts(html, images, { + stripColorScheme: true, + }); + const nodes = elements(body); + const hasTable = nodes.some((node) => node.tagName === 'table'); + const signatureNode = nodes.find( + (node) => + hasClass(node, 'gmail_signature') || + hasClass(node, 'macro-email-signature') + ); + if (!showQuotedContent) { + if (signatureNode) { + remove(signatureNode); + const prefix = nodes.find((node) => + hasClass(node, 'gmail_signature_prefix') + ); + if (prefix) remove(prefix); + } + trim(body); + } + return { + mainContent: styles + (styles ? '\n' : '') + serialize(body), + hasSignature: !!signatureNode, + hasTable, + }; +} + +/** Narrow content input, independent of a message DTO, thread, or framework. */ +export interface EmailBodyInput { + html?: string | null; + replylessHtml?: string | null; + text?: string | null; +} +export interface BodyOptions { + showQuotedContent?: boolean; + showFullContent?: boolean; + images?: ImagePolicy; +} +export interface PreparedEmailBody { + readonly html: string; + readonly kind: 'html' | 'text'; + readonly hasTable: boolean; + readonly hasHiddenContent: boolean; +} +export function prepareEmailBody( + input: EmailBodyInput, + options: BodyOptions = {} +): PreparedEmailBody { + if (!input.html) { + const escaped = (input.text ?? '') + .replace(/&/g, '&') + .replace(//g, '>'); + return { + html: `
${escaped}
`, + kind: 'text', + hasTable: false, + hasHiddenContent: false, + }; + } + const images = options.images ?? IMAGES_ALLOWED; + const { body, styles } = documentParts(input.html, IMAGES_ALLOWED); + const quote = elements(body).find((node) => hasClass(node, 'macro_quote')); + if (quote) remove(quote); + // Missing replyless data must still display a newly received/sent message. + const replyless = + input.replylessHtml || (quote ? styles + serialize(body) : input.html); + const full = !!(options.showQuotedContent || options.showFullContent); + const shortened = parseEmailContent(replyless, images); + const parsed = full + ? parseEmailContent(input.html, images, options.showQuotedContent) + : shortened; + return { + html: parsed.mainContent, + kind: 'html', + hasTable: parsed.hasTable, + hasHiddenContent: + !!quote || + shortened.hasSignature || + replyless.replace(/\s+/g, '') !== input.html.replace(/\s+/g, ''), + }; +} diff --git a/packages/email-renderer/src/core/index.ts b/packages/email-renderer/src/core/index.ts new file mode 100644 index 00000000000..4a01b0ede38 --- /dev/null +++ b/packages/email-renderer/src/core/index.ts @@ -0,0 +1,8 @@ +export { stripColorSchemeMediaQueries } from './css'; +export type { + BodyOptions, + EmailBodyInput, + PreparedEmailBody, +} from './html'; +export { prepareEmailBody, sanitizeEmailHtml } from './html'; +export type { ImagePolicy } from './resource-policy'; diff --git a/packages/email-renderer/src/core/resource-policy.ts b/packages/email-renderer/src/core/resource-policy.ts new file mode 100644 index 00000000000..64159978ead --- /dev/null +++ b/packages/email-renderer/src/core/resource-policy.ts @@ -0,0 +1,45 @@ +/** Explicit policy: preparation never reads an origin, flag, or network client. */ +export interface ImagePolicy { + remote: 'allow' | 'block'; + /** Proxy for img[src], matching native authenticated-image support. + * CSS/background images retain direct URLs under the remote policy. */ + proxyUrl?: string; +} + +function cleanUrl(value: string): string { + const url = Array.from(value) + .filter((char) => char.charCodeAt(0) >= 0x20 && char.charCodeAt(0) !== 0x7f) + .join('') + .trim(); + return url.startsWith('//') ? `https:${url}` : url; +} + +const hasScheme = (url: string) => /^[a-z][a-z\d+.-]*:/i.test(url); + +/** Relative resources remain literal; only the browser resolves their base URL. */ +export function imageUrl( + value: string, + policy: ImagePolicy +): string | undefined { + const url = cleanUrl(value); + if (/^cid:/i.test(url)) return `cid:${url.slice(4)}`; + if ( + /^data:image\/(?:png|gif|jpe?g|webp|avif|bmp);base64,[a-z0-9+/=]+$/i.test( + url + ) + ) + return url; + if (policy.remote === 'block') return; + if (!hasScheme(url)) return url; + if (!/^https?:\/\//i.test(url)) return; + if (!policy.proxyUrl) + return url.replace(/^https?:/i, (scheme) => scheme.toLowerCase()); + if (!/^https?:\/\//i.test(policy.proxyUrl)) return; + return `${policy.proxyUrl}${policy.proxyUrl.includes('?') ? '&' : '?'}url=${encodeURIComponent(url)}`; +} + +export function linkUrl(value: string): string | undefined { + const url = cleanUrl(value); + if (/^(?:https?:\/\/|mailto:|tel:|sms:|cid:)/i.test(url) || !hasScheme(url)) + return url; +} diff --git a/packages/email-renderer/tests/boundaries.test.ts b/packages/email-renderer/tests/boundaries.test.ts new file mode 100644 index 00000000000..e3802de7d04 --- /dev/null +++ b/packages/email-renderer/tests/boundaries.test.ts @@ -0,0 +1,76 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import ts from 'typescript'; +import { expect, it } from 'vitest'; + +const src = resolve(import.meta.dirname, '../src'); + +it('type-checks production core without DOM libraries', () => { + const configPath = resolve(src, '../tsconfig.core.json'); + const config = ts.readConfigFile(configPath, ts.sys.readFile); + expect(config.error).toBeUndefined(); + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + dirname(configPath) + ); + const program = ts.createProgram(parsed.fileNames, parsed.options); + expect( + [...parsed.errors, ...ts.getPreEmitDiagnostics(program)].map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + ) + ).toEqual([]); +}); +function files(path: string): string[] { + return readdirSync(path, { withFileTypes: true }).flatMap((entry) => { + const file = resolve(path, entry.name); + return entry.isDirectory() + ? files(file) + : file.endsWith('.ts') && !file.endsWith('.test.ts') + ? [file] + : []; + }); +} + +it('enforces core -> parsers and browser -> core, with no framework or app imports', () => { + const violations: string[] = []; + for (const file of files(src)) { + const source = ts.createSourceFile( + file, + readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true + ); + function check(name: string) { + const target = relative(src, resolve(dirname(file), name)); + if (name === 'parse5' || name === 'css-tree') return; + if ( + !name.startsWith('.') || + target.startsWith('..') || + (relative(src, file).startsWith('core/') && !target.startsWith('core/')) + ) { + violations.push(`${relative(src, file)} -> ${name}`); + } + } + function visit(node: ts.Node) { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) + check(node.moduleSpecifier.text); + if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + node.expression.getText(source) === 'require') + ) { + const argument = node.arguments[0]; + if (argument && ts.isStringLiteral(argument)) check(argument.text); + else violations.push(`${file}: nonliteral module import`); + } + ts.forEachChild(node, visit); + } + visit(source); + } + expect(violations).toEqual([]); +}); diff --git a/packages/email-renderer/tests/fixtures/github-pr-review.json b/packages/email-renderer/tests/fixtures/github-pr-review.json new file mode 100644 index 00000000000..bd88a3a6513 --- /dev/null +++ b/packages/email-renderer/tests/fixtures/github-pr-review.json @@ -0,0 +1,5 @@ +{ + "name": "github-pr-review", + "description": "Unwrapped
 review diff. Without pre-wrap this inflates scrollWidth and used to zoom the whole letter.",
+  "html": "

cam requested changes on this pull request.

apps/web/src/features/block-email/component/EmailMessageBody.tsx

@@ -291,8 +291,20 @@ export function EmailMessageBody() {\n       const contentWidth = messageDiv.scrollWidth;\n-      messageDiv.style.zoom = `${container.clientWidth / contentWidth}`;\n+      const fit = fitToWidthZoom({ containerWidth: container.clientWidth, contentWidth });\n+      // One unwrapped pre line like this must not shrink the letter to dust: const leftoverWideCanvas = 'newsletter-table-width-836px-and-a-pathological-diff-hunk-that-would-be-two-thousand-pixels-if-white-space-were-pre';\n
" +} diff --git a/apps/web/src/lib/core/email/tests/fixtures/google-calendar-invite.json b/packages/email-renderer/tests/fixtures/google-calendar-invite.json similarity index 51% rename from apps/web/src/lib/core/email/tests/fixtures/google-calendar-invite.json rename to packages/email-renderer/tests/fixtures/google-calendar-invite.json index 7c15ea82b27..7c1a5b73c12 100644 --- a/apps/web/src/lib/core/email/tests/fixtures/google-calendar-invite.json +++ b/packages/email-renderer/tests/fixtures/google-calendar-invite.json @@ -1,5 +1,5 @@ { "name": "google-calendar-invite", "description": "Calendar invite from Google", - "body_html_sanitized": "Standup
Join with Google Meet – You have been invited by Alex Organizer to attend an event named Standup on Tuesday Jan 27, 2026 ⋅ 11am – 11:30am (Eastern Time - Toronto).
 

Invitation from Google Calendar

You are receiving this email because you are subscribed to calendar notifications. To stop receiving these emails, go to Calendar settings, select this calendar, and change \"Other notifications\".

Forwarding this invitation could allow any recipient to send a response to the organizer, be added to the guest list, invite others regardless of their own invitation status, or modify your RSVP. Learn more

" -} \ No newline at end of file + "html": "Standup
Join with Google Meet \u2013 You have been invited by Alex Organizer to attend an event named Standup on Tuesday Jan 27, 2026 \u22c5 11am \u2013 11:30am (Eastern Time - Toronto).
 

Invitation from Google Calendar

You are receiving this email because you are subscribed to calendar notifications. To stop receiving these emails, go to Calendar settings, select this calendar, and change \"Other notifications\".

Forwarding this invitation could allow any recipient to send a response to the organizer, be added to the guest list, invite others regardless of their own invitation status, or modify your RSVP. Learn more

" +} diff --git a/packages/email-renderer/tests/fixtures/nested-quotes.json b/packages/email-renderer/tests/fixtures/nested-quotes.json new file mode 100644 index 00000000000..8c4d4605c35 --- /dev/null +++ b/packages/email-renderer/tests/fixtures/nested-quotes.json @@ -0,0 +1,5 @@ +{ + "name": "nested-quotes", + "description": "Email thread with nested blockquotes", + "html": "

Thanks for the update!

On Monday, John wrote:

Original message here with some longer content that might wrap to multiple lines.

My reply to the original.

And here's my final response.

" +} diff --git a/packages/email-renderer/tests/fixtures/personal-calendar-response.json b/packages/email-renderer/tests/fixtures/personal-calendar-response.json new file mode 100644 index 00000000000..ad46d8c40c0 --- /dev/null +++ b/packages/email-renderer/tests/fixtures/personal-calendar-response.json @@ -0,0 +1,7 @@ +{ + "name": "personal-calendar-response", + "description": "Personal calendar acceptance: theme text and links, remove the pale status banner, and retain the table outline.", + "adaptColors": true, + "normalizeFonts": true, + "html": "
A guest has accepted this invitation.

When

Thursday September 3, 2026 · 7pm – 9pm (Eastern Time)

Guests

organizer@example.com – organizer
Guest
View all guest info

Invitation from Google Calendar

" +} diff --git a/packages/email-renderer/tests/fixtures/personal-letter.json b/packages/email-renderer/tests/fixtures/personal-letter.json new file mode 100644 index 00000000000..e12fbe9b8eb --- /dev/null +++ b/packages/email-renderer/tests/fixtures/personal-letter.json @@ -0,0 +1,7 @@ +{ + "name": "personal-letter", + "description": "Personal announcement: unstyled links inherit the theme accent while an intentionally colored button is preserved.", + "adaptColors": true, + "normalizeFonts": true, + "html": "

Thanks,
The team

Please update for iOS or Android.

Open the app

If you no longer want these messages, please let us know.

" +} diff --git a/packages/email-renderer/tests/fixtures/styled-email.json b/packages/email-renderer/tests/fixtures/styled-email.json new file mode 100644 index 00000000000..5d795b62ff0 --- /dev/null +++ b/packages/email-renderer/tests/fixtures/styled-email.json @@ -0,0 +1,5 @@ +{ + "name": "styled-email", + "description": "Email with inline styles and formatting", + "html": "

Welcome!

Thank you for signing up. We're excited to have you on board.

Click here to get started
" +} diff --git a/packages/email-renderer/tests/fixtures/wide-table.json b/packages/email-renderer/tests/fixtures/wide-table.json new file mode 100644 index 00000000000..48af9d99527 --- /dev/null +++ b/packages/email-renderer/tests/fixtures/wide-table.json @@ -0,0 +1,9 @@ +{ + "name": "wide-table", + "description": "Fixed-width 836px table. At 360px the viewer should floor zoom at 0.7 and scroll. At 800px it should shrink slightly without hitting the floor.", + "container_widths": [ + 360, + 800 + ], + "html": "
Panelist invitation for a designed newsletter table.
" +} diff --git a/packages/email-renderer/tests/rendering.pw.ts b/packages/email-renderer/tests/rendering.pw.ts new file mode 100644 index 00000000000..187e1fdd58a --- /dev/null +++ b/packages/email-renderer/tests/rendering.pw.ts @@ -0,0 +1,555 @@ +import { expect, type Page, test } from '@playwright/test'; +import type {} from '../viewer/main'; + +const imagePixel = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=', + 'base64' +); + +/** Static-content checks share mounting; lifecycle tests below control their own renderer. */ +async function mountHtml( + page: Page, + id: string, + html: string, + options: { theme?: 'light' | 'dark'; style?: Record } = {} +) { + await page.evaluate( + ({ id, html, options }) => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + host.id = id; + for (const [name, value] of Object.entries(options.style ?? {})) + host.style.setProperty(name, value); + document.body.append(host); + mountEmailBody(host, prepareEmailBody({ html }), { + theme: themes[options.theme ?? 'light'], + adaptColors: false, + normalizeFonts: false, + }); + }, + { id, html, options } + ); + return page.locator(`#${id}`); +} + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test('preserves CSS recovery and native variable cascade and fallbacks', async ({ + page, +}) => { + const host = await mountHtml( + page, + 'css-compat', + '
Hidden preview

Inherited green

Fallback red

Image map footer

', + { style: { '--gap': '9px' } } + ); + await expect(host.locator('.preheader')).toBeHidden(); + await expect(host.locator('.message')).toHaveCSS('font-size', '32px'); + await expect(host.locator('.message')).toHaveCSS('padding', '9px'); + await expect(host.getByText('Inherited green')).toHaveCSS( + 'color', + 'rgb(0, 128, 0)' + ); + await expect(host.locator('.fallback')).toHaveCSS( + 'color', + 'rgb(221, 17, 17)' + ); + await expect(host.locator('.fallback')).toHaveCSS('background-image', 'none'); + await expect(host.locator('area')).toHaveAttribute( + 'href', + 'https://example.com/accept' + ); + await expect(host.locator('area')).toHaveAttribute('target', '_blank'); + await expect(host.locator('area')).toHaveAttribute( + 'rel', + 'noopener noreferrer' + ); +}); + +test('loads protocol-relative images and retains protocol-relative navigation', async ({ + page, +}) => { + await page.route('https://renderer.invalid/image', (route) => + route.fulfill({ + contentType: 'image/png', + body: imagePixel, + }) + ); + const host = await mountHtml( + page, + 'url-compat', + 'Open' + ); + await expect + .poll(() => + host + .locator('img') + .evaluate((image) => (image as HTMLImageElement).naturalWidth) + ) + .toBe(1); + await expect(host.locator('a')).toHaveAttribute( + 'href', + 'https://example.com/open' + ); +}); + +test('preserves namespace, layer and scope styling in the browser', async ({ + page, +}) => { + const host = await mountHtml( + page, + 'grouped-css', + '

Styled inside scope

Outside scope

' + ); + await expect(host.getByText('Styled inside scope')).toHaveCSS( + 'color', + 'rgb(255, 0, 0)' + ); + await expect(host.getByText('Styled inside scope')).toHaveCSS( + 'font-size', + '32px' + ); + await expect(host.getByText('Outside scope')).toHaveCSS( + 'color', + 'rgb(0, 0, 0)' + ); +}); + +test('preserves nested and body theme rules while stripping top-level head overrides', async ({ + page, +}) => { + await page.emulateMedia({ colorScheme: 'dark' }); + const host = await mountHtml( + page, + 'theme-rule-scope', + '

Head

Nested

Body

', + { theme: 'dark' } + ); + await expect(host.getByText('Head', { exact: true })).toHaveCSS( + 'color', + 'rgb(0, 0, 0)' + ); + await expect(host.getByText('Nested', { exact: true })).toHaveCSS( + 'color', + 'rgb(0, 128, 0)' + ); + await expect(host.getByText('Body', { exact: true })).toHaveCSS( + 'color', + 'rgb(0, 0, 255)' + ); +}); + +test('keeps custom-property names consistent with selectors and container queries', async ({ + page, +}) => { + const host = await mountHtml( + page, + 'variable-names', + '

Variable styled text

' + ); + const text = host.getByText('Variable styled text'); + await expect(text).toHaveCSS('color', 'rgb(255, 0, 0)'); + await expect(text).toHaveCSS('font-size', '32px'); +}); + +test('allows image-set backgrounds explicitly and blocks strings expanded by CSS functions', async ({ + page, +}) => { + const requests: string[] = []; + await page.route('https://renderer.invalid/**', async (route) => { + requests.push(route.request().url()); + await route.fulfill({ + contentType: 'image/png', + body: imagePixel, + }); + }); + await page.evaluate(() => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + for (const remote of ['allow', 'block'] as const) { + const host = document.createElement('div'); + host.id = `image-set-${remote}`; + document.body.append(host); + const html = `
Variable image
Environment image
Direct image
`; + mountEmailBody(host, prepareEmailBody({ html }, { images: { remote } }), { + theme: themes.light, + adaptColors: false, + normalizeFonts: false, + }); + } + }); + await expect + .poll(() => requests.filter((url) => url.includes('/allow-')).length) + .toBe(3); + for (const label of ['Variable image', 'Environment image', 'Direct image']) + await expect(page.locator('#image-set-block').getByText(label)).toHaveCSS( + 'background-image', + 'none' + ); + expect(requests.filter((url) => url.includes('/block-'))).toEqual([]); +}); + +test('adapts text, links and status backgrounds after delayed attachment, exactly once', async ({ + page, +}) => { + await page.evaluate(async () => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + host.id = 'delayed-host'; + host.style.width = '600px'; + mountEmailBody( + host, + prepareEmailBody({ + html: '
A guest has accepted this invitation.

When: Thursday at 7pm

View all guest info', + }), + { theme: themes.dark, adaptColors: true, normalizeFonts: false } + ); + // Solid may construct a body before its parent/Suspense inserts the host. + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + document.body.append(host); + }); + const host = page.locator('#delayed-host'); + await expect(host.locator('#status')).toHaveCSS( + 'background-color', + 'rgba(0, 0, 0, 0)' + ); + await expect(host.locator('#details')).toHaveAttribute('style', /oklch/); + await expect(host.locator('a')).toHaveCSS('color', 'oklch(0.75 0.15 250)'); + const styles = () => + host.evaluate((element) => element.shadowRoot!.innerHTML); + const adapted = await styles(); + await host.evaluate((element) => { + element.style.width = '400px'; + }); + await expect(host).toHaveCSS('width', '400px'); + await page.evaluate(() => new Promise(requestAnimationFrame)); + expect(await styles()).toEqual(adapted); +}); + +test('prepares colors without waiting for an animation frame', async ({ + page, +}) => { + const color = await page.evaluate(async () => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const originalFrame = window.requestAnimationFrame; + window.requestAnimationFrame = () => 0; + const host = document.createElement('div'); + document.body.append(host); + const renderer = mountEmailBody( + host, + prepareEmailBody({ html: 'iOS' }), + { theme: themes.dark, adaptColors: true, normalizeFonts: false } + ); + try { + await Promise.resolve(); + return getComputedStyle(host.shadowRoot!.querySelector('a')!).color; + } finally { + renderer.dispose(); + host.remove(); + window.requestAnimationFrame = originalFrame; + } + }); + expect(color).toBe('oklch(0.75 0.15 250)'); +}); + +test('blocked resource policy prevents real requests from escaped CSS URL functions', async ({ + page, +}) => { + const remote: string[] = []; + page.on('request', (request) => { + if (request.url().includes('renderer.invalid')) remote.push(request.url()); + }); + await page.evaluate(async () => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + document.body.append(host); + const body = prepareEmailBody( + { + html: '

Text

', + }, + { images: { remote: 'block' } } + ); + const renderer = mountEmailBody(host, body, { + theme: themes.light, + adaptColors: false, + normalizeFonts: false, + }); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + renderer.dispose(); + }); + expect(remote).toEqual([]); +}); + +test('shadow isolation, link safety, literal plaintext and quote expansion use production preparation', async ({ + page, +}) => { + const result = await page.evaluate(() => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + document.body.append(host); + const options = { + theme: themes.light, + adaptColors: false, + normalizeFonts: false, + }; + const input = { + html: '

Hello Link

Quoted
', + }; + const prepared = prepareEmailBody(input); + const renderer = mountEmailBody(host, prepared, options); + const shortText = host.shadowRoot!.textContent; + const link = host.shadowRoot!.querySelector('a')!; + const linkPolicy = { target: link.target, rel: link.rel }; + renderer.update( + prepareEmailBody(input, { showQuotedContent: true }), + options + ); + const fullText = host.shadowRoot!.textContent; + renderer.update( + prepareEmailBody({ text: 'Literal\n**Markdown**' }), + options + ); + const plain = host.shadowRoot!.querySelector('div')!.textContent; + const outerColor = getComputedStyle(document.body).color; + renderer.dispose(); + renderer.dispose(); + return { + shortText, + fullText, + linkPolicy, + plain, + outerColor, + remaining: host.shadowRoot!.childNodes.length, + }; + }); + expect(result.shortText).not.toContain('Quoted'); + expect(result.fullText).toContain('Quoted'); + expect(result.linkPolicy).toEqual({ + target: '_blank', + rel: 'noopener noreferrer', + }); + expect(result.plain).toBe('Literal\n**Markdown**'); + expect(result.outerColor).not.toBe('rgb(255, 0, 0)'); + expect(result.remaining).toBe(0); +}); + +test('resizes wide content, removes stale scaling, hides collapsed images', async ({ + page, +}) => { + await page.evaluate(() => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + host.id = 'resize-host'; + host.style.width = '360px'; + document.body.append(host); + const renderer = mountEmailBody( + host, + prepareEmailBody({ + html: '
Wide
', + }), + { theme: themes.light, adaptColors: false, normalizeFonts: false } + ); + host.addEventListener('collapse', () => renderer.setExpanded(false)); + }); + const content = page.locator('#resize-host > div'); + await expect(content).toHaveCSS('zoom', '0.7'); + await expect(content).toHaveCSS('overflow-x', 'auto'); + await page.locator('#resize-host').evaluate((host) => { + (host as HTMLElement).style.width = '1200px'; + }); + await expect(content).toHaveCSS('zoom', '1'); + await expect(content).toHaveCSS('overflow-x', 'visible'); + await page.locator('#resize-host').dispatchEvent('collapse'); + await expect(content).toHaveCSS('overflow-x', 'hidden'); + await expect(content).toHaveCSS('overflow-y', 'hidden'); + await expect(page.locator('#resize-host img')).toHaveCSS('display', 'none'); +}); + +test('collapsed HTML stays within three lines without replacing image resources', async ({ + page, +}) => { + const result = await page.evaluate(async () => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + // Match the reader's outer clamp. Containment must not let the full body + // escape this summary when the renderer establishes its own layout boundary. + const wrapper = document.createElement('div'); + wrapper.style.cssText = + 'width:240px;font:16px/20px sans-serif;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden'; + const host = document.createElement('div'); + wrapper.append(host); + document.body.append(wrapper); + let resolutions = 0; + const renderer = mountEmailBody( + host, + prepareEmailBody({ + html: `

${'A line of text
'.repeat(20)}

`, + }), + { + theme: themes.light, + adaptColors: false, + normalizeFonts: false, + expanded: false, + async resolveImages() { + resolutions++; + }, + } + ); + const image = host.shadowRoot!.querySelector('img'); + const frame = () => new Promise(requestAnimationFrame); + await frame(); + const collapsed = wrapper.getBoundingClientRect().height; + renderer.setExpanded(true); + await frame(); + const expanded = wrapper.getBoundingClientRect().height; + renderer.setExpanded(false); + await frame(); + const collapsedAgain = wrapper.getBoundingClientRect().height; + const sameImage = host.shadowRoot!.querySelector('img') === image; + renderer.dispose(); + wrapper.remove(); + return { collapsed, expanded, collapsedAgain, sameImage, resolutions }; + }); + expect(result.collapsed).toBe(60); + expect(result.expanded).toBeGreaterThan(300); + expect(result.collapsedAgain).toBe(60); + expect(result.sameImage).toBe(true); + expect(result.resolutions).toBe(1); +}); + +test('aborts replaced resources, releases late results, and handles rejected adapters', async ({ + page, +}) => { + const result = await page.evaluate(async () => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + document.body.append(host); + const signals: AbortSignal[] = []; + const released: number[] = []; + const errors: string[] = []; + let finish = () => {}; + const options = { + theme: themes.light, + adaptColors: true, + normalizeFonts: false, + }; + const renderer = mountEmailBody( + host, + prepareEmailBody({ html: '

First

' }), + { + ...options, + async resolveImages(_root, lifetime) { + signals.push(lifetime.signal); + await new Promise((resolve) => { + finish = resolve; + }); + lifetime.onDispose(() => released.push(1)); + }, + } + ); + await Promise.resolve(); + renderer.update(prepareEmailBody({ html: '

Second

' }), { + ...options, + async resolveImages(_root, lifetime) { + signals.push(lifetime.signal); + throw new Error('Image unavailable'); + }, + onResourceError(error) { + errors.push(String(error)); + }, + }); + finish(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const text = host.shadowRoot!.querySelector('div')!.textContent; + renderer.dispose(); + return { + aborted: signals.map((signal) => signal.aborted), + released, + errors, + text, + }; + }); + expect(result).toEqual({ + aborted: [true, true], + released: [1], + errors: ['Error: Image unavailable'], + text: 'Second', + }); +}); + +test('theme updates start from prepared content rather than accumulating color transforms', async ({ + page, +}) => { + const result = await page.evaluate(async () => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + document.body.append(host); + const prepared = prepareEmailBody({ + html: '

Text Link

', + }); + const frame = () => new Promise(requestAnimationFrame); + const renderer = mountEmailBody(host, prepared, { + theme: themes.light, + adaptColors: true, + normalizeFonts: false, + }); + await frame(); + const light = host.shadowRoot!.querySelector('p')!.getAttribute('style'); + renderer.update(prepared, { + theme: themes.dark, + adaptColors: true, + normalizeFonts: false, + }); + await frame(); + const dark = host.shadowRoot!.querySelector('p')!.getAttribute('style'); + renderer.update(prepared, { + theme: themes.light, + adaptColors: true, + normalizeFonts: false, + }); + await frame(); + const lightAgain = host + .shadowRoot!.querySelector('p')! + .getAttribute('style'); + renderer.dispose(); + return { light, dark, lightAgain }; + }); + expect(result.dark).not.toEqual(result.light); + expect(result.lightAgain).toEqual(result.light); +}); + +test('background adaptation preserves designed buttons and removes nested page backgrounds', async ({ + page, +}) => { + const colors = await page.evaluate(async () => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + document.body.append(host); + const renderer = mountEmailBody( + host, + prepareEmailBody({ + html: '
Text
Button', + }), + { theme: themes.dark, adaptColors: true, normalizeFonts: true } + ); + await new Promise(requestAnimationFrame); + const result = Object.fromEntries( + ['outer', 'inner', 'border', 'face', 'dark'].map((id) => [ + id, + (host.shadowRoot!.querySelector(`#${id}`) as HTMLElement).style + .backgroundColor, + ]) + ); + renderer.dispose(); + return result; + }); + expect(colors).toEqual({ + outer: 'transparent', + inner: 'transparent', + border: 'rgb(81, 177, 231)', + face: 'rgb(228, 236, 242)', + dark: 'rgb(126, 130, 201)', + }); +}); diff --git a/packages/email-renderer/tests/snapshots.pw.ts b/packages/email-renderer/tests/snapshots.pw.ts new file mode 100644 index 00000000000..90cf88a01f5 --- /dev/null +++ b/packages/email-renderer/tests/snapshots.pw.ts @@ -0,0 +1,32 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { expect, test } from '@playwright/test'; + +for (const file of readdirSync(new URL('./fixtures', import.meta.url)).filter( + (file) => file.endsWith('.json') +)) { + const fixture = JSON.parse( + readFileSync(new URL(`./fixtures/${file}`, import.meta.url), 'utf8') + ) as { name: string; container_widths?: number[] }; + for (const theme of ['light', 'dark']) { + for (const width of fixture.container_widths ?? [600]) { + test(`${fixture.name} ${theme} ${width}`, async ({ page }) => { + const errors: string[] = []; + const remote: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('request', (request) => { + if (!request.url().startsWith('http://127.0.0.1:24821')) + remote.push(request.url()); + }); + await page.goto( + `/?fixture=${fixture.name}&theme=${theme}&width=${width}` + ); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator('#email-host')).toHaveScreenshot( + `${fixture.name}-${theme}-${width}.png` + ); + expect(errors).toEqual([]); + expect(remote).toEqual([]); + }); + } + } +} diff --git a/packages/email-renderer/tests/snapshots/github-pr-review-dark-600.png b/packages/email-renderer/tests/snapshots/github-pr-review-dark-600.png new file mode 100644 index 00000000000..98d95f8838e Binary files /dev/null and b/packages/email-renderer/tests/snapshots/github-pr-review-dark-600.png differ diff --git a/packages/email-renderer/tests/snapshots/github-pr-review-light-600.png b/packages/email-renderer/tests/snapshots/github-pr-review-light-600.png new file mode 100644 index 00000000000..e7517bf54c3 Binary files /dev/null and b/packages/email-renderer/tests/snapshots/github-pr-review-light-600.png differ diff --git a/packages/email-renderer/tests/snapshots/google-calendar-invite-dark-600.png b/packages/email-renderer/tests/snapshots/google-calendar-invite-dark-600.png new file mode 100644 index 00000000000..eecb7c7db50 Binary files /dev/null and b/packages/email-renderer/tests/snapshots/google-calendar-invite-dark-600.png differ diff --git a/packages/email-renderer/tests/snapshots/google-calendar-invite-light-600.png b/packages/email-renderer/tests/snapshots/google-calendar-invite-light-600.png new file mode 100644 index 00000000000..eecb7c7db50 Binary files /dev/null and b/packages/email-renderer/tests/snapshots/google-calendar-invite-light-600.png differ diff --git a/packages/email-renderer/tests/snapshots/nested-quotes-dark-600.png b/packages/email-renderer/tests/snapshots/nested-quotes-dark-600.png new file mode 100644 index 00000000000..8d18bf41f0b Binary files /dev/null and b/packages/email-renderer/tests/snapshots/nested-quotes-dark-600.png differ diff --git a/packages/email-renderer/tests/snapshots/nested-quotes-light-600.png b/packages/email-renderer/tests/snapshots/nested-quotes-light-600.png new file mode 100644 index 00000000000..b51535acbe9 Binary files /dev/null and b/packages/email-renderer/tests/snapshots/nested-quotes-light-600.png differ diff --git a/packages/email-renderer/tests/snapshots/personal-calendar-response-dark-600.png b/packages/email-renderer/tests/snapshots/personal-calendar-response-dark-600.png new file mode 100644 index 00000000000..dad8104c16a Binary files /dev/null and b/packages/email-renderer/tests/snapshots/personal-calendar-response-dark-600.png differ diff --git a/packages/email-renderer/tests/snapshots/personal-calendar-response-light-600.png b/packages/email-renderer/tests/snapshots/personal-calendar-response-light-600.png new file mode 100644 index 00000000000..fc83a2b6d09 Binary files /dev/null and b/packages/email-renderer/tests/snapshots/personal-calendar-response-light-600.png differ diff --git a/packages/email-renderer/tests/snapshots/personal-letter-dark-600.png b/packages/email-renderer/tests/snapshots/personal-letter-dark-600.png new file mode 100644 index 00000000000..36b86416935 Binary files /dev/null and b/packages/email-renderer/tests/snapshots/personal-letter-dark-600.png differ diff --git a/packages/email-renderer/tests/snapshots/personal-letter-light-600.png b/packages/email-renderer/tests/snapshots/personal-letter-light-600.png new file mode 100644 index 00000000000..eaf95550b9c Binary files /dev/null and b/packages/email-renderer/tests/snapshots/personal-letter-light-600.png differ diff --git a/packages/email-renderer/tests/snapshots/styled-email-dark-600.png b/packages/email-renderer/tests/snapshots/styled-email-dark-600.png new file mode 100644 index 00000000000..225e1cc5987 Binary files /dev/null and b/packages/email-renderer/tests/snapshots/styled-email-dark-600.png differ diff --git a/packages/email-renderer/tests/snapshots/styled-email-light-600.png b/packages/email-renderer/tests/snapshots/styled-email-light-600.png new file mode 100644 index 00000000000..1829de91333 Binary files /dev/null and b/packages/email-renderer/tests/snapshots/styled-email-light-600.png differ diff --git a/packages/email-renderer/tests/snapshots/wide-table-dark-360.png b/packages/email-renderer/tests/snapshots/wide-table-dark-360.png new file mode 100644 index 00000000000..e73134630d3 Binary files /dev/null and b/packages/email-renderer/tests/snapshots/wide-table-dark-360.png differ diff --git a/packages/email-renderer/tests/snapshots/wide-table-dark-800.png b/packages/email-renderer/tests/snapshots/wide-table-dark-800.png new file mode 100644 index 00000000000..a6bfa3dba3e Binary files /dev/null and b/packages/email-renderer/tests/snapshots/wide-table-dark-800.png differ diff --git a/packages/email-renderer/tests/snapshots/wide-table-light-360.png b/packages/email-renderer/tests/snapshots/wide-table-light-360.png new file mode 100644 index 00000000000..97cf4a9cb67 Binary files /dev/null and b/packages/email-renderer/tests/snapshots/wide-table-light-360.png differ diff --git a/packages/email-renderer/tests/snapshots/wide-table-light-800.png b/packages/email-renderer/tests/snapshots/wide-table-light-800.png new file mode 100644 index 00000000000..a6bfa3dba3e Binary files /dev/null and b/packages/email-renderer/tests/snapshots/wide-table-light-800.png differ diff --git a/packages/email-renderer/tsconfig.core.json b/packages/email-renderer/tsconfig.core.json new file mode 100644 index 00000000000..b8a1d0e2991 --- /dev/null +++ b/packages/email-renderer/tsconfig.core.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "lib": ["ES2022"], "types": [] }, + "include": ["src/core"], + "exclude": ["**/*.test.ts"] +} diff --git a/packages/email-renderer/tsconfig.json b/packages/email-renderer/tsconfig.json new file mode 100644 index 00000000000..2b243527966 --- /dev/null +++ b/packages/email-renderer/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node", "vite/client"] + }, + "include": ["src", "tests", "viewer", "*.ts"] +} diff --git a/packages/email-renderer/viewer/main.ts b/packages/email-renderer/viewer/main.ts new file mode 100644 index 00000000000..e1e8c1fad7c --- /dev/null +++ b/packages/email-renderer/viewer/main.ts @@ -0,0 +1,92 @@ +import '@fontsource-variable/inter'; +import { + type EmailBodyRenderer, + mountEmailBody, + type ThemeColorParams, +} from '../src/browser'; +import { type EmailBodyInput, prepareEmailBody } from '../src/core'; +import './style.css'; + +export const themes: Record<'light' | 'dark', ThemeColorParams> = { + light: { + inkL: 0.2, + inkC: 0, + inkH: 0, + panelL: 0.98, + accentL: 0.5, + accentC: 0.15, + accentH: 250, + }, + dark: { + inkL: 0.9, + inkC: 0, + inkH: 0, + panelL: 0.2, + accentL: 0.75, + accentC: 0.15, + accentH: 250, + }, +}; +interface Fixture extends EmailBodyInput { + name: string; + description: string; + container_widths?: number[]; + adaptColors?: boolean; + normalizeFonts?: boolean; +} +const fixtures = Object.values( + import.meta.glob('../tests/fixtures/*.json', { + eager: true, + import: 'default', + }) +); +const select = document.querySelector('#fixture')!; +const themeSelect = document.querySelector('#theme')!; +const widthInput = document.querySelector('#width')!; +const fullInput = document.querySelector('#full')!; +const expandedInput = document.querySelector('#expanded')!; +const host = document.querySelector('#email-host')!; +let renderer: EmailBodyRenderer | undefined; +for (const fixture of fixtures) + select.add(new Option(fixture.name, fixture.name)); +const params = new URLSearchParams(location.search); +select.value = params.get('fixture') ?? fixtures[0].name; +themeSelect.value = params.get('theme') ?? 'light'; +widthInput.value = params.get('width') ?? '600'; + +function renderFixture() { + const fixture = + fixtures.find((item) => item.name === select.value) ?? fixtures[0]; + const theme = themes[themeSelect.value === 'dark' ? 'dark' : 'light']; + document.body.style.backgroundColor = `oklch(${theme.panelL} 0 0)`; + document.body.style.color = `oklch(${theme.inkL} 0 0)`; + host.style.width = `${Math.max(240, Math.min(1200, Number(widthInput.value) || 600))}px`; + document.querySelector('#description')!.textContent = fixture.description; + const prepared = prepareEmailBody(fixture, { + showQuotedContent: fullInput.checked, + images: { remote: 'block' }, + }); + const options = { + theme, + adaptColors: fixture.adaptColors ?? !prepared.hasTable, + normalizeFonts: fixture.normalizeFonts ?? false, + expanded: expandedInput.checked, + }; + if (renderer) renderer.update(prepared, options); + else renderer = mountEmailBody(host, prepared, options); +} +for (const input of [select, themeSelect, widthInput, fullInput, expandedInput]) + input.addEventListener('change', renderFixture); +renderFixture(); + +// The fixture viewer and browser tests deliberately share the public API. +window.emailRenderer = { prepareEmailBody, mountEmailBody, themes }; +declare global { + interface Window { + emailRenderer: { + prepareEmailBody: typeof prepareEmailBody; + mountEmailBody: typeof mountEmailBody; + themes: typeof themes; + }; + } +} diff --git a/packages/email-renderer/viewer/style.css b/packages/email-renderer/viewer/style.css new file mode 100644 index 00000000000..e52f52194dc --- /dev/null +++ b/packages/email-renderer/viewer/style.css @@ -0,0 +1,5 @@ +body { margin: 24px; font: 14px/1.5 'Inter Variable', sans-serif; } +header { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; } +input[type=number] { width: 70px; } +main { padding-top: 16px; } +#email-host { background: inherit; } diff --git a/packages/email-renderer/vitest.config.ts b/packages/email-renderer/vitest.config.ts new file mode 100644 index 00000000000..877e68701b3 --- /dev/null +++ b/packages/email-renderer/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + name: 'email-renderer', + environment: 'node', + include: ['src/**/*.test.ts', 'tests/*.test.ts'], + }, +}); diff --git a/rules/ast-grep/ts-email-no-block-dependencies.yml b/rules/ast-grep/ts-email-no-block-dependencies.yml new file mode 100644 index 00000000000..25fff4a16e0 --- /dev/null +++ b/rules/ast-grep/ts-email-no-block-dependencies.yml @@ -0,0 +1,25 @@ +id: ts-email-no-block-dependencies +language: typescript +severity: error +message: Email features must not import block signals or block packages. Put host integration in block-email/EmailBlockAdapter.tsx. +files: + - apps/web/src/features/email-message/** + - apps/web/src/features/email-thread/** + - apps/web/src/features/email-compose/** +rule: + kind: string + regex: ^['"](@block-|@core/block|@core/signal/(block|load)|@app/features/block-|.*[/]block-[^/]*/) + any: + - inside: + kind: import_statement + field: source + - inside: + kind: export_statement + field: source + - inside: + kind: arguments + inside: + kind: call_expression + has: + field: function + kind: import diff --git a/rules/ast-grep/ts-feature-components-presentational.yml b/rules/ast-grep/ts-feature-components-presentational.yml index 17d24842715..ce5f8bd23ad 100644 --- a/rules/ast-grep/ts-feature-components-presentational.yml +++ b/rules/ast-grep/ts-feature-components-presentational.yml @@ -13,6 +13,9 @@ note: |- untyped wire JSON for property-changed rows. files: - apps/web/src/features/activity/components/** + - apps/web/src/features/email-message/components/** + - apps/web/src/features/email-thread/components/** + - apps/web/src/features/email-compose/components/** rule: any: - kind: string diff --git a/rules/ast-grep/ts-feature-core-pure.yml b/rules/ast-grep/ts-feature-core-pure.yml index b5d79229192..f2656b91448 100644 --- a/rules/ast-grep/ts-feature-core-pure.yml +++ b/rules/ast-grep/ts-feature-core-pure.yml @@ -12,11 +12,17 @@ note: |- reactive view models in `primitives/`. The `dateBucket` import from `soup/collection/date-buckets` (a pure date-fns module, not the soup barrel) is the documented exception for feed labels that must match soup views. + The shared @core/util/base64 codec is also pure and may be reused by email. files: - apps/web/src/features/activity/core/** + - apps/web/src/features/email-message/core/** + - apps/web/src/features/email-thread/core/** + - apps/web/src/features/email-compose/core/** rule: kind: string regex: ^['"](solid-js|@urql|urql|@app/lib/urql|@queries/|@service-storage/|@core/|@property/|@components/|@ui|@entity/|(\.\./)+(queries|primitives|components|views)/|(\.\./)+context/) + not: + regex: ^['"]@core/util/base64['"] any: - inside: kind: import_statement diff --git a/rules/ast-grep/ts-feature-data-no-ui.yml b/rules/ast-grep/ts-feature-data-no-ui.yml index 50aa9b2cace..68f8e2f3b60 100644 --- a/rules/ast-grep/ts-feature-data-no-ui.yml +++ b/rules/ast-grep/ts-feature-data-no-ui.yml @@ -5,14 +5,20 @@ language: typescript severity: warning message: Feature queries/ and primitives/ never import components, views, or UI primitives. They produce data and view-state; views render it. note: |- - Rule FE-33 in docs/STYLE_GUIDE.md. `queries/` decodes wire types and - builds query factories; `primitives/` turns query results into view-state - unions and actions with Solid primitives but no JSX. Both are tested - under `createRoot` against a mock client, which only stays possible - while they import nothing that renders. + Rule FE-33 in docs/STYLE_GUIDE.md. `queries/` adapts wire types and + builds query factories; `primitives/` consumes feature-owned capabilities + to produce state and actions with Solid primitives but no JSX. Test adapters + with fake clients and controllers with fake capabilities; neither imports + rendering code. files: - apps/web/src/features/activity/queries/** - apps/web/src/features/activity/primitives/** + - apps/web/src/features/email-message/queries/** + - apps/web/src/features/email-message/primitives/** + - apps/web/src/features/email-thread/queries/** + - apps/web/src/features/email-thread/primitives/** + - apps/web/src/features/email-compose/queries/** + - apps/web/src/features/email-compose/primitives/** rule: kind: string regex: ^['"](solid-js/web|@ui|@components/|(\.\./)+(components|views)/) diff --git a/rules/ast-grep/ts-feature-layers-use-context.yml b/rules/ast-grep/ts-feature-layers-use-context.yml index 3eb84bcc065..786e378ce7f 100644 --- a/rules/ast-grep/ts-feature-layers-use-context.yml +++ b/rules/ast-grep/ts-feature-layers-use-context.yml @@ -3,24 +3,38 @@ language: typescript # Twin of tsx-feature-layers-use-context — ast-grep rules are single-language, # and `tsx` only scans .tsx files. severity: warning -message: Layered features reach the app only through context/. Add the capability to the feature's Context type and wire it in context/. +message: Supply app capabilities through feature-owned contracts; construct production adapters at the feature root. note: |- Rule FE-33 in docs/STYLE_GUIDE.md. Inside a layered feature, `queries/`, `primitives/`, `components/`, and `views/` never import the app's singletons or context hooks (GraphQL soup client, user context, display names, entity display, openDocument, PostHog). Those arrive through the - feature's `Context` record so every layer runs against mocks in tests. Only - `context/` (the contract and its production wiring) at the - feature root touch them. Adopting features add their layer paths to + feature's `Context` record so every layer runs against mocks in tests. Keep `context/` free of production imports. Only production entry points + and adapters at the feature root construct app capabilities. The pure + `@core/user/macroId` identity helpers are a documented exception. Adopting features add their layer paths to `files` in each ts-/tsx-feature-* rule. files: - apps/web/src/features/activity/components/** - apps/web/src/features/activity/queries/** - apps/web/src/features/activity/primitives/** - apps/web/src/features/activity/views/** + - apps/web/src/features/email-message/components/** + - apps/web/src/features/email-message/queries/** + - apps/web/src/features/email-message/primitives/** + - apps/web/src/features/email-message/views/** + - apps/web/src/features/email-thread/components/** + - apps/web/src/features/email-thread/queries/** + - apps/web/src/features/email-thread/primitives/** + - apps/web/src/features/email-thread/views/** + - apps/web/src/features/email-compose/components/** + - apps/web/src/features/email-compose/queries/** + - apps/web/src/features/email-compose/primitives/** + - apps/web/src/features/email-compose/views/** rule: kind: string regex: ^['"](@service-storage/graphql-soup|@core/context/user|@core/user|@property/hooks|@property/editor/hooks|@core/component/LexicalMarkdown/component/core/BlockLink|@app/lib/analytics/posthog) + not: + regex: ^['"]@core/user/macroId['"] any: - inside: kind: import_statement diff --git a/rules/ast-grep/tsx-email-no-block-dependencies.yml b/rules/ast-grep/tsx-email-no-block-dependencies.yml new file mode 100644 index 00000000000..a275a39ec8f --- /dev/null +++ b/rules/ast-grep/tsx-email-no-block-dependencies.yml @@ -0,0 +1,25 @@ +id: tsx-email-no-block-dependencies +language: tsx +severity: error +message: Email features must not import block signals or block packages. Put host integration in block-email/EmailBlockAdapter.tsx. +files: + - apps/web/src/features/email-message/** + - apps/web/src/features/email-thread/** + - apps/web/src/features/email-compose/** +rule: + kind: string + regex: ^['"](@block-|@core/block|@core/signal/(block|load)|@app/features/block-|.*[/]block-[^/]*/) + any: + - inside: + kind: import_statement + field: source + - inside: + kind: export_statement + field: source + - inside: + kind: arguments + inside: + kind: call_expression + has: + field: function + kind: import diff --git a/rules/ast-grep/tsx-feature-components-presentational.yml b/rules/ast-grep/tsx-feature-components-presentational.yml index 0177c28001a..22fe899cee8 100644 --- a/rules/ast-grep/tsx-feature-components-presentational.yml +++ b/rules/ast-grep/tsx-feature-components-presentational.yml @@ -13,6 +13,9 @@ note: |- untyped wire JSON for property-changed rows. files: - apps/web/src/features/activity/components/** + - apps/web/src/features/email-message/components/** + - apps/web/src/features/email-thread/components/** + - apps/web/src/features/email-compose/components/** rule: any: - kind: string diff --git a/rules/ast-grep/tsx-feature-core-pure.yml b/rules/ast-grep/tsx-feature-core-pure.yml index 2937af47a25..94bfd15f94a 100644 --- a/rules/ast-grep/tsx-feature-core-pure.yml +++ b/rules/ast-grep/tsx-feature-core-pure.yml @@ -12,11 +12,17 @@ note: |- reactive view models in `primitives/`. The `dateBucket` import from `soup/collection/date-buckets` (a pure date-fns module, not the soup barrel) is the documented exception for feed labels that must match soup views. + The shared @core/util/base64 codec is also pure and may be reused by email. files: - apps/web/src/features/activity/core/** + - apps/web/src/features/email-message/core/** + - apps/web/src/features/email-thread/core/** + - apps/web/src/features/email-compose/core/** rule: kind: string regex: ^['"](solid-js|@urql|urql|@app/lib/urql|@queries/|@service-storage/|@core/|@property/|@components/|@ui|@entity/|(\.\./)+(queries|primitives|components|views)/|(\.\./)+context/) + not: + regex: ^['"]@core/util/base64['"] any: - inside: kind: import_statement diff --git a/rules/ast-grep/tsx-feature-data-no-ui.yml b/rules/ast-grep/tsx-feature-data-no-ui.yml index fb9d0fbfabf..1d981058ec2 100644 --- a/rules/ast-grep/tsx-feature-data-no-ui.yml +++ b/rules/ast-grep/tsx-feature-data-no-ui.yml @@ -5,14 +5,20 @@ language: tsx severity: warning message: Feature queries/ and primitives/ never import components, views, or UI primitives. They produce data and view-state; views render it. note: |- - Rule FE-33 in docs/STYLE_GUIDE.md. `queries/` decodes wire types and - builds query factories; `primitives/` turns query results into view-state - unions and actions with Solid primitives but no JSX. Both are tested - under `createRoot` against a mock client, which only stays possible - while they import nothing that renders. + Rule FE-33 in docs/STYLE_GUIDE.md. `queries/` adapts wire types and + builds query factories; `primitives/` consumes feature-owned capabilities + to produce state and actions with Solid primitives but no JSX. Test adapters + with fake clients and controllers with fake capabilities; neither imports + rendering code. files: - apps/web/src/features/activity/queries/** - apps/web/src/features/activity/primitives/** + - apps/web/src/features/email-message/queries/** + - apps/web/src/features/email-message/primitives/** + - apps/web/src/features/email-thread/queries/** + - apps/web/src/features/email-thread/primitives/** + - apps/web/src/features/email-compose/queries/** + - apps/web/src/features/email-compose/primitives/** rule: kind: string regex: ^['"](solid-js/web|@ui|@components/|(\.\./)+(components|views)/) diff --git a/rules/ast-grep/tsx-feature-layers-use-context.yml b/rules/ast-grep/tsx-feature-layers-use-context.yml index 8eec3ddccd2..e8bc05ab9f4 100644 --- a/rules/ast-grep/tsx-feature-layers-use-context.yml +++ b/rules/ast-grep/tsx-feature-layers-use-context.yml @@ -3,24 +3,38 @@ language: tsx # Twin of ts-feature-layers-use-context — ast-grep rules are single-language, # and `typescript` only scans .ts files. severity: warning -message: Layered features reach the app only through context/. Add the capability to the feature's Context type and wire it in context/. +message: Supply app capabilities through feature-owned contracts; construct production adapters at the feature root. note: |- Rule FE-33 in docs/STYLE_GUIDE.md. Inside a layered feature, `queries/`, `primitives/`, `components/`, and `views/` never import the app's singletons or context hooks (GraphQL soup client, user context, display names, entity display, openDocument, PostHog). Those arrive through the - feature's `Context` record so every layer runs against mocks in tests. Only - `context/` (the contract and its production wiring) at the - feature root touch them. Adopting features add their layer paths to + feature's `Context` record so every layer runs against mocks in tests. Keep `context/` free of production imports. Only production entry points + and adapters at the feature root construct app capabilities. The pure + `@core/user/macroId` identity helpers are a documented exception. Adopting features add their layer paths to `files` in each ts-/tsx-feature-* rule. files: - apps/web/src/features/activity/components/** - apps/web/src/features/activity/queries/** - apps/web/src/features/activity/primitives/** - apps/web/src/features/activity/views/** + - apps/web/src/features/email-message/components/** + - apps/web/src/features/email-message/queries/** + - apps/web/src/features/email-message/primitives/** + - apps/web/src/features/email-message/views/** + - apps/web/src/features/email-thread/components/** + - apps/web/src/features/email-thread/queries/** + - apps/web/src/features/email-thread/primitives/** + - apps/web/src/features/email-thread/views/** + - apps/web/src/features/email-compose/components/** + - apps/web/src/features/email-compose/queries/** + - apps/web/src/features/email-compose/primitives/** + - apps/web/src/features/email-compose/views/** rule: kind: string regex: ^['"](@service-storage/graphql-soup|@core/context/user|@core/user|@property/hooks|@property/editor/hooks|@core/component/LexicalMarkdown/component/core/BlockLink|@app/lib/analytics/posthog) + not: + regex: ^['"]@core/user/macroId['"] any: - inside: kind: import_statement diff --git a/tooling/just/check.just b/tooling/just/check.just index 407d92bbd35..1e39ff64ea0 100644 --- a/tooling/just/check.just +++ b/tooling/just/check.just @@ -38,12 +38,16 @@ check *ARGS: || { echo " fix: cargo fmt"; fail=1; } fi if [ -n "$js" ]; then - echo "── biome (apps/web format + lint, same flags as CI)" + # Both tools run in apps/web. Absolute paths also reach package and + # service files; quoted arguments preserve literal path characters. + js_paths=() + while IFS= read -r f; do js_paths+=("$PWD/$f"); done <<<"$js" + echo "── biome (changed JS/TS format + lint, same flags as CI)" (cd apps/web && bunx --bun @biomejs/biome ci --colors=off --no-errors-on-unmatched \ - --error-on-warnings $(sed 's|^apps/web/||' <<<"$js")) \ + --error-on-warnings "${js_paths[@]}") \ || { echo " fix: bun run fix, then re-check"; fail=1; } echo "── oxlint (warn-only; docs/STYLE_GUIDE.md FE-12)" - (cd apps/web && bunx --yes oxlint@1.73.0 $(sed 's|^apps/web/||' <<<"$js")) || fail=1 + (cd apps/web && bunx --yes oxlint@1.73.0 "${js_paths[@]}") || fail=1 fi if [ -n "$sg" ]; then echo "── ast-grep code rules (rule ids map to docs/STYLE_GUIDE.md)"