From e611f5dda68ac988a7605848def3fd7477f6f8d5 Mon Sep 17 00:00:00 2001 From: synoet Date: Sat, 5 Sep 2026 05:38:41 +0000 Subject: [PATCH 01/21] docs(web): define frontend feature architecture and skill --- .agents/skills/frontend-feature-architecture | 1 + .../frontend-feature-architecture/SKILL.md | 212 ++++++ apps/web/AGENTS.md | 2 +- docs/FRONTEND_FEATURE_ARCHITECTURE.md | 618 ++++++++++++++++++ docs/STYLE_GUIDE.md | 57 +- 5 files changed, 864 insertions(+), 26 deletions(-) create mode 120000 .agents/skills/frontend-feature-architecture create mode 100644 .claude/skills/frontend-feature-architecture/SKILL.md create mode 100644 docs/FRONTEND_FEATURE_ARCHITECTURE.md diff --git a/.agents/skills/frontend-feature-architecture b/.agents/skills/frontend-feature-architecture new file mode 120000 index 00000000000..e25e8b5f184 --- /dev/null +++ b/.agents/skills/frontend-feature-architecture @@ -0,0 +1 @@ +../../.claude/skills/frontend-feature-architecture \ No newline at end of file diff --git a/.claude/skills/frontend-feature-architecture/SKILL.md b/.claude/skills/frontend-feature-architecture/SKILL.md new file mode 100644 index 00000000000..fae5e8a2d3d --- /dev/null +++ b/.claude/skills/frontend-feature-architecture/SKILL.md @@ -0,0 +1,212 @@ +--- +name: frontend-feature-architecture +description: Structure, refactor, and review frontend feature folders in apps/web using layered responsibilities, separate production composition, and narrow feature-owned contracts. Use when adding a feature, moving feature modules, separating data and UI, or changing feature dependency injection and composition boundaries. +--- + +# Frontend feature architecture + +Apply the layered frontend architecture developed from `features/activity` in +PR #6176 (`f598574d7`), with the stronger composition and capability boundaries +below. Activity demonstrates the layer responsibilities but still combines its +context with production wiring and injects a raw GraphQL client into state logic; +those are migration gaps, not patterns to copy. For a focused edit, keep the +changed code within its owning boundary without expanding into an unrelated +migration. + +Read `apps/web/AGENTS.md` and +[docs/FRONTEND_FEATURE_ARCHITECTURE.md](../../../docs/FRONTEND_FEATURE_ARCHITECTURE.md) +before designing or reviewing a restructure. The document is the detailed source +for responsibilities, examples, migration steps, testing, and reference exceptions. +FE-33 in `docs/STYLE_GUIDE.md` is the summary rule. + +Paths in this skill are repository-relative unless linked otherwise. Locate the +repository root before running commands. This skill is shared through +`.claude/skills/frontend-feature-architecture` and +`.agents/skills/frontend-feature-architecture`; maintain one copy of its contents. + +## Classify before editing + +Inspect the feature's imports, callers, tests, route registration, and flag gates. +Identify what is pure logic, transport adaptation, reactive behavior, presentation, +composition, an ambient capability, or a host-specific action. Do not infer the +architecture from existing filenames alone. + +| Location | Owns | Keep out | +| --- | --- | --- | +| `core/` | Feature types, domain unions, pure grouping/descriptions/calculations | Solid, JSX, generated GraphQL, app capabilities, sibling layers | +| `queries/` | Wire decoding, selectors, feature-owned query/mutation factories and cache mechanics | JSX, components/views, hidden singleton clients | +| `primitives/` | Reactive use-case state and actions over feature-owned contracts | JSX, rendering imports, concrete query adapters, direct ambient app hooks | +| `components/` | JSX from domain values, resolved display, children/slots, handlers | Queries, feature primitives/context consumption, app navigation policy | +| `views/` | Context consumption and use-case composition | Duplicated query-state logic, direct ambient capability imports | +| `context/` | Feature-owned capability contracts and provider/consumer mechanism | Production imports or fallback, per-surface actions, prepared screen state | +| Production entry point | Construct adapters and supply them to a feature view | Feature state decisions, domain logic | +| `tests/` | Shared context/client mocks and wire helpers | Production dependencies on test helpers | + +Create only the layers the feature needs. Use descriptive kebab-case modules and +the reference's plural layer names. Co-locate tests as `*.test.ts(x)`; no mandatory +root barrel or empty scaffolding. Host action adapters and rollout hooks can live +at the feature root. + +## Preserve dependency direction + +Read these arrows as “depends on”: + +```text +production entry point -> query/app adapters + context provider + views +views -> primitives -> feature contracts -> core +views -> components -> core +views -> context +query/app adapters -> feature contracts +components -> context display types only +production wiring / host adapters -> app capabilities +``` + +Contracts can live in `context/` or a small dedicated module; they must not import +their implementations. Concrete clients are adapter inputs supplied by production +wiring. Feature state depends on the capabilities those adapters implement. + +Consumers can use core directly. Inspect re-exports and aliases for cycles or +hidden dependencies. Keep actual network transport in service-clients and shared +server-state operations in `src/lib/queries`; feature-specific orchestration +belongs to the owning feature's `queries/`. Reuse the existing query library and +cache conventions rather than requiring a library migration. + +Decode transport shapes into feature-owned types before they reach domain logic +or reusable rendering. Preserve explicit unknown/unsupported cases and missing +versus empty results. Other input surfaces, such as AI tools, adapt their own +transport to the same feature model. + +## Separate production composition + +Keep dependency contracts and the Solid provider/consumer separate from production +adapter construction. Importing a feature context or state module must not initialize +app services, sockets, workers, or global listeners. A lazy function fallback does +not remove static imports of production dependencies. + +Use an app-facing entry point to construct real adapters and mount the feature +provider around its view. Tests import the reusable view and supply their own +dependencies. The context consumer reports a missing provider clearly; it must not +silently fall back to production services. Direct dependency arguments are also +appropriate when a subtree context would add no value. + +Production callers can still mount one convenient `` wrapper. One +context module and one production entry point can provide this boundary; no DI +framework, class hierarchy, or extra directory per adapter is required. Keep +hook-based construction under its Solid owner and gate mounting before starting +feature resources. Inspect shared UI dependencies separately: moving context +wiring alone does not remove their transitive app imports. + +## Introduce narrow feature-owned contracts + +Use a feature-owned contract where meaningful feature behavior needs to be tested +or changed independently of its infrastructure. Describe domain values and the +operations the consumer requires. For example, feed state needs an activity event +source with request status and pagination actions; it should not receive an urql +`Client` and construct concrete GraphQL queries itself. + +Query adapters implement that source using existing query/cache infrastructure. +Keep documents, variables, decoding, cursors, cache mechanics, and transport-error +translation in adapters. Keep grouping, visible-state selection, and the decision +to retain rows during background failures in primitives. A source reports data +availability; it must not return a precomputed screen view-state that moves those +decisions into infrastructure. + +Solid accessors and factories are appropriate in these contracts. Accept Solid +as the reactive foundation instead of wrapping it in a custom framework. Preserve +enabled inputs, updates, pagination, owner cleanup, and the chosen resource-read +semantics; a promise-only replacement can lose required behavior. Factories must +run under the consuming Solid owner, not create a global feature-state singleton. + +Pass a source directly to a primitive or use a narrow `Pick` of dependencies. +Do not expose generated GraphQL shapes, concrete clients, or library-specific +query result types in the consumer contract. Avoid generic `Repository` APIs +or interfaces for every helper. If a wrapper merely forwards values and protects +no independent behavior, do not create a new layer just to justify a contract. +See the document's “Feature-owned contracts” section for the feed example. + +## Choose context or props deliberately + +Put an ambient capability used the same way on every surface in one feature +context record. Activity's viewer ID, display-name resolver, entity display +resolver, and property-definition resolver illustrate useful capabilities. For +data access, supply factories implementing feature contracts rather than placing +the raw GraphQL client in the consumer's dependency record. + +Keep changing inputs reactive with accessors. Keep props for instance data and +resolved leaf values. +Do not add environment-derived values or one consumer's prepared state to context +without an actual dependency-replacement need. + +Host-specific behavior is a callback prop. A primitive can translate an event to +a target; the host adapter performs navigation or another host action. Omitting +an optional action omits its handlers. If the whole surface must be inert, inspect +nested widgets too. Keep rollout flags out of context and gate mounting before +query-owning components initialize. + +## Separate state, presentation, and composition + +Have screen primitives return explicit view-state unions and named actions, so +loading, errors, emptiness, pagination, and background data precedence are decided +once. Small helpers may return a simple accessor; do not manufacture a state +machine for every function. + +Have components render supplied values and callbacks. Solid control flow, local +presentation derivations, shared UI, and scoped presentation contexts are fine. +Move feature capability resolution and reusable reactive orchestration into +primitives/views. Views can keep small local layout helpers and UI state with +their only consumer. + +Use these reference pairs when a boundary is unclear: + +- Domain and wire: `activity/core/event.ts` and `activity/queries/decode.ts`. +- State and rendering: `activity/primitives/my-activity.ts` and + `activity/views/my-activity-view.tsx`. +- Presentation and wiring: `activity/components/activity-timeline-row.tsx` and + `activity/views/activity-timeline-row.tsx`. +- Existing capability signatures and test helpers: `activity/context/activity-context.tsx` + and `activity/tests/mock-context.ts`. Their production fallback and raw-client + injection still need the migration described above. + +These paths are under `apps/web/src/features/`. Follow existing Solid rules on +resource reads, effects, and memo use; the folder layout does not override them. + +## Migrate and verify + +For a restructure, extract core and wire adapters, separate production composition, +then introduce narrow source contracts where reactive decisions currently depend +on concrete adapters. Split reactive state from presentation and update all hosts +and imports. Composition isolation is useful even before a source-contract +migration. Preserve query enabling, cache/pagination behavior, entity switching, route fallbacks, and +required presentation providers unless the task explicitly changes them. + +Register the adopting feature's layer globs in both the `ts-` and `tsx-` versions +of all four `rules/ast-grep/*feature-*` families: `core-pure`, +`components-presentational`, `data-no-ui`, and `layers-use-context`. The detailed +document maps each family to its layers. They currently warn and cover activity; +a clean scan does not prove the whole dependency graph is valid. +These rules do not yet enforce separate composition or source-contract dependency +inversion; review those boundaries explicitly and report remaining gaps. + +Test the behavior that changed at its owning layer: pure core/decoder tests, +query-adapter tests with a fake client, `createRoot` with fake feature sources for +reactive decisions, and provider-backed view tests for visible state and callbacks. +Keep integration tests that exercise adapters and primitives together. Dispose +test roots. Primitive unit tests should control domain data/status without naming +GraphQL operations; adapter tests verify wire behavior. Exercise the real code of +the layer being tested and replace its dependencies through the contract. +Check that importing context/state does not require production-service quarantine, +and that omitted provider setup fails clearly instead of reaching the app. +For implementation changes, run relevant `bun run test ` and +`bun run check` from `apps/web`, and exercise user-visible changes in a browser +per its AGENTS.md. Run the feature-scoped ast-grep scan from the repository root. +Update `docs/AGENT_GUIDE/` when interactions or routes change. + +Before treating a reference pattern as a universal rule, read the document's +“Enforcement and reference limitations” section. Activity has narrow exceptions +for a pure soup date utility and property-value parsing; view tests retain shared +UI stubs and websocket import quarantine. Do not generalize those into permission +for dependency leaks or describe the reference as entirely free of `vi.mock`. + +Report the boundaries changed, relevant verification, and any remaining concrete +exceptions or limitations. For a review, distinguish a violated architecture rule +from an activity-specific implementation choice. diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 0c2b08d565c..ed2ed992be1 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -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. Use the [frontend-feature-architecture skill](../../.claude/skills/frontend-feature-architecture/SKILL.md) when adding or restructuring feature folders or changing their dependency and composition boundaries. ## Styling - Use semantic color tokens, not raw Tailwind color classes. diff --git a/docs/FRONTEND_FEATURE_ARCHITECTURE.md b/docs/FRONTEND_FEATURE_ARCHITECTURE.md new file mode 100644 index 00000000000..775f2744866 --- /dev/null +++ b/docs/FRONTEND_FEATURE_ARCHITECTURE.md @@ -0,0 +1,618 @@ +# Frontend feature architecture + +New frontend features use the layered structure established by +`apps/web/src/features/activity` in commit `f598574d7` (PR #6176, +“layered feature layout with injected deps”). 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) and the +[frontend-feature-architecture skill](../.claude/skills/frontend-feature-architecture/SKILL.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. +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. 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 one feature context record containing the ambient capabilities the feature +uses consistently across surfaces. 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. + +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 useActivityDependencies(): ActivityDependencies { + const dependencies = useContext(Context); + if (!dependencies) throw new Error('ActivityProvider is required'); + return dependencies; +} +``` + +```tsx +// activity.tsx — production entry point +export function Activity() { + const dependencies = createAppActivityDependencies(); + return ( + + + + ); +} +``` + +`createAppActivityDependencies` 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 ActivityDependencies = { + 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 dependencies = useActivityDependencies(); +const state = createMyActivityState(dependencies.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 + +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 | +| 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. 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? diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md index 4ed51b8de50..4e8b833d7ec 100644 --- a/docs/STYLE_GUIDE.md +++ b/docs/STYLE_GUIDE.md @@ -251,29 +251,36 @@ 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. + Screen primitives expose view-state unions; 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. Use the + [frontend-feature-architecture skill](../.claude/skills/frontend-feature-architecture/SKILL.md). + 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.) From f223ba2232779d7d1f727e419fd2472b3b0c93aa Mon Sep 17 00:00:00 2001 From: synoet Date: Sat, 5 Sep 2026 07:54:33 +0000 Subject: [PATCH 02/21] refactor(web): separate email thread, message, and compose features Keep block lifecycle, focus, and location integration in a thin email block adapter. Give reusable email features domain contracts and separate production adapters, with regression coverage for ownership, drafts, rendering, and send scheduling. Document the applied boundaries, skill lessons, and before/after Chrome verification. Preserve existing rendering snapshots; original and extracted assets match all 12 temporary comparison snapshots exactly. --- .../frontend-feature-architecture/SKILL.md | 35 +- .../components/app/mailtoComposerHandler.ts | 2 +- .../app/split-layout/componentRegistry.tsx | 2 +- .../block-email/EmailBlockAdapter.tsx | 156 ++ .../block-email/component/BaseInput.tsx | 2319 ----------------- .../features/block-email/component/Block.tsx | 16 +- .../block-email/component/EmailContext.tsx | 1028 -------- .../block-email/component/EmailTaskButton.tsx | 22 + .../component/MessageContainer.tsx | 368 --- .../block-email/component/ModalsProvider.tsx | 5 +- .../features/block-email/component/TopBar.tsx | 2 +- .../block-email/component/compose/index.ts | 4 - .../sidepanel/EmailSidePanelSections.tsx | 2 +- .../block-email/signal/scrollState.ts | 3 - .../features/block-email/util/emailHotkeys.ts | 26 +- apps/web/src/features/command/Launcher.tsx | 2 +- .../components}/date-selector.tsx | 0 .../components}/email-date-selector.tsx | 6 +- .../components/from-inbox-selector.tsx} | 6 +- .../components/macro-signature-button.tsx} | 14 +- .../components/recipient-drop-row.tsx | 53 + .../components/signature-preview.tsx} | 8 +- .../email-compose/compose-host-adapter.ts | 29 + .../features/email-compose/compose-layout.ts | 4 + .../email-compose/compose-service-adapter.ts | 183 ++ .../email-compose/context/compose-services.ts | 153 ++ .../context/editor-capabilities.ts | 15 + .../context/email-form-dependencies.ts | 41 + .../core}/constants.ts | 4 - .../core/decode-base64.ts} | 0 .../email-compose/core/email-draft.ts | 31 + .../email-compose/core/email-recipient.ts | 33 + .../core}/mailto.test.ts | 0 .../util => email-compose/core}/mailto.ts | 0 .../core/plain-text-to-html.test.ts} | 2 +- .../core/plain-text-to-html.ts} | 0 .../core/recipient-conversion.ts} | 27 +- .../core/reply-type.ts} | 6 +- .../core/subject-text.test.ts} | 8 +- .../core/subject-text.ts} | 6 +- .../features/email-compose/editor-adapter.ts | 57 + .../features/email-compose/email-compose.tsx | 11 + .../make-attachment-public.ts} | 24 +- .../appended-reply-round-trip.test.ts} | 6 +- .../primitives/compose-operation.test.ts | 64 + .../primitives/compose-operation.ts | 36 + .../primitives/compose-view-state.ts} | 34 +- .../primitives/email-composer.test.ts | 181 ++ .../primitives/email-composer.ts} | 520 ++-- .../primitives/email-editor-commands.ts | 11 + .../primitives/email-form-state.test.ts | 59 + .../primitives/email-form-state.ts} | 33 +- .../primitives/email-form-types.ts | 6 + .../flatten-consecutive-paragraphs.test.ts} | 2 +- .../flatten-consecutive-paragraphs.ts} | 0 .../primitives/mention-to-cc.test.ts | 32 + .../primitives/mention-to-cc.ts} | 12 +- .../primitives/prepare-email-body.test.ts} | 6 +- .../primitives/prepare-email-body.ts} | 31 +- .../email-compose/primitives/reply-input.ts | 1414 ++++++++++ .../primitives/send-schedule.test.ts | 158 ++ .../primitives/undo-send-claim.ts | 13 + .../primitives/undo-store.test.ts | 36 + .../email-compose/primitives/undo-store.ts | 40 + .../queries/inbox-source.test.ts | 72 + .../email-compose/queries/inbox-source.ts | 44 + .../features/email-compose/tests/services.ts | 62 + .../undo-send.ts} | 31 +- .../views/compose-body.tsx} | 82 +- .../email-compose/views/compose-context.ts | 21 + .../views/compose-layout.tsx} | 18 +- .../views/compose-recipients.tsx} | 29 +- .../views/compose-subject.tsx} | 13 +- .../views/compose-toolbar.tsx} | 24 +- .../email-compose/views/email-compose.tsx | 143 + .../views/email-form-context.tsx} | 38 +- .../email-compose/views/reply-input.tsx | 960 +++++++ .../attachment-action-adapter.ts | 55 + .../components/attachment-pill.tsx} | 4 +- .../components/collapsed-message.tsx} | 14 +- .../components/email-message-top-bar.tsx} | 36 +- .../components/email-user-tooltip.tsx} | 2 +- .../components/message-actions.tsx} | 42 +- .../components/message-card.tsx} | 35 +- .../context/email-rendering-context.tsx | 34 + .../core/email-body-containment-css.ts} | 0 .../email-message/core/email-message.ts | 87 + .../core/email-user.ts} | 12 +- .../core/fit-to-width-zoom.test.ts} | 2 +- .../core/fit-to-width-zoom.ts} | 0 .../core/format-email-date.ts} | 2 +- .../core/is-personal-message.ts} | 4 +- .../util => email-message/core}/name.ts | 0 .../features/email-message/email-message.tsx | 16 + .../image-adapter.ts} | 4 +- .../primitives/email-message-body.test.ts | 89 + .../primitives/email-message-body.ts} | 156 +- .../email-message/rendering-adapter.ts | 31 + .../features/email-message/tests/messages.ts | 30 + .../views/email-message-body.tsx | 80 + .../email-message/views/email-message.tsx | 242 ++ .../components/copy-subject-button.tsx} | 14 +- .../components/email-thread-title.tsx} | 4 +- .../context/email-thread-dependencies.ts | 66 + .../email-thread/core/email-thread.ts | 18 + .../features/email-thread/core/location.ts | 1 + .../email-thread/core/thread-keyboard.ts | 24 + .../email-thread/core/thread-messages.test.ts | 26 + .../email-thread/core/thread-messages.ts | 48 + .../core/thread-stops.test.ts} | 2 +- .../core/thread-stops.ts} | 2 +- .../email-thread/core/thread-window.ts | 24 + .../features/email-thread/email-thread.tsx | 88 + .../primitives/email-thread-state.test.ts | 79 + .../primitives/email-thread-state.ts | 404 +++ .../primitives/reply-actions.ts} | 15 +- .../primitives/scroll-to-message.test.ts} | 2 +- .../primitives/scroll-to-message.ts} | 32 +- .../primitives/thread-navigation.ts} | 535 ++-- .../primitives/thread-snapshot.test.ts | 40 + .../primitives/thread-snapshot.ts | 22 + .../queries/thread-source.test.ts | 96 + .../email-thread/queries/thread-source.ts | 54 + .../email-thread/tests/architecture.test.ts | 184 ++ .../features/email-thread/tests/fixtures.ts | 56 + .../email-thread/thread-action-adapter.tsx | 461 ++++ .../views/bottom-reply-buttons.tsx} | 22 +- .../email-thread/views/email-input.test.tsx | 70 + .../views/email-input.tsx} | 42 +- .../views/email-participants.tsx} | 10 +- .../views/email-thread-context.tsx | 11 + .../views/email-thread-surface.test.tsx | 73 + .../views/email-thread-surface.tsx | 33 + .../src/features/email-thread/views/email.tsx | 133 + .../email-thread/views/message-container.tsx | 169 ++ .../views/message-list.tsx} | 34 +- .../views/mobile-email-compose-drawer.tsx} | 4 +- .../email-thread/views/thread-environment.tsx | 24 + apps/web/src/features/next-soup/utils.ts | 2 +- .../AI/component/tool/email/ChatCompose.tsx | 32 +- .../core/component/LexicalMarkdown/utils.ts | 58 +- .../utils/setEditorStateFromHtml.ts | 66 + .../core/email/tests/email-rendering.pw.ts | 4 +- apps/web/src/lib/queries/email/draft-cache.ts | 17 + apps/web/src/lib/queries/email/integration.ts | 37 + apps/web/vitest.config.ts | 14 +- docs/AGENT_GUIDE/surfaces.md | 12 + docs/EMAIL_FEATURE_ARCHITECTURE.md | 212 ++ docs/FRONTEND_FEATURE_ARCHITECTURE.md | 15 + .../ts-email-no-block-dependencies.yml | 25 + .../ts-feature-components-presentational.yml | 3 + rules/ast-grep/ts-feature-core-pure.yml | 6 + rules/ast-grep/ts-feature-data-no-ui.yml | 6 + .../ts-feature-layers-use-context.yml | 22 +- .../tsx-email-no-block-dependencies.yml | 25 + .../tsx-feature-components-presentational.yml | 3 + rules/ast-grep/tsx-feature-core-pure.yml | 6 + rules/ast-grep/tsx-feature-data-no-ui.yml | 6 + .../tsx-feature-layers-use-context.yml | 22 +- 159 files changed, 8307 insertions(+), 5129 deletions(-) create mode 100644 apps/web/src/features/block-email/EmailBlockAdapter.tsx delete mode 100644 apps/web/src/features/block-email/component/BaseInput.tsx delete mode 100644 apps/web/src/features/block-email/component/EmailContext.tsx create mode 100644 apps/web/src/features/block-email/component/EmailTaskButton.tsx delete mode 100644 apps/web/src/features/block-email/component/MessageContainer.tsx delete mode 100644 apps/web/src/features/block-email/component/compose/index.ts delete mode 100644 apps/web/src/features/block-email/signal/scrollState.ts rename apps/web/src/features/{block-email/component => email-compose/components}/date-selector.tsx (100%) rename apps/web/src/features/{block-email/component => email-compose/components}/email-date-selector.tsx (94%) rename apps/web/src/features/{block-email/component/FromInboxSelector.tsx => email-compose/components/from-inbox-selector.tsx} (96%) rename apps/web/src/features/{block-email/component/MacroSignatureButton.tsx => email-compose/components/macro-signature-button.tsx} (63%) create mode 100644 apps/web/src/features/email-compose/components/recipient-drop-row.tsx rename apps/web/src/features/{block-email/component/compose/SignaturePreview.tsx => email-compose/components/signature-preview.tsx} (95%) create mode 100644 apps/web/src/features/email-compose/compose-host-adapter.ts create mode 100644 apps/web/src/features/email-compose/compose-layout.ts create mode 100644 apps/web/src/features/email-compose/compose-service-adapter.ts create mode 100644 apps/web/src/features/email-compose/context/compose-services.ts create mode 100644 apps/web/src/features/email-compose/context/editor-capabilities.ts create mode 100644 apps/web/src/features/email-compose/context/email-form-dependencies.ts rename apps/web/src/features/{block-email => email-compose/core}/constants.ts (83%) rename apps/web/src/features/{block-email/util/decodeBase64.ts => email-compose/core/decode-base64.ts} (100%) create mode 100644 apps/web/src/features/email-compose/core/email-draft.ts create mode 100644 apps/web/src/features/email-compose/core/email-recipient.ts rename apps/web/src/features/{block-email/util => email-compose/core}/mailto.test.ts (100%) rename apps/web/src/features/{block-email/util => email-compose/core}/mailto.ts (100%) rename apps/web/src/features/{block-email/util/plainTextToHtml.test.ts => email-compose/core/plain-text-to-html.test.ts} (97%) rename apps/web/src/features/{block-email/util/plainTextToHtml.ts => email-compose/core/plain-text-to-html.ts} (100%) rename apps/web/src/features/{block-email/util/recipientConversion.ts => email-compose/core/recipient-conversion.ts} (84%) rename apps/web/src/features/{block-email/util/replyType.ts => email-compose/core/reply-type.ts} (64%) rename apps/web/src/features/{block-email/util/subjectText.test.ts => email-compose/core/subject-text.test.ts} (88%) rename apps/web/src/features/{block-email/util/subjectText.ts => email-compose/core/subject-text.ts} (86%) create mode 100644 apps/web/src/features/email-compose/editor-adapter.ts create mode 100644 apps/web/src/features/email-compose/email-compose.tsx rename apps/web/src/features/{block-email/util/makeAttachmentPublic.ts => email-compose/make-attachment-public.ts} (62%) rename apps/web/src/features/{block-email/util/appendedReplyRoundTrip.test.ts => email-compose/primitives/appended-reply-round-trip.test.ts} (96%) create mode 100644 apps/web/src/features/email-compose/primitives/compose-operation.test.ts create mode 100644 apps/web/src/features/email-compose/primitives/compose-operation.ts rename apps/web/src/features/{block-email/component/compose/ComposeContext.ts => email-compose/primitives/compose-view-state.ts} (78%) create mode 100644 apps/web/src/features/email-compose/primitives/email-composer.test.ts rename apps/web/src/features/{block-email/component/compose/Compose.tsx => email-compose/primitives/email-composer.ts} (60%) create mode 100644 apps/web/src/features/email-compose/primitives/email-editor-commands.ts create mode 100644 apps/web/src/features/email-compose/primitives/email-form-state.test.ts rename apps/web/src/features/{block-email/component/createEmailFormState.ts => email-compose/primitives/email-form-state.ts} (93%) create mode 100644 apps/web/src/features/email-compose/primitives/email-form-types.ts rename apps/web/src/features/{block-email/util/flattenConsecutiveParagraphs.test.ts => email-compose/primitives/flatten-consecutive-paragraphs.test.ts} (95%) rename apps/web/src/features/{block-email/util/flattenConsecutiveParagraphs.ts => email-compose/primitives/flatten-consecutive-paragraphs.ts} (100%) create mode 100644 apps/web/src/features/email-compose/primitives/mention-to-cc.test.ts rename apps/web/src/features/{block-email/util/mentionToCc.ts => email-compose/primitives/mention-to-cc.ts} (67%) rename apps/web/src/features/{block-email/util/prepareEmailBody.test.ts => email-compose/primitives/prepare-email-body.test.ts} (91%) rename apps/web/src/features/{block-email/util/prepareEmailBody.ts => email-compose/primitives/prepare-email-body.ts} (95%) create mode 100644 apps/web/src/features/email-compose/primitives/reply-input.ts create mode 100644 apps/web/src/features/email-compose/primitives/send-schedule.test.ts create mode 100644 apps/web/src/features/email-compose/primitives/undo-send-claim.ts create mode 100644 apps/web/src/features/email-compose/primitives/undo-store.test.ts create mode 100644 apps/web/src/features/email-compose/primitives/undo-store.ts create mode 100644 apps/web/src/features/email-compose/queries/inbox-source.test.ts create mode 100644 apps/web/src/features/email-compose/queries/inbox-source.ts create mode 100644 apps/web/src/features/email-compose/tests/services.ts rename apps/web/src/features/{block-email/util/undoSend.ts => email-compose/undo-send.ts} (84%) rename apps/web/src/features/{block-email/component/compose/ComposeBody.tsx => email-compose/views/compose-body.tsx} (72%) create mode 100644 apps/web/src/features/email-compose/views/compose-context.ts rename apps/web/src/features/{block-email/component/compose/ComposeLayout.tsx => email-compose/views/compose-layout.tsx} (94%) rename apps/web/src/features/{block-email/component/compose/ComposeRecipients.tsx => email-compose/views/compose-recipients.tsx} (92%) rename apps/web/src/features/{block-email/component/compose/ComposeSubject.tsx => email-compose/views/compose-subject.tsx} (90%) rename apps/web/src/features/{block-email/component/compose/ComposeToolbar.tsx => email-compose/views/compose-toolbar.tsx} (91%) create mode 100644 apps/web/src/features/email-compose/views/email-compose.tsx rename apps/web/src/features/{block-email/component/EmailFormContext.tsx => email-compose/views/email-form-context.tsx} (64%) create mode 100644 apps/web/src/features/email-compose/views/reply-input.tsx create mode 100644 apps/web/src/features/email-message/attachment-action-adapter.ts rename apps/web/src/features/{block-email/component/AttachmentPill.tsx => email-message/components/attachment-pill.tsx} (95%) rename apps/web/src/features/{block-email/component/CollapsedMessage.tsx => email-message/components/collapsed-message.tsx} (87%) rename apps/web/src/features/{block-email/component/EmailMessageTopBar.tsx => email-message/components/email-message-top-bar.tsx} (89%) rename apps/web/src/features/{block-email/component/EmailUserTooltip.tsx => email-message/components/email-user-tooltip.tsx} (95%) rename apps/web/src/features/{block-email/component/MessageActions.tsx => email-message/components/message-actions.tsx} (58%) rename apps/web/src/features/{block-email/component/MessageCard.tsx => email-message/components/message-card.tsx} (76%) create mode 100644 apps/web/src/features/email-message/context/email-rendering-context.tsx rename apps/web/src/features/{block-email/util/emailBodyContainmentCss.ts => email-message/core/email-body-containment-css.ts} (100%) create mode 100644 apps/web/src/features/email-message/core/email-message.ts rename apps/web/src/features/{block-email/util/emailUser.ts => email-message/core/email-user.ts} (81%) rename apps/web/src/features/{block-email/util/fitToWidthZoom.test.ts => email-message/core/fit-to-width-zoom.test.ts} (93%) rename apps/web/src/features/{block-email/util/fitToWidthZoom.ts => email-message/core/fit-to-width-zoom.ts} (100%) rename apps/web/src/features/{block-email/util/formatEmailDate.ts => email-message/core/format-email-date.ts} (93%) rename apps/web/src/features/{block-email/util/isPersonalMessage.ts => email-message/core/is-personal-message.ts} (84%) rename apps/web/src/features/{block-email/util => email-message/core}/name.ts (100%) create mode 100644 apps/web/src/features/email-message/email-message.tsx rename apps/web/src/features/{block-email/util/resolveEmailImages.ts => email-message/image-adapter.ts} (96%) create mode 100644 apps/web/src/features/email-message/primitives/email-message-body.test.ts rename apps/web/src/features/{block-email/component/EmailMessageBody.tsx => email-message/primitives/email-message-body.ts} (67%) create mode 100644 apps/web/src/features/email-message/rendering-adapter.ts create mode 100644 apps/web/src/features/email-message/tests/messages.ts create mode 100644 apps/web/src/features/email-message/views/email-message-body.tsx create mode 100644 apps/web/src/features/email-message/views/email-message.tsx rename apps/web/src/features/{block-email/component/CopySubjectButton.tsx => email-thread/components/copy-subject-button.tsx} (78%) rename apps/web/src/features/{block-email/component/EmailThreadTitle.tsx => email-thread/components/email-thread-title.tsx} (91%) create mode 100644 apps/web/src/features/email-thread/context/email-thread-dependencies.ts create mode 100644 apps/web/src/features/email-thread/core/email-thread.ts create mode 100644 apps/web/src/features/email-thread/core/location.ts create mode 100644 apps/web/src/features/email-thread/core/thread-keyboard.ts create mode 100644 apps/web/src/features/email-thread/core/thread-messages.test.ts create mode 100644 apps/web/src/features/email-thread/core/thread-messages.ts rename apps/web/src/features/{block-email/util/threadStops.test.ts => email-thread/core/thread-stops.test.ts} (99%) rename apps/web/src/features/{block-email/util/threadStops.ts => email-thread/core/thread-stops.ts} (98%) create mode 100644 apps/web/src/features/email-thread/core/thread-window.ts create mode 100644 apps/web/src/features/email-thread/email-thread.tsx create mode 100644 apps/web/src/features/email-thread/primitives/email-thread-state.test.ts create mode 100644 apps/web/src/features/email-thread/primitives/email-thread-state.ts rename apps/web/src/features/{block-email/component/emailReplyActions.ts => email-thread/primitives/reply-actions.ts} (76%) rename apps/web/src/features/{block-email/util/scrollToMessage.test.ts => email-thread/primitives/scroll-to-message.test.ts} (99%) rename apps/web/src/features/{block-email/util/scrollToMessage.ts => email-thread/primitives/scroll-to-message.ts} (93%) rename apps/web/src/features/{block-email/component/Email.tsx => email-thread/primitives/thread-navigation.ts} (58%) create mode 100644 apps/web/src/features/email-thread/primitives/thread-snapshot.test.ts create mode 100644 apps/web/src/features/email-thread/primitives/thread-snapshot.ts create mode 100644 apps/web/src/features/email-thread/queries/thread-source.test.ts create mode 100644 apps/web/src/features/email-thread/queries/thread-source.ts create mode 100644 apps/web/src/features/email-thread/tests/architecture.test.ts create mode 100644 apps/web/src/features/email-thread/tests/fixtures.ts create mode 100644 apps/web/src/features/email-thread/thread-action-adapter.tsx rename apps/web/src/features/{block-email/component/BottomReplyButtons.tsx => email-thread/views/bottom-reply-buttons.tsx} (84%) create mode 100644 apps/web/src/features/email-thread/views/email-input.test.tsx rename apps/web/src/features/{block-email/component/EmailInput.tsx => email-thread/views/email-input.tsx} (67%) rename apps/web/src/features/{block-email/component/EmailParticipants.tsx => email-thread/views/email-participants.tsx} (90%) create mode 100644 apps/web/src/features/email-thread/views/email-thread-context.tsx create mode 100644 apps/web/src/features/email-thread/views/email-thread-surface.test.tsx create mode 100644 apps/web/src/features/email-thread/views/email-thread-surface.tsx create mode 100644 apps/web/src/features/email-thread/views/email.tsx create mode 100644 apps/web/src/features/email-thread/views/message-container.tsx rename apps/web/src/features/{block-email/component/MessageList.tsx => email-thread/views/message-list.tsx} (89%) rename apps/web/src/features/{block-email/component/MobileEmailComposeDrawer.tsx => email-thread/views/mobile-email-compose-drawer.tsx} (96%) create mode 100644 apps/web/src/features/email-thread/views/thread-environment.tsx create mode 100644 apps/web/src/lib/core/component/LexicalMarkdown/utils/setEditorStateFromHtml.ts create mode 100644 apps/web/src/lib/queries/email/draft-cache.ts create mode 100644 apps/web/src/lib/queries/email/integration.ts create mode 100644 docs/EMAIL_FEATURE_ARCHITECTURE.md create mode 100644 rules/ast-grep/ts-email-no-block-dependencies.yml create mode 100644 rules/ast-grep/tsx-email-no-block-dependencies.yml diff --git a/.claude/skills/frontend-feature-architecture/SKILL.md b/.claude/skills/frontend-feature-architecture/SKILL.md index fae5e8a2d3d..34acde6605d 100644 --- a/.claude/skills/frontend-feature-architecture/SKILL.md +++ b/.claude/skills/frontend-feature-architecture/SKILL.md @@ -182,7 +182,7 @@ required presentation providers unless the task explicitly changes them. Register the adopting feature's layer globs in both the `ts-` and `tsx-` versions of all four `rules/ast-grep/*feature-*` families: `core-pure`, `components-presentational`, `data-no-ui`, and `layers-use-context`. The detailed -document maps each family to its layers. They currently warn and cover activity; +document maps each family to its layers. They currently warn and cover adopting features; a clean scan does not prove the whole dependency graph is valid. These rules do not yet enforce separate composition or source-contract dependency inversion; review those boundaries explicitly and report remaining gaps. @@ -210,3 +210,36 @@ for dependency leaks or describe the reference as entirely free of `vi.mock`. Report the boundaries changed, relevant verification, and any remaining concrete exceptions or limitations. For a review, distinguish a violated architecture rule from an activity-specific implementation choice. +## Lessons verified by the email migration + +Read [docs/EMAIL_FEATURE_ARCHITECTURE.md](../../../docs/EMAIL_FEATURE_ARCHITECTURE.md) +for a complete application of the stronger boundaries. Prefer its separate +production entries and domain contracts over activity's documented migration gaps. + +- Keep scoped screen/form providers in `views/` and their view-state types in + `primitives/`; reserve `context/` for replaceable capabilities and their provider + mechanism. Do not turn a capability record into a prepared screen-state object. +- Pass lazy content factories to host frames that establish providers. Verify + where the factory executes; a JSX element created before the provider is too + early even if the final DOM is nested correctly. +- Resolve owner-dependent accessors during component setup and capture them in + host callbacks. Block signals resolve `.get` through their active provider; + looking up `.get` inside a later DOM event can fail despite passing TypeScript. + Exercise host callbacks in the mounted application as well as isolated state. +- Preserve completion as well as status when adapting queries. An operation that + used to be awaited must not become fire-and-forget at the contract boundary. +- Test cached error data on a fresh mount, not only success followed by failure. + Keep request identity available before data arrives and keep snapshot retention + policy in primitives. Check both pending-resource safety and cached-data access. +- Scope editor lifetimes and undo recovery to stable instance identities. Server + echoes should not reset an engaged editor; switching the actual reply target + must reset the form lifetime. Test two owners and cleanup after replacement. +- Inspect transitive imports of shared utility barrels. A single apparently pure + editor helper can initialize rendering plugins or query persistence. Extract a + narrow shared helper when needed, while preserving its existing public export. +- When an adapter begins translating result errors into rejected promises, update + its consumers' error handling. Preserve pending guards so mutually exclusive + operations, such as scheduling and immediate sending, cannot overlap. +- Register the feature rules and add focused import-graph checks when a migration + has hard ownership restrictions. A folder rename and a passing TypeScript check + alone do not establish isolation. 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 a7f3b6e7b51..d65a9895aea 100644 --- a/apps/web/src/components/app/split-layout/componentRegistry.tsx +++ b/apps/web/src/components/app/split-layout/componentRegistry.tsx @@ -3,6 +3,7 @@ import { useActivityFeedFlag } from '@app/features/activity/use-activity-feed-fl 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 { GettingStarted } from '@app/features/getting-started'; import { Home } from '@app/features/home'; import { InboxView } from '@app/features/inbox-view/inbox-view'; @@ -22,7 +23,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 { 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..9cc484c93b4 --- /dev/null +++ b/apps/web/src/features/block-email/EmailBlockAdapter.tsx @@ -0,0 +1,156 @@ +import { AskMacroButton } from '@app/features/chat/ChatWithAgentButton'; +import { URL_PARAMS } from '@app/features/email-thread/core/location'; +import { EmailThread } from '@app/features/email-thread/email-thread'; +import { useEmailContext } from '@app/features/email-thread/views/email-thread-context'; +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 = useEmailContext(); + 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 4de5bb18614..00000000000 --- a/apps/web/src/features/block-email/component/BaseInput.tsx +++ /dev/null @@ -1,2319 +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; - }, - runWithInputFocused: true, - 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/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/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..df0521b0000 100644 --- a/apps/web/src/features/block-email/component/ModalsProvider.tsx +++ b/apps/web/src/features/block-email/component/ModalsProvider.tsx @@ -1,10 +1,11 @@ +import { useEmailContext } from '@app/features/email-thread/views/email-thread-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(); @@ -21,7 +22,7 @@ export function ModalsProvider(props: ParentProps<{ subject?: string }>) { diff --git a/apps/web/src/features/block-email/component/TopBar.tsx b/apps/web/src/features/block-email/component/TopBar.tsx index 1dabab2e9cd..5432e4a8ebe 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 { useEmailContext } from '@app/features/email-thread/views/email-thread-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; 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/sidepanel/EmailSidePanelSections.tsx b/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx index 193a02ece30..023fa1b5a9c 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 { useEmailContext } from '@app/features/email-thread/views/email-thread-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; 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/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/command/Launcher.tsx b/apps/web/src/features/command/Launcher.tsx index 27b2a054d26..0dca3903bda 100644 --- a/apps/web/src/features/command/Launcher.tsx +++ b/apps/web/src/features/command/Launcher.tsx @@ -1,8 +1,8 @@ import { startPendingSession } from '@app/features/block-agent/context/pending-session'; +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 ( 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). */} - + { + 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/compose-layout.ts b/apps/web/src/features/email-compose/compose-layout.ts new file mode 100644 index 00000000000..f0dd2a42109 --- /dev/null +++ b/apps/web/src/features/email-compose/compose-layout.ts @@ -0,0 +1,4 @@ +export * from './context/editor-capabilities'; +export { ComposeLayout } from './views/compose-layout'; + +export { EmailComposeToolbar } from './views/compose-toolbar'; diff --git a/apps/web/src/features/email-compose/compose-service-adapter.ts b/apps/web/src/features/email-compose/compose-service-adapter.ts new file mode 100644 index 00000000000..65611c90dd4 --- /dev/null +++ b/apps/web/src/features/email-compose/compose-service-adapter.ts @@ -0,0 +1,183 @@ +import { useFeatureFlag } from '@app/lib/analytics/posthog'; +import { useHasPaidAccess } from '@core/auth'; +import { + createFilesReadyHandler, + getDragDropPosition, +} from '@core/component/LexicalMarkdown/utils/fileUploadUtils'; +import { toast } from '@core/component/Toast/Toast'; +import { + ENABLE_EMAIL_SCHEDULED_SEND, + enableEmailSignatures, + enableGraphqlSoup, + isFeatureEnabled, +} from '@core/constant/featureFlags'; +import { PaywallKey, usePaywallState } from '@core/constant/PaywallState'; +import { useEmail, useUserContext } from '@core/context/user'; +import { isMobile } from '@core/mobile/isMobile'; +import { isTouchDevice } from '@core/mobile/isTouchDevice'; +import { trackMention } from '@core/signal/mention'; +import { useCombinedRecipients } from '@core/signal/useCombinedRecipient'; +import { getDisplayName, tryMacroId } from '@core/user'; +import { interceptMailtoLinks } from '@core/util/interceptMailtoLinks'; +import { handleFileFolderDrop } from '@core/util/upload'; +import { Telemetry } from '@macro-inc/observability'; +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 { markThreadDraftSaved } from '@queries/email/draft-cache'; +import { + archiveEmailThread, + scheduleEmailMessage, +} from '@queries/email/integration'; +import { emailKeys } from '@queries/email/keys'; +import { + useEmailLinksQuery, + useNonPrimaryEmailLinkIdHeader, + usePrimaryEmailLinkId, +} from '@queries/email/link'; +import { + fetchAndCacheThread, + useSendMessageMutation, + useUnscheduleMessageMutation, +} from '@queries/email/thread'; +import { invalidateSoupEntity, refetchSoupEntity } from '@queries/soup/cache'; +import type { ApiThread } from '@service-email/generated/schemas'; +import type { InfiniteData } from '@tanstack/solid-query'; +import type { + ComposeNoticeOptions, + EmailComposeServices, +} from './context/compose-services'; +import { readDroppedEmailFiles } from './editor-adapter'; +import { makeAttachmentPublic } from './make-attachment-public'; +import { createEmailInboxSource } from './queries/inbox-source'; +import { restoreDraftBodyAfterUndo, runUndoSend } from './undo-send'; + +/** Construct under the composing surface's Solid owner to scope request progress. */ +export function createEmailComposeServices(): EmailComposeServices { + const accounts = useEmailLinksQuery(); + const user = useUserContext(); + const paywall = usePaywallState(); + const viewerEmail = useEmail(); + const inboxSource = createEmailInboxSource(viewerEmail, accounts, (email) => + 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, + })), + }); + return { + viewerLoading: user.isLoading, + prepareSignatureLinks: interceptMailtoLinks, + onUpgrade: () => paywall.showPaywall(PaywallKey.REMOVE_SIGNATURE), + readDroppedFiles: readDroppedEmailFiles, + 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: (error) => + Telemetry.error( + error instanceof Error ? error : new Error(String(error)) + ), + isTouch: isTouchDevice, + isMobile, + scheduleEnabled: ENABLE_EMAIL_SCHEDULED_SEND, + recipientName: (id) => getDisplayName(tryMacroId(id)), + recordMention: (sourceId, targetId) => + trackMention(sourceId, 'document', targetId), + 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 } + ) + ); + }, + accounts: { + ...inboxSource, + primaryId: usePrimaryEmailLinkId(), + headerId: useNonPrimaryEmailLinkIdHeader(), + }, + viewerEmail, + recipients: users, + signaturesEnabled: () => signatures().enabled, + hasPaidAccess: useHasPaidAccess(), + saveDraft: (input) => save.mutateAsync(input), + deleteDraft: (input) => remove.mutateAsync(input), + sendMessage: (input) => send.mutateAsync(input), + uploadAttachments: (input) => upload.mutateAsync(input), + addForwardedAttachments: (input) => forward.mutateAsync(input), + removeAttachment: (input) => removeAttachment.mutateAsync(input), + removeForwardedAttachment: (input) => removeForwarded.mutateAsync(input), + unschedule: (input) => unschedule.mutateAsync(input), + schedule: async (input, linkId) => { + await scheduleEmailMessage(input, linkId); + }, + archive: async (input, linkId) => { + await archiveEmailThread(input, linkId); + }, + markDraftSaved: markThreadDraftSaved, + prepareUndo(threadId, draftId) { + if (isFeatureEnabled(enableGraphqlSoup)) return; + 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); + }, + refreshAfterUndo(threadId) { + if (isFeatureEnabled(enableGraphqlSoup)) + void fetchAndCacheThread(threadId); + }, + refreshThreadPreview(threadId) { + void refetchSoupEntity(threadId, 'emailThread'); + }, + invalidatePreview: invalidateSoupEntity, + undoSend: runUndoSend, + restoreDraft: restoreDraftBodyAfterUndo, + }; +} diff --git a/apps/web/src/features/email-compose/context/compose-services.ts b/apps/web/src/features/email-compose/context/compose-services.ts new file mode 100644 index 00000000000..9eb9505ef2b --- /dev/null +++ b/apps/web/src/features/email-compose/context/compose-services.ts @@ -0,0 +1,153 @@ +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; + }; +} + +export interface SavedEmailDraft { + db_id?: string | null; + thread_db_id?: string | null; + provider_id?: string | null; + link_id: string; +} + +export interface SaveEmailDraft { + draft: EmailDraft; + sendTime?: Date | null; + linkId?: string; + skipSoupRefetch?: boolean; +} +export interface DeleteEmailDraft { + draftId: string; + threadId?: string; + linkId?: string; + skipSoupRefetch?: boolean; +} +export interface SendEmailDraft { + message: EmailDraft; + linkId?: string; + skipSoupRefetch?: boolean; +} +export interface UploadEmailAttachments { + draftID: string; + attachments: File[]; + linkId?: string; + onAttachmentAdded?: (file: File, id: string) => void; + onAttachmentUploadFailed?: (file: File) => void; +} +export interface EmailAttachmentChange { + draftID: string; + attachmentID: string; + linkId?: string; +} + +/** A compose session needs these operations, never a client, query key, or mutation result. */ +export interface EmailComposeServices { + viewerLoading: Accessor; + onUpgrade(): void; + prepareSignatureLinks(root: ShadowRoot): void; + readDroppedFiles: import('./editor-capabilities').ComposeBodyActions['readDroppedFiles']; + 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; + isTouch: Accessor; + isMobile: Accessor; + scheduleEnabled: boolean; + recipientName(id: string): string; + recordMention(sourceId: string, targetId: string): void; + makePublic(id: string): void; + uploadEditorFiles(input: { + editor: LexicalEditor | undefined; + sourceId?: string; + files: FileSystemFileEntry[]; + directories: FileSystemDirectoryEntry[]; + dropEvent?: DragEvent; + onUploaded(ids: string[]): void; + }): void; + + accounts: { + inboxes: Accessor; + loading: Accessor; + failed: Accessor; + primaryId: Accessor; + headerId(id: string | undefined): string | undefined; + }; + viewerEmail: Accessor; + recipients: Accessor; + signaturesEnabled: Accessor; + hasPaidAccess: Accessor; + saveDraft(input: SaveEmailDraft): Promise<{ draft: SavedEmailDraft }>; + deleteDraft(input: DeleteEmailDraft): Promise; + sendMessage(input: SendEmailDraft): Promise<{ message: SavedEmailDraft }>; + uploadAttachments(input: UploadEmailAttachments): Promise; + addForwardedAttachments(input: { + draftID: string; + attachments: { attachmentID: string }[]; + linkId?: string; + }): Promise; + removeAttachment(input: EmailAttachmentChange): Promise; + removeForwardedAttachment(input: EmailAttachmentChange): Promise; + unschedule(input: { draftID: string; linkId?: string }): Promise; + schedule( + input: { draftID: string; send_time: string }, + linkId?: string + ): Promise; + archive( + input: { id: string; value: boolean }, + linkId?: string + ): Promise; + markDraftSaved(threadId: string): void; + prepareUndo(threadId: string, draftId: string): void; + refreshAfterUndo(threadId: string): void; + refreshThreadPreview(threadId: string): void; + invalidatePreview(id: string): void; + undoSend(input: { + draftId: string; + linkId: string | undefined; + onUndone: () => Promise | void; + }): Promise; + restoreDraft( + draft: Omit, + html: string, + linkId?: string + ): Promise; +} + +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/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/email-compose/context/email-form-dependencies.ts b/apps/web/src/features/email-compose/context/email-form-dependencies.ts new file mode 100644 index 00000000000..59f7cdce141 --- /dev/null +++ b/apps/web/src/features/email-compose/context/email-form-dependencies.ts @@ -0,0 +1,41 @@ +import type { Accessor } from 'solid-js'; +import type { EmailMessage } from '../../email-message/core/email-message'; +import type { EmailRecipient } from '../core/email-recipient'; +import type { ReplyType } from '../core/reply-type'; + +export interface EmailFormDependencies { + 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; + onRecipientsChange(items: EmailRecipient[]): void; + drafts: { + getDraftForMessage(id: string): EmailMessage | undefined; + deleteDraftForMessage(id: string): void; + }; + messages: { + list: Accessor; + unfiltered: Accessor; + personalSenders: Accessor>; + focusedID: Accessor; + setFocused(id: string | undefined): void; + }; + replyRequest: { + messageId: Accessor; + 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..1b0498551b2 --- /dev/null +++ b/apps/web/src/features/email-compose/core/email-draft.ts @@ -0,0 +1,31 @@ +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; + headers_json?: null | Record; + + 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..6094276fe3c --- /dev/null +++ b/apps/web/src/features/email-compose/core/email-recipient.ts @@ -0,0 +1,33 @@ +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 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/block-email/util/plainTextToHtml.test.ts b/apps/web/src/features/email-compose/core/plain-text-to-html.test.ts similarity index 97% rename from apps/web/src/features/block-email/util/plainTextToHtml.test.ts rename to apps/web/src/features/email-compose/core/plain-text-to-html.test.ts index 7741b028f07..f809f2286fa 100644 --- a/apps/web/src/features/block-email/util/plainTextToHtml.test.ts +++ b/apps/web/src/features/email-compose/core/plain-text-to-html.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { plainTextToHtml } from './plainTextToHtml'; +import { plainTextToHtml } from './plain-text-to-html'; const span = (text: string) => `${text}`; 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 84% 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..328a7772592 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,6 @@ -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 { 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,12 +17,13 @@ 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[]; @@ -80,7 +69,7 @@ export const getReplyAllRecipients = ( // getReplyRecipientsFromParent), or when no recipient remains in to/cc // after filtering out both the user and the sender. export const isReplyAllEligible = ( - message: ApiMessage, + message: EmailMessage, userEmail: string ): boolean => { const sender = message.from?.email; @@ -92,7 +81,7 @@ export const isReplyAllEligible = ( }; export const getReplyRecipientsFromParent = ( - replyingTo: ApiMessage | undefined, + replyingTo: EmailMessage | undefined, userEmail: string ): { to: EmailRecipient[]; diff --git a/apps/web/src/features/block-email/util/replyType.ts b/apps/web/src/features/email-compose/core/reply-type.ts similarity index 64% rename from apps/web/src/features/block-email/util/replyType.ts rename to apps/web/src/features/email-compose/core/reply-type.ts index 606b25951e5..8acab4b7729 100644 --- a/apps/web/src/features/block-email/util/replyType.ts +++ b/apps/web/src/features/email-compose/core/reply-type.ts @@ -1,10 +1,10 @@ -import type { ApiMessage } from '@service-email/generated/schemas'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; export type ReplyType = 'reply' | 'reply-all' | 'forward'; export const getReplyTypeFromDraft: ( - draft: ApiMessage | undefined -) => ReplyType | undefined = (draft: ApiMessage | undefined) => { + draft: EmailMessage | undefined +) => ReplyType | undefined = (draft: EmailMessage | undefined) => { if (!draft) { return undefined; } 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..d209ace9069 --- /dev/null +++ b/apps/web/src/features/email-compose/email-compose.tsx @@ -0,0 +1,11 @@ +import type { ComponentProps } from 'solid-js'; +import { createEmailComposeHost } from './compose-host-adapter'; +import { createEmailComposeServices } from './compose-service-adapter'; +import { EmailComposeView } from './views/email-compose'; +export function EmailCompose( + props: Omit, 'services' | 'host'> +) { + const services = createEmailComposeServices(); + 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/block-email/util/appendedReplyRoundTrip.test.ts b/apps/web/src/features/email-compose/primitives/appended-reply-round-trip.test.ts similarity index 96% rename from apps/web/src/features/block-email/util/appendedReplyRoundTrip.test.ts rename to apps/web/src/features/email-compose/primitives/appended-reply-round-trip.test.ts index a5f2ed14e45..3b74e6939ce 100644 --- a/apps/web/src/features/block-email/util/appendedReplyRoundTrip.test.ts +++ b/apps/web/src/features/email-compose/primitives/appended-reply-round-trip.test.ts @@ -1,10 +1,10 @@ // @vitest-environment jsdom +import type { EmailMessage } from '@app/features/email-message/core/email-message'; import { $generateNodesFromDOM } from '@lexical/html'; import { $isClassedBlockNode, SupportedNodeTypes, } from '@macro-inc/lexical-core'; -import type { ApiMessage } from '@service-email/generated/schemas'; import { $createParagraphNode, $createTextNode, @@ -17,7 +17,7 @@ import { prepareEmailBody, registerToggleAppendedThread, TOGGLE_APPEND_EMAIL_THREAD_COMMAND, -} from './prepareEmailBody'; +} from './prepare-email-body'; function decodeBodyHtml(encoded: string) { const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); @@ -34,7 +34,7 @@ const replyingTo = { body_text: 'original message text', internal_date_ts: '2026-08-01T12:00:00Z', attachments: [], -} as unknown as ApiMessage; +} as unknown as EmailMessage; function makeEditor() { const editor = createEditor({ diff --git a/apps/web/src/features/email-compose/primitives/compose-operation.test.ts b/apps/web/src/features/email-compose/primitives/compose-operation.test.ts new file mode 100644 index 00000000000..9ddfc4a7f20 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/compose-operation.test.ts @@ -0,0 +1,64 @@ +import { createRoot } from 'solid-js'; +import { describe, expect, it, vi } from 'vitest'; +import { createComposeOperation } from './compose-operation'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +describe('compose operations', () => { + it('keeps progress pending until concurrent requests and completion effects finish', async () => { + const first = deferred(); + const second = deferred(); + const completion = deferred(); + const root = createRoot((dispose) => ({ + dispose, + operation: createComposeOperation( + (input: number) => (input === 1 ? first.promise : second.promise), + { onSuccess: () => completion.promise } + ), + })); + try { + const a = root.operation.run(1); + const b = root.operation.run(2); + expect(root.operation.pending()).toBe(true); + first.resolve('first'); + await Promise.resolve(); + expect(root.operation.result()).toBe('first'); + expect(root.operation.pending()).toBe(true); + completion.resolve(); + await a; + expect(root.operation.pending()).toBe(true); + second.resolve('second'); + await b; + expect(root.operation.pending()).toBe(false); + } finally { + root.dispose(); + } + }); + it('reports a failure once, clears progress, and permits another attempt', async () => { + const fail = vi + .fn() + .mockRejectedValueOnce('offline') + .mockResolvedValueOnce('saved'); + const onError = vi.fn(); + const root = createRoot((dispose) => ({ + dispose, + operation: createComposeOperation(fail, { onError }), + })); + try { + await expect(root.operation.run('draft')).rejects.toThrow('offline'); + expect(onError).toHaveBeenCalledWith(expect.any(Error), 'draft'); + expect(root.operation.pending()).toBe(false); + expect(await root.operation.run('draft')).toBe('saved'); + expect(onError).toHaveBeenCalledOnce(); + } finally { + root.dispose(); + } + }); +}); diff --git a/apps/web/src/features/email-compose/primitives/compose-operation.ts b/apps/web/src/features/email-compose/primitives/compose-operation.ts new file mode 100644 index 00000000000..9c5b8416d25 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/compose-operation.ts @@ -0,0 +1,36 @@ +import { createSignal } from 'solid-js'; + +/** Progress and completion for one operation, independent of the query library. */ +export function createComposeOperation( + execute: (input: Input) => Promise, + callbacks: { + onSuccess?: (output: Output, input: Input) => void | Promise; + onError?: (error: Error, input: Input) => void | Promise; + } = {} +) { + const [inFlight, setInFlight] = createSignal(0); + const [result, setResult] = createSignal(); + const run = async (input: Input) => { + setInFlight((count) => count + 1); + try { + const output = await execute(input); + setResult(() => output); + await callbacks.onSuccess?.(output, input); + return output; + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + await callbacks.onError?.(error, input); + throw error; + } finally { + setInFlight((count) => count - 1); + } + }; + return { + pending: () => inFlight() > 0, + result, + run, + start: (input: Input) => { + void run(input).catch(() => {}); + }, + }; +} 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 78% 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..74644f1ae2b 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,8 +1,8 @@ -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'; - +import type { Accessor, JSX } from 'solid-js'; +import type { ComposeBodyActions } from '../context/editor-capabilities'; +import type { EmailRecipient } from '../core/email-recipient'; +import type { DraftFormAttachment } from './email-form-state'; export type EmailFormRecipients = { to: EmailRecipient[]; cc: EmailRecipient[]; @@ -17,6 +17,13 @@ export type ComposeValidationError = { }; export interface ComposeContextValue { + bodyActions: ComposeBodyActions; + isMobile: Accessor; + scheduleEnabled: boolean; + attachmentFailure(message: string, options?: { subtext?: string }): void; + prepareSignatureLinks?: (root: ShadowRoot) => void; + onUpgrade?: () => void; + viewerLoading?: Accessor; // Form state (read) recipients: () => EmailFormRecipients; subject: () => string; @@ -69,7 +76,12 @@ 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; @@ -83,15 +95,3 @@ export interface ComposeContextValue { // 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/email-composer.test.ts b/apps/web/src/features/email-compose/primitives/email-composer.test.ts new file mode 100644 index 00000000000..c3996d927d6 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/email-composer.test.ts @@ -0,0 +1,181 @@ +import { SupportedNodeTypes } from '@macro-inc/lexical-core'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + createEditor, +} from 'lexical'; +import { createRoot } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { decodeBase64Utf8 } from '../core/decode-base64'; +import { composeServices } from '../tests/services'; +import { createEmailComposer } from './email-composer'; + +// Real controller and editor, with only feature capabilities replaced. +describe('standalone compose controller', () => { + afterEach(() => vi.useRealTimers()); + it('saves a composed draft after debounce and preserves the recipients and HTML', async () => { + vi.useFakeTimers(); + const services = composeServices(); + const showDraft = vi.fn(); + const root = createRoot((dispose) => ({ + dispose, + state: createEmailComposer({ + services, + host: { showDraft }, + initialTo: ['colleague@example.com'], + }), + })); + try { + const editor = createEditor({ + nodes: SupportedNodeTypes, + namespace: 'email-controller-test', + onError: (error) => { + throw error; + }, + }); + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Keep this draft')) + ); + }, + { discrete: true } + ); + root.state.context.captureEditor(editor); + root.state.context.setSubject('Architecture'); + root.state.context.onContentChange('Keep this draft'); + await vi.advanceTimersByTimeAsync(600); + expect(services.saveDraft).toHaveBeenCalled(); + const input = vi.mocked(services.saveDraft).mock.calls.at(-1)![0]; + expect(input.draft.to).toEqual([ + expect.objectContaining({ email: 'colleague@example.com' }), + ]); + expect(input.draft.subject).toBe('Architecture'); + expect(decodeBase64Utf8(input.draft.body_html ?? '')).toContain( + 'Keep this draft' + ); + expect(root.state.context.hasDraft()).toBe(true); + } finally { + root.dispose(); + } + }); + it('does not send an empty draft', async () => { + const services = composeServices(); + const root = createRoot((dispose) => ({ + dispose, + state: createEmailComposer({ services }), + })); + try { + root.state.context.onSend(); + await Promise.resolve(); + expect(services.sendMessage).not.toHaveBeenCalled(); + } finally { + root.dispose(); + } + }); +}); + +it('reports scheduling failure without adopting an unconfirmed send time, and keeps a confirmed schedule when archive fails', async () => { + vi.useFakeTimers(); + const services = composeServices(); + const root = createRoot((dispose) => ({ + dispose, + state: createEmailComposer({ + services, + initialTo: ['colleague@example.com'], + }), + })); + try { + const editor = createEditor({ + nodes: SupportedNodeTypes, + onError: (error) => { + throw error; + }, + }); + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Schedule this reply')) + ); + }, + { discrete: true } + ); + root.state.context.captureEditor(editor); + root.state.context.setSubject('Schedule review'); + root.state.context.onContentChange('Schedule this reply'); + const requested = new Date('2026-10-01T12:00:00Z'); + vi.mocked(services.schedule).mockRejectedValueOnce(new Error('offline')); + await root.state.context.onSendTimeChange?.(requested); + expect(root.state.context.sendTime()).toBeFalsy(); + expect(services.feedback.failure).toHaveBeenCalledWith( + 'Failed to schedule message' + ); + vi.mocked(services.archive).mockRejectedValueOnce( + new Error('archive offline') + ); + await root.state.context.onSendTimeChange?.(requested); + expect(root.state.context.sendTime()).toEqual(requested); + expect(services.feedback.failure).toHaveBeenCalledWith( + 'Email scheduled, but unable to mark thread done' + ); + } finally { + root.dispose(); + vi.useRealTimers(); + } +}); + +it('blocks immediate send and overlapping changes while a scheduling request is pending', async () => { + let finish!: () => void; + const services = composeServices({ + schedule: vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }) + ), + }); + const root = createRoot((dispose) => ({ + dispose, + state: createEmailComposer({ + services, + initialTo: ['colleague@example.com'], + }), + })); + try { + const editor = createEditor({ + nodes: SupportedNodeTypes, + onError: (error) => { + throw error; + }, + }); + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Schedule this reply')) + ); + }, + { discrete: true } + ); + root.state.context.captureEditor(editor); + root.state.context.setSubject('Schedule review'); + root.state.context.onContentChange('Schedule this reply'); + const request = root.state.context.onSendTimeChange?.( + new Date('2026-10-01T12:00:00Z') + ); + await vi.waitFor(() => expect(services.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(services.sendMessage).not.toHaveBeenCalled(); + expect(services.schedule).toHaveBeenCalledOnce(); + finish(); + await request; + expect(root.state.context.disabled()).toBe(false); + expect(root.state.draftDirty()).toBe(true); + } finally { + root.dispose(); + } +}); diff --git a/apps/web/src/features/block-email/component/compose/Compose.tsx b/apps/web/src/features/email-compose/primitives/email-composer.ts similarity index 60% rename from apps/web/src/features/block-email/component/compose/Compose.tsx rename to apps/web/src/features/email-compose/primitives/email-composer.ts index 1efb5932404..e8978295e06 100644 --- a/apps/web/src/features/block-email/component/compose/Compose.tsx +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -1,107 +1,41 @@ -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 { MACRO_EMAIL_SIGNATURE } from '@app/features/email-compose/core/constants'; 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 { createEffect, createMemo, createSignal, on } from 'solid-js'; import { unwrap } from 'solid-js/store'; +import type { EmailContact } from '../../email-message/core/email-message'; +import type { + EmailComposeHost, + EmailComposeServices, +} from '../context/compose-services'; +import type { EmailReplySession } from '../context/email-form-dependencies'; +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 { createComposeOperation } from '../primitives/compose-operation'; +import type { + ComposeContextValue, + ComposeValidationError, +} from '../primitives/compose-view-state'; +import type { EmailFormRecipients } from '../primitives/email-form-state'; +import { + createEmailFormState, + type DraftFormAttachment, +} from '../primitives/email-form-state'; import { - type ComposeContextValue, - ComposeProvider, - type ComposeValidationError, -} from './ComposeContext'; -import { ComposeLayout } from './ComposeLayout'; -import { EmailComposeToolbar } from './ComposeToolbar'; -import { SignaturePreview } from './SignaturePreview'; + clearEmailBody, + hasDraftContent, + prepareEmailBody, +} from '../primitives/prepare-email-body'; +import { endUndoSend } from '../primitives/undo-send-claim'; +import { createEmailUndoStore } from './undo-store'; const DRAFT_DEBOUNCE_MS = 500; @@ -114,23 +48,33 @@ type UndoComposeSnapshot = { includeSignature: boolean; }; -let undoComposeSnapshot: UndoComposeSnapshot | null = null; +const composeUndo = createEmailUndoStore(); -type EmailComposeProps = { +export type EmailComposeInput = { + services: EmailComposeServices; + host?: EmailComposeHost; + session?: EmailReplySession; 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(); +export function createEmailComposer(props: EmailComposeInput) { + const services = props.services; + const [scheduling, setScheduling] = createSignal(false); + const hasPaidAccess = services.hasPaidAccess; + const uploadAttachmentMutation = createComposeOperation( + services.uploadAttachments + ); + const saveDraftMutation = createComposeOperation(services.saveDraft); + const deleteDraftMutation = createComposeOperation(services.deleteDraft); + const emailContext = props.session; const form = createEmailFormState( + { + viewerEmail: services.viewerEmail, + inboxes: services.accounts.inboxes, + }, props.draftID ? { type: 'draft', @@ -147,9 +91,9 @@ export function EmailCompose(props: EmailComposeProps) { : undefined ); - const primaryLinkId = usePrimaryEmailLinkId(); + const primaryLinkId = services.accounts.primaryId; const link = createMemo(() => { - const data = emailLinksQuery.data; + const data = { links: services.accounts.inboxes() }; 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. @@ -162,7 +106,7 @@ export function EmailCompose(props: EmailComposeProps) { return data.links.find((l) => l.id === targetId) ?? data.links[0]; }); - const toHeaderLinkId = useNonPrimaryEmailLinkIdHeader(); + const toHeaderLinkId = services.accounts.headerId; // 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); @@ -171,19 +115,17 @@ export function EmailCompose(props: EmailComposeProps) { // 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 signature = () => link()?.settings.signature ?? undefined; const [includeSignature, setIncludeSignature] = createSignal(true); const hasLinkError = createMemo(() => { - if (emailLinksQuery.isPending) return false; + if (services.accounts.loading()) return false; return ( - emailLinksQuery.isError || - (emailLinksQuery.data && emailLinksQuery.data.links.length === 0) + services.accounts.failed() || services.accounts.inboxes().length === 0 ); }); - const { users: destinationOptions } = useCombinedRecipients(); + const destinationOptions = services.recipients; const [editor, setEditor] = createSignal(); const [content, setContent] = createSignal(''); @@ -205,8 +147,9 @@ export function EmailCompose(props: EmailComposeProps) { ); // Restore form state from undo-send snapshot if available - const restoredSnapshot = - undoComposeSnapshot?.draftId === props.draftID ? undoComposeSnapshot : null; + const restoredSnapshot = props.draftID + ? composeUndo.take(props.draftID) + : undefined; if (restoredSnapshot) { form.setRecipients('to', restoredSnapshot.recipients.to); @@ -217,19 +160,20 @@ export function EmailCompose(props: EmailComposeProps) { 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), + props.initialTo.map((email) => ({ + kind: 'custom' as const, + id: `macro|${email}`, + data: { + id: `macro|${email}`, email, invalid: !EmailValidator.validate(email), - }) - ) + }, + })) ); } @@ -239,7 +183,7 @@ export function EmailCompose(props: EmailComposeProps) { $removeAllWatermarkNodes(editor()); const prepared = prepareEmailBody(editor()); if (!prepared) { - Telemetry.error( + services.reportError( new Error('Unable to prepare email body for draft collection.') ); return null; @@ -272,14 +216,14 @@ export function EmailCompose(props: EmailComposeProps) { const inFlightAttachmentUploads = new Set>(); async function executeSaveDraft() { - if (sendMutation.isPending) { + if (sendMutation.pending()) { return; } const draftToSave = collectDraft(); if (!draftToSave) { const draftID = currentDraftID(); if (draftID) { - await deleteDraftMutation.mutateAsync({ + await deleteDraftMutation.run({ draftId: draftID, threadId: currentThreadID(), linkId: headerLinkId(), @@ -290,7 +234,7 @@ export function EmailCompose(props: EmailComposeProps) { } const previousThreadID = currentThreadID(); - const draftResponse = await saveDraftMutation.mutateAsync({ + const draftResponse = await saveDraftMutation.run({ draft: { ...draftToSave, db_id: currentDraftID(), @@ -300,8 +244,8 @@ export function EmailCompose(props: EmailComposeProps) { const newThreadID = draftResponse.draft.thread_db_id ?? undefined; if (previousThreadID && previousThreadID !== newThreadID) { - invalidateSoupEntity(previousThreadID); - refetchSoupEntity(previousThreadID, 'emailThread'); + services.invalidatePreview(previousThreadID); + services.refreshThreadPreview(previousThreadID); } setCurrentThreadID(newThreadID); @@ -316,7 +260,7 @@ export function EmailCompose(props: EmailComposeProps) { let uploadRun: Promise | undefined; if (attachments.length) { - uploadRun = uploadAttachmentMutation.mutateAsync({ + uploadRun = uploadAttachmentMutation.run({ draftID: draftId, attachments: attachments.map((a) => a.file), linkId: headerLinkId(), @@ -357,9 +301,12 @@ export function EmailCompose(props: EmailComposeProps) { // --- Attachment handling --- - const removeAttachmentMutation = useRemoveDraftAttachmentMutation(); - const removeForwardedAttachmentMutation = - useRemoveForwardedAttachmentMutation(); + const removeAttachmentMutation = createComposeOperation( + services.removeAttachment + ); + const removeForwardedAttachmentMutation = createComposeOperation( + services.removeForwardedAttachment + ); const handleAddAttachments = (attachments: DraftFormAttachment[]) => { for (const attachment of attachments) { @@ -382,13 +329,13 @@ export function EmailCompose(props: EmailComposeProps) { if (!savedDraftID || !attachment.attachmentID) return; if (attachment.type === 'forwarded') { - removeForwardedAttachmentMutation.mutate({ + removeForwardedAttachmentMutation.start({ draftID: savedDraftID, attachmentID: attachment.attachmentID, linkId: headerLinkId(), }); } else { - removeAttachmentMutation.mutate({ + removeAttachmentMutation.start({ draftID: savedDraftID, attachmentID: attachment.attachmentID, linkId: headerLinkId(), @@ -410,8 +357,6 @@ export function EmailCompose(props: EmailComposeProps) { // --- Send --- - const { replaceSplit } = useSplitLayout(); - const [validationError, setValidationError] = createSignal(null); @@ -425,16 +370,14 @@ export function EmailCompose(props: EmailComposeProps) { ) => { // 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); + if (threadId) services.prepareUndo(threadId, draftId); // 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; + const snapshot = composeUndo.peek(draftId); if (snapshot) { - await restoreDraftBodyAfterUndo( + await services.restoreDraft( { bcc: snapshot.recipients.bcc.map(convertEmailRecipientToContactInfo), cc: snapshot.recipients.cc.map(convertEmailRecipientToContactInfo), @@ -451,20 +394,9 @@ export function EmailCompose(props: EmailComposeProps) { // 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); - } + if (threadId) services.refreshAfterUndo(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, - }, - }); + props.host?.showDraft?.(draftId); }; // `linkId` is the X-Email-Link-Id header value the send itself used, resolved @@ -474,27 +406,26 @@ export function EmailCompose(props: EmailComposeProps) { threadId: string | undefined, linkId: string | undefined ) => - runUndoSend({ + services.undoSend({ draftId, linkId, onUndone: () => restoreAfterUndoSend(draftId, threadId, linkId), }); - const sendMutation = useSendMessageMutation({ + const sendMutation = createComposeOperation(services.sendMessage, { 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', { + const toastId = services.feedback.success('Email sent', { actions: draftId ? [ { label: 'Undo', - icon: ArrowCounterClockwise, onClick: () => { - if (toastId != null) toast.dismiss(toastId); + if (toastId != null) services.feedback.dismiss(toastId); void undoSend(draftId, threadId ?? undefined, sendLinkId); }, }, @@ -503,18 +434,16 @@ export function EmailCompose(props: EmailComposeProps) { duration: 5_000, }); if (data.message.thread_db_id) { - replaceSplit({ - content: { type: 'email', id: data.message.thread_db_id }, - mergeHistory: true, - }); + props.host?.showThread?.(data.message.thread_db_id); } }, onError: () => { - toast.failure('Failed to send email'); + services.feedback.failure('Failed to send email'); }, }); const onSubmit = async () => { + if (scheduling()) return; setValidationError(null); const currentEditor = editor(); @@ -567,6 +496,9 @@ export function EmailCompose(props: EmailComposeProps) { // 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(() => @@ -574,14 +506,14 @@ export function EmailCompose(props: EmailComposeProps) { ); const draftId = currentDraftID(); if (draftId) { - undoComposeSnapshot = { + composeUndo.remember({ draftId, recipients: structuredClone(unwrap(form.recipients())), subject: form.subject(), bodyHtml: snapshotHtml, attachments: [...form.attachments.list()], includeSignature: includeSignature(), - }; + }); } } @@ -600,7 +532,7 @@ export function EmailCompose(props: EmailComposeProps) { const bodyMacro = content(); - sendMutation.mutate({ + sendMutation.start({ message: { to: convertToContactInfoArray(recipients.to), cc: @@ -628,56 +560,70 @@ export function EmailCompose(props: EmailComposeProps) { // --- 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; + const unscheduleMessageMutation = createComposeOperation( + services.unschedule, + { + onSuccess: (_data, vars) => { + services.feedback.success('Email unscheduled'); + services.invalidatePreview(vars.draftID); + }, + onError: () => { + services.feedback.failure('Failed to unschedule email'); + }, } + ); - form.setSendTime(date); - - if (date) { - const draftID = currentDraft ?? (await executeSaveDraft()); - if (!draftID) { - toast.failure('Failed to schedule message', { - subtext: 'Draft required', - }); + const handleSendTimeChange = async (date: Date | null) => { + if (scheduling()) return; + setScheduling(true); + try { + const previous = form.sendTime(); + const currentDraft = currentDraftID(); + if (!date && previous && currentDraft) { + try { + await unscheduleMessageMutation.run({ + draftID: currentDraft, + linkId: headerLinkId(), + }); + form.setSendTime(null); + setDraftDirty(true); + } catch { + /* The operation reports the failure; keep the confirmed send time. */ + } 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 }, + if (!date) { + form.setSendTime(null); + setDraftDirty(true); + return; + } + try { + const draftID = currentDraft ?? (await executeSaveDraft()); + if (!draftID) throw new Error('Draft required'); + await services.schedule( + { draftID, send_time: date.toISOString() }, headerLinkId() ); + form.setSendTime(date); + setDraftDirty(true); + } catch (error) { + services.reportError(error); + services.feedback.failure('Failed to schedule message'); + return; } + const threadID = saveDraftMutation.result()?.draft.thread_db_id; + if (threadID) { + try { + await services.archive({ id: threadID, value: true }, headerLinkId()); + } catch (error) { + services.reportError(error); + services.feedback.failure( + 'Email scheduled, but unable to mark thread done' + ); + } + } + } finally { + setScheduling(false); } }; @@ -710,7 +656,7 @@ export function EmailCompose(props: EmailComposeProps) { const deleteDraftAndReset = async () => { const draftId = currentDraftID(); if (draftId) { - await deleteDraftMutation.mutateAsync({ + await deleteDraftMutation.run({ draftId, threadId: currentThreadID(), linkId: headerLinkId(), @@ -753,7 +699,7 @@ export function EmailCompose(props: EmailComposeProps) { let recipientName = recipients[0].data.email; if (recipients[0].kind === 'user') { - recipientName = getDisplayName(tryMacroId(recipients[0].data.id)); + recipientName = services.recipientName(recipients[0].data.id); } return recipientName ? `Email to ${recipientName}` : 'Draft email'; @@ -763,7 +709,7 @@ export function EmailCompose(props: EmailComposeProps) { .slice(0, 2) .map((r) => { if (r.kind === 'user') { - return getDisplayName(tryMacroId(r.data.id)); + return services.recipientName(r.data.id); } return r.data.email || 'Unknown'; }) @@ -779,6 +725,26 @@ export function EmailCompose(props: EmailComposeProps) { // --- Context value --- const ctxValue: ComposeContextValue = { + bodyActions: { + focusSibling: props.host?.focusSibling, + recipientAdded: (email) => { + services.feedback.success(`${email} added to CC`); + }, + readDroppedFiles: services.readDroppedFiles, + pasteFiles: (editor, files, directories) => + services.uploadEditorFiles({ + editor, + files, + directories, + onUploaded: (ids) => ids.forEach(services.makePublic), + }), + }, + isMobile: services.isMobile, + scheduleEnabled: services.scheduleEnabled, + attachmentFailure: services.feedback.failure, + prepareSignatureLinks: services.prepareSignatureLinks, + onUpgrade: services.onUpgrade, + viewerLoading: services.viewerLoading, // Form state (read) recipients: form.recipients, subject: form.subject, @@ -808,8 +774,8 @@ export function EmailCompose(props: EmailComposeProps) { onSendTimeChange: handleSendTimeChange, // Status - disabled: () => hasLinkError() || sendMutation.isPending, - isSending: () => sendMutation.isPending, + disabled: () => hasLinkError() || sendMutation.pending() || scheduling(), + isSending: () => sendMutation.pending(), hasDraft: () => currentDraftID() != null, // Validation @@ -824,11 +790,11 @@ export function EmailCompose(props: EmailComposeProps) { focusRecipientsOnMount: !hasLinkError(), // Schedule send - scheduleSendDisabled: () => totalRecipientCount() === 0, + scheduleSendDisabled: () => totalRecipientCount() === 0 || scheduling(), // Display fromAddress: () => link()?.email_address, - fromInboxes: () => emailLinksQuery.data?.links ?? [], + fromInboxes: () => services.accounts.inboxes() ?? [], selectedFromLinkId: () => link()?.id, // Persist immediately on a sender switch so the draft moves to the new // inbox even without a text edit. @@ -839,123 +805,23 @@ export function EmailCompose(props: EmailComposeProps) { 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 { + context: ctxValue, + editor, + previewName, + hasLinkError, + draftDirty, + deleteDraftAndReset, + signature, + includeSignature, + setIncludeSignature, }; - - 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[] { + recipients: EmailRecipient[] +): EmailContact[] { return recipients.map((recipient) => ({ email: recipient.data.email, name: 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.test.ts b/apps/web/src/features/email-compose/primitives/email-form-state.test.ts new file mode 100644 index 00000000000..098325d62bc --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/email-form-state.test.ts @@ -0,0 +1,59 @@ +import { createRoot } from 'solid-js'; +import { describe, expect, it } from 'vitest'; +import type { EmailMessage } from '../../email-message/core/email-message'; +import { createEmailFormState } from './email-form-state'; + +describe('reply recipients across inboxes', () => { + it('uses the thread inbox instead of the viewer address when replying to a sent message', () => + createRoot((dispose) => { + try { + const parent: EmailMessage = { + db_id: 'message', + thread_db_id: 'thread', + link_id: 'secondary', + from: { email: 'secondary@example.com' }, + to: [{ email: 'colleague@example.com' }], + cc: [], + bcc: [], + created_at: '', + updated_at: '', + subject: 'Review', + labels: [], + attachments: [], + attachments_draft: [], + attachments_forwarded: [], + is_read: true, + is_sent: true, + is_draft: false, + is_starred: false, + has_attachments: false, + }; + const form = createEmailFormState( + { + viewerEmail: () => 'primary@example.com', + inboxes: () => [ + { id: 'secondary', email_address: 'secondary@example.com' }, + ], + }, + { type: 'replying_to', messageID: parent.db_id }, + { + getMessageByID: () => parent, + getDraftForMessageReply: () => undefined, + } + ); + expect(form.selectedLinkId()).toBe('secondary'); + expect( + form.recipients().to.map((recipient) => recipient.data.email) + ).toEqual(['colleague@example.com']); + expect( + form + .recipients() + .to.some( + (recipient) => recipient.data.email === 'secondary@example.com' + ) + ).toBe(false); + } finally { + dispose(); + } + })); +}); diff --git a/apps/web/src/features/block-email/component/createEmailFormState.ts b/apps/web/src/features/email-compose/primitives/email-form-state.ts similarity index 93% rename from apps/web/src/features/block-email/component/createEmailFormState.ts rename to apps/web/src/features/email-compose/primitives/email-form-state.ts index 4c307c73467..60f9526776e 100644 --- a/apps/web/src/features/block-email/component/createEmailFormState.ts +++ b/apps/web/src/features/email-compose/primitives/email-form-state.ts @@ -1,19 +1,18 @@ -import { useEmail } from '@core/context/user'; -import { useEmailLinksQuery } from '@queries/email/link'; -import type { ApiMessage } from '@service-email/generated/schemas'; +import type { EmailRecipient } from '@app/features/email-compose/core/email-recipient'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; 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 type { EmailFormDependencies } from '../context/email-form-dependencies'; +import { decodeBase64Utf8 } from '../core/decode-base64'; import { convertContactInfoToEmailRecipient, getReplyAllRecipients, getReplyRecipientsFromParent, -} from '../util/recipientConversion'; -import type { ReplyType } from '../util/replyType'; -import { getSubjectText } from '../util/subjectText'; -import type { EmailRecipient } from './EmailContext'; +} from '../core/recipient-conversion'; +import type { ReplyType } from '../core/reply-type'; +import { getSubjectText } from '../core/subject-text'; +import { TOGGLE_APPEND_EMAIL_THREAD_COMMAND } from './email-editor-commands'; export type EmailFormRecipients = { to: EmailRecipient[]; @@ -44,12 +43,12 @@ export type DraftFormAttachment = }; export interface EmailFormStateOptions { - getMessageByID: (id: string) => ApiMessage | undefined; - getDraftForMessageReply: (id: string) => ApiMessage | undefined; + getMessageByID: (id: string) => EmailMessage | undefined; + getDraftForMessageReply: (id: string) => EmailMessage | 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; + isPersonalMessage?: (message: EmailMessage) => boolean; } type EmailFormState = { @@ -84,21 +83,22 @@ const EMPTY_FORM_STATE: EmailFormState = { * @returns A state object for the email form. */ export function createEmailFormState( + dependencies: EmailFormDependencies, purpose?: | { type: 'replying_to'; messageID: string } | { type: 'draft'; messageID: string }, options?: EmailFormStateOptions ) { - const userEmail = useEmail(); + const userEmail = dependencies.viewerEmail; - let replyingTo: ApiMessage | undefined; + let replyingTo: EmailMessage | undefined; if (purpose?.type === 'replying_to') { replyingTo = options?.getMessageByID?.(purpose.messageID); } - let draft: ApiMessage | undefined; + let draft: EmailMessage | undefined; if (purpose?.type === 'draft') { draft = options?.getMessageByID(purpose.messageID); @@ -106,7 +106,6 @@ export function createEmailFormState( 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( @@ -119,7 +118,7 @@ export function createEmailFormState( const inboxEmail = () => { const linkId = selectedLinkId() ?? (draft ?? replyingTo)?.link_id; const ownerEmail = linkId - ? linksQuery.data?.links.find((l) => l.id === linkId)?.email_address + ? dependencies.inboxes().find((l) => l.id === linkId)?.email_address : undefined; return ownerEmail ?? userEmail() ?? ''; }; 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..dfceee89a8f --- /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/block-email/util/flattenConsecutiveParagraphs.test.ts b/apps/web/src/features/email-compose/primitives/flatten-consecutive-paragraphs.test.ts similarity index 95% 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..9b925d2da9c 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; diff --git a/apps/web/src/features/block-email/util/flattenConsecutiveParagraphs.ts b/apps/web/src/features/email-compose/primitives/flatten-consecutive-paragraphs.ts similarity index 100% rename from apps/web/src/features/block-email/util/flattenConsecutiveParagraphs.ts rename to apps/web/src/features/email-compose/primitives/flatten-consecutive-paragraphs.ts 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/block-email/util/prepareEmailBody.test.ts b/apps/web/src/features/email-compose/primitives/prepare-email-body.test.ts similarity index 91% rename from apps/web/src/features/block-email/util/prepareEmailBody.test.ts rename to apps/web/src/features/email-compose/primitives/prepare-email-body.test.ts index 3a46f9d8e37..5836e13af51 100644 --- a/apps/web/src/features/block-email/util/prepareEmailBody.test.ts +++ b/apps/web/src/features/email-compose/primitives/prepare-email-body.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom -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 { prepareEmailBodyFromHtml } from './prepareEmailBody'; +import { prepareEmailBodyFromHtml } from './prepare-email-body'; function decodeBodyHtml(encoded: string) { const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); @@ -17,7 +17,7 @@ const replyingTo = { body_text: 'original message text', internal_date_ts: '2026-08-01T12:00:00Z', attachments: [], -} as unknown as ApiMessage; +} as unknown as EmailMessage; describe('prepareEmailBodyFromHtml', () => { it('does not add a quote block without appendReply (undo-send restore)', () => { 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 95% 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..69ff6feee13 100644 --- a/apps/web/src/features/block-email/util/prepareEmailBody.ts +++ b/apps/web/src/features/email-compose/primitives/prepare-email-body.ts @@ -1,3 +1,4 @@ +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'; @@ -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 ) => { @@ -329,7 +322,7 @@ async function _appendItemsAsMacroMentions( } function getAppendedReplyElement( - replyingTo: ApiMessage, + replyingTo: EmailMessage, replyType: ReplyType | undefined ) { const wrapper = document.createElement('div'); @@ -397,7 +390,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 +414,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-input.ts b/apps/web/src/features/email-compose/primitives/reply-input.ts new file mode 100644 index 00000000000..7be3335ac53 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/reply-input.ts @@ -0,0 +1,1414 @@ +import { + MACRO_EMAIL_SIGNATURE, + MAX_ATTACHMENTS_BYTES_SIZE, +} from '@app/features/email-compose/core/constants'; +import type { EmailRecipient } from '@app/features/email-compose/core/email-recipient'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; +import { isPersonalMessage } from '@app/features/email-message/core/is-personal-message'; +import type { + EmailComposeServices, + EmailUndoHandle, +} from '../context/compose-services'; +import type { EmailReplySession } from '../context/email-form-dependencies'; +import type { EmailDraft } from '../core/email-draft'; +import { getRecipientDisplayName } from '../core/email-recipient'; +import { createComposeOperation } from '../primitives/compose-operation'; +import { createEmailUndoStore } from './undo-store'; + +type EmailDraftId = string | null; + +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 { convertEmailRecipientToContactInfo } from '../core/recipient-conversion'; +import { getReplyTypeFromDraft } from '../core/reply-type'; +import type { DraftFormAttachment } from '../primitives/email-form-state'; +import type { + EmailFormContextValue, + FormAccessKey, +} from '../primitives/email-form-types'; +import { addUserMentionToCc } from '../primitives/mention-to-cc'; +import { + clearEmailBody, + hasDraftContent, + prepareEmailBody, + prepareMacroBody, + TOGGLE_APPEND_EMAIL_THREAD_COMMAND, +} from '../primitives/prepare-email-body'; +import { endUndoSend } from '../primitives/undo-send-claim'; + +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: EmailDraft; +}; +const replyUndo = createEmailUndoStore(); + +export type ReplyInputProps = { + newMessage?: boolean; + services: EmailComposeServices; + session: EmailReplySession; + sourceEntityId: string; + replyingTo: Accessor; + isEditingExisting?: boolean; + draft?: EmailMessage; + 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: EmailDraftId | null) => void; + onMarkDone?: (opts?: { + silent?: boolean; + onUndoHandle?: (handle: EmailUndoHandle) => void; + nextEntityId?: string; + }) => void; + setShowReply?: Setter; + markdownDomRef?: (ref: HTMLDivElement) => void | HTMLDivElement; + unframed?: boolean; + mobileDrawer?: { + onClose: () => void; + }; +}; +type ReplyEditorOptions = { + namespace: string; + onChange?: (markdown: string) => void; + onUserMention?: (mention: UserMentionRecord) => void; + onDocumentMention?: (item: { id: string }) => void; + onPasteFilesAndDirs?: ( + files: FileSystemFileEntry[], + directories: FileSystemDirectoryEntry[] + ) => void; + scrollContainer?: Accessor; +}; + +export function createReplyInput( + props: ReplyInputProps, + editor: Accessor, + dom: { + container: Accessor; + footer: Accessor; + }, + forms: (key?: FormAccessKey) => EmailFormContextValue +) { + const ctx = props.session; + 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 forms(); + } + + // 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 forms({ + 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 forms({ + type: 'draft', + messageID: props.draft.db_id, + seed: props.formSeed, + }); + } + + // Fallback to empty state + return forms(); + }); + const sourceEntityId = props.sourceEntityId; + const undoKey = `${sourceEntityId}:${props.replyingTo()?.db_id ?? props.draft?.replying_to_id ?? props.draft?.db_id ?? 'new'}`; + const services = props.services; + const [scheduling, setScheduling] = createSignal(false); + const userEmail = services.viewerEmail; + + const toHeaderLinkId = services.accounts.headerId; + const primaryLinkId = services.accounts.primaryId; + // 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() ?? + services.accounts.inboxes()[0]?.id; + const headerLinkId = () => toHeaderLinkId(activeLinkId()); + // The address of the inbox this input sends from, for the "from" display. + const activeInboxEmail = () => + services.accounts.inboxes().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(() => + services.accounts.inboxes().find((l) => l.id === activeLinkId()) + ); + const signature = () => + services.accounts.inboxes().find((inbox) => inbox.id === activeLinkId()) + ?.settings.signature ?? undefined; + const emailSignaturesFlag = () => ({ enabled: services.signaturesEnabled() }); + // 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 = replyUndo.takePending(undoKey); + + // The draft row this composer upserts into: the server draft when one + // exists, else the one the undone send restores. + const [savedDraftId, setSavedDraftId] = createSignal< + EmailDraftId | undefined + >(props.draft?.db_id ?? restoredSnapshot?.draftId ?? undefined); + + const editorOptions: ReplyEditorOptions = { + namespace: 'email-base-input-markdown', + scrollContainer, + onChange: (markdown) => handleChange(markdown), + onUserMention: (mention) => handleUserMention(mention), + onDocumentMention: (item) => { + services.makePublic(item.id); + scheduleDraftSave(); + }, + onPasteFilesAndDirs: (files, directories) => { + services.uploadEditorFiles({ + editor: editor(), + sourceId: sourceEntityId, + files, + directories, + onUploaded: (ids) => { + ids.forEach(services.makePublic); + scheduleDraftSave(); + }, + }); + }, + }; + + // 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) { + 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 unregisterUndo = replyUndo.register(undoKey, (snapshot) => { + const draftId = 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(unregisterUndo); + + const initialHtml = () => restoredSnapshot?.bodyHtml ?? props.preloadedHtml; + const handleEditorConnect = () => { + const currentEditor = editor(); + if (!currentEditor) return; + 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: EmailUndoHandle | 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 + ) => { + const snapshot = replyUndo.take(draftId); + + // 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) services.prepareUndo(threadId, draftId); + + // 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 services.restoreDraft( + 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) services.refreshAfterUndo(threadId); + + if (snapshot) { + // Resolve the live registration after cache updates and unmounts settle. + setTimeout(() => replyUndo.restore(undoKey, snapshot), 0); + 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: () => + services.feedback.failure('Failed to restore thread to inbox'), + }); + } + if (threadId) { + void services.refreshThreadPreview(threadId); + } + }; + + // 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) => + services.undoSend({ + draftId, + linkId, + onUndone: () => restoreAfterUndoSend(draftId, linkId), + }); + + const sendMutation = createComposeOperation(services.sendMessage, { + 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 = services.feedback.success('Email sent', { + actions: draftId + ? [ + { + label: 'Undo', + onClick: () => { + if (toastId != null) services.feedback.dismiss(toastId); + void undoSend(draftId, sendLinkId); + }, + }, + ] + : undefined, + duration: 5_000, + }); + pendingMentions.forEach((mention) => { + services.recordMention(sourceEntityId, 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; + services.feedback.failure('Failed to send email'); + }, + }); + + const uploadAttachmentMutation = createComposeOperation( + services.uploadAttachments + ); + const addForwardedAttachmentsMutation = createComposeOperation( + services.addForwardedAttachments + ); + const saveDraftMutation = createComposeOperation(services.saveDraft); + const deleteDraftMutation = createComposeOperation(services.deleteDraft); + + function refetchThreadMessages() { + const threadId = ctx.thread()?.db_id; + if (threadId) { + services.markDraftSaved(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 (!dom.container()?.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 + dom.footer()?.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(); + dom.footer()?.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) { + services.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: 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.pending() || pendingDeletion || pendingSend) { + return; + } + const draftToSave = collectDraft(); + if (!draftToSave) { + const draftId = savedDraftId(); + if (draftId) { + await deleteDraftMutation.run({ + 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) { + services.reportError(new Error('Failed to save draft: thread not found')); + return; + } + + if (newMessage && currentThread) { + services.reportError( + new Error( + 'Failed to save draft: new message and current thread cannot be provided together' + ) + ); + return; + } + + const draftResponse = await saveDraftMutation.run({ + 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.run({ + 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.run({ + 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 services.feedback. + 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 (!dom.container()?.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 = services.hasPaidAccess; + + const sendEmail = async (markDone = false) => { + if (scheduling()) return; + if (sendMutation.pending() || uploadAttachmentMutation.pending()) 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) { + services.feedback.failure('Email failed to send. No recipients provided'); + return; + } + + const currentThread = ctx.thread(); + const newMessage = props.newMessage ?? false; + + if (!currentThread && !newMessage) { + services.reportError( + new Error("Can't send email, no email thread found") + ); + services.feedback.failure('Email failed to send'); + return; + } + + if (newMessage && currentThread) { + services.feedback.failure('Email failed to send'); + services.reportError( + 'New message and thread cannot be provided together' + ); + return; + } + + let linkId: string | undefined = currentThread?.link_id; + if (newMessage || !linkId) { + if (services.accounts.loading()) { + services.feedback.alert('Loading email accounts...'); + return; + } + + if (services.accounts.failed()) { + services.feedback.failure( + 'Email failed to send: Could not load email accounts' + ); + services.reportError('Failed to load email links'); + return; + } + + const linksData = { links: services.accounts.inboxes() }; + if (!linksData || linksData.links.length < 1) { + services.feedback.failure( + 'Email failed to send: No email account connected' + ); + services.reportError('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. + // 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 = ctx.thread()?.db_id; + if (snapshotDraftId && snapshotThreadId) { + replyUndo.remember({ + 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, + }, + }); + } + } + + // 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 = 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.start({ + 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.run({ + 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), + onRecipientAdded: (email) => { + services.feedback.success(`${email} added to CC`); + }, + }); + }; + + // 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 (services.isTouch()) { + 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) { + services.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 + ) { + services.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 removeAttachmentMutation = createComposeOperation( + services.removeAttachment + ); + const removeForwardedAttachmentMutation = createComposeOperation( + services.removeForwardedAttachment + ); + + 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.start({ + draftID: currentDraftID, + attachmentID: attachment.attachmentID, + linkId: headerLinkId(), + }); + } else { + removeAttachmentMutation.start({ + draftID: currentDraftID, + attachmentID: attachment.attachmentID, + linkId: headerLinkId(), + }); + } + }; + + const unscheduleMessageMutation = createComposeOperation( + services.unschedule, + { + onSuccess: () => { + services.feedback.success('Email unscheduled'); + }, + onError: () => { + services.feedback.failure('Failed to unschedule email'); + }, + } + ); + + const handleSendTimeChange = async (date: Date | null) => { + if (scheduling()) return; + setScheduling(true); + try { + const previous = form().sendTime(); + const currentDraft = savedDraftId(); + if (!date && previous && currentDraft) { + try { + await unscheduleMessageMutation.run({ + draftID: currentDraft, + linkId: headerLinkId(), + }); + form().setSendTime(null); + } catch { + /* The operation reports the failure; keep the confirmed send time. */ + } + return; + } + if (!date) { + form().setSendTime(null); + return; + } + try { + const draftID = currentDraft ?? (await executeSaveDraft()); + if (!draftID) throw new Error('Draft required'); + await services.schedule( + { draftID, send_time: date.toISOString() }, + headerLinkId() + ); + form().setSendTime(date); + } catch (error) { + services.reportError(error); + services.feedback.failure('Failed to schedule message'); + return; + } + const threadID = ctx.thread()?.db_id; + if (threadID) { + try { + await services.archive({ id: threadID, value: true }, headerLinkId()); + } catch (error) { + services.reportError(error); + services.feedback.failure( + 'Email scheduled, but unable to mark thread done' + ); + } + } + } finally { + setScheduling(false); + } + }; + + // 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 = () => + services.isTouch() && + !hasBodyText() && + // Forwards carry the quoted thread as content, so send is available without typing anything. + effectiveReplyType() !== 'forward'; + const sendActionDisabled = () => + scheduling() || + uploadAttachmentMutation.pending() || + sendMutation.pending() || + !!form().sendTime(); + const scheduleSendDisabled = () => + scheduling() || + (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 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(); + }); + }; + + return { + editorOptions, + ctx, + form, + sourceEntityId, + services, + activeLinkId, + activeInboxEmail, + signature, + setIncludeSignature, + setScrollContainer, + composerExpanded, + setComposerExpanded, + quoteCollapsed, + setQuoteCollapsed, + showExpandedRecipients, + setShowExpandedRecipients, + isDragging, + setIsDragging, + setToRef, + ccRef, + setCcRef, + bccRef, + setBccRef, + showCc, + setShowCc, + showBcc, + setShowBcc, + recipientDragState, + savedDraftId, + initialHtml, + handleEditorConnect, + sendMutation, + uploadAttachmentMutation, + collectDraft, + scheduleDraftSave, + persistDraftOnSenderSwitch, + handleChipDragStart, + handleChipDragEnd, + handleRecipientDrop, + withDraftSave, + hasPaidAccess, + sendEmail, + deleteDraftAndReset, + handleAddAttachments, + handleRemoveAttachment, + handleSendTimeChange, + isMobileDrawer, + composePortalScope, + sendActionHidden, + sendActionDisabled, + scheduleSendDisabled, + scrollAreaSignatureHtml, + footerSignatureHtml, + replyingToSummary, + mobileDrawerCcBccOpen, + toggleMobileDrawerCcBcc, + toggleQuotedText, + }; +} 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..cabe14d2dcf --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/send-schedule.test.ts @@ -0,0 +1,158 @@ +import { SupportedNodeTypes } from '@macro-inc/lexical-core'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + createEditor, +} from 'lexical'; +import { createRoot } from 'solid-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { message } from '../../email-message/tests/messages'; +import type { + EmailComposeServices, + SavedEmailDraft, +} from '../context/compose-services'; +import { composeServices } from '../tests/services'; +import { createEmailComposer } from './email-composer'; +import { createEmailFormState } from './email-form-state'; +import { createReplyInput } from './reply-input'; + +function composer( + kind: 'standalone' | 'reply', + services: EmailComposeServices +) { + return createRoot((dispose) => { + const editor = createEditor({ + nodes: SupportedNodeTypes, + onError: (error) => { + throw error; + }, + }); + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Ready to send')) + ); + }, + { discrete: true } + ); + if (kind === 'standalone') { + const { context } = createEmailComposer({ + services, + initialTo: ['colleague@example.com'], + }); + context.captureEditor(editor); + context.setSubject('Review'); + context.onContentChange('Ready to send'); + return { + dispose, + send: context.onSend, + schedule: context.onSendTimeChange!, + }; + } + const parent = message('parent'); + const form = createEmailFormState( + { viewerEmail: services.viewerEmail, inboxes: services.accounts.inboxes }, + { type: 'replying_to', messageID: parent.db_id }, + { getMessageByID: () => parent, getDraftForMessageReply: () => undefined } + ); + const state = createReplyInput( + { + services, + sourceEntityId: 'thread', + replyingTo: () => parent, + session: { + thread: () => ({ + db_id: 'thread', + link_id: 'inbox', + inbox_visible: false, + }), + recipientOptions: () => [], + onRecipientsChange() {}, + drafts: { + getDraftForMessage: () => undefined, + deleteDraftForMessage() {}, + }, + messages: { + list: () => [parent], + unfiltered: () => [parent], + personalSenders: () => new Set(), + focusedID: () => parent.db_id, + setFocused() {}, + }, + replyRequest: { + messageId: () => undefined, + replyType: () => undefined, + clear() {}, + }, + getMarkDoneNavigationTargetId: () => undefined, + }, + }, + () => editor, + { container: () => undefined, footer: () => undefined }, + () => form + ); + return { + dispose, + send: () => state.sendEmail(), + schedule: state.handleSendTimeChange, + }; + }); +} + +describe.each(['standalone', 'reply'] as const)( + '%s send and schedule ordering', + (kind) => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('sends a valid message after the draft is saved', async () => { + const services = composeServices(); + const state = composer(kind, services); + try { + state.send(); + await vi.advanceTimersByTimeAsync(0); + expect(services.saveDraft).toHaveBeenCalledOnce(); + expect(services.sendMessage).toHaveBeenCalledOnce(); + } finally { + state.dispose(); + } + }); + + it('does not dispatch when scheduling starts during the pending draft save', async () => { + let finishSaving!: (value: { draft: SavedEmailDraft }) => void; + let finishScheduling!: () => void; + const services = composeServices({ + schedule: vi.fn( + () => + new Promise((resolve) => { + finishScheduling = resolve; + }) + ), + }); + vi.mocked(services.saveDraft).mockImplementationOnce( + () => + new Promise((resolve) => { + finishSaving = resolve; + }) + ); + const state = composer(kind, services); + try { + state.send(); + expect(services.saveDraft).toHaveBeenCalledOnce(); + const scheduling = state.schedule(new Date('2026-10-01T12:00:00Z')); + await vi.advanceTimersByTimeAsync(0); + expect(services.schedule).toHaveBeenCalledOnce(); + finishSaving({ + draft: { db_id: 'draft', thread_db_id: 'thread', link_id: 'inbox' }, + }); + await vi.advanceTimersByTimeAsync(0); + expect(services.sendMessage).not.toHaveBeenCalled(); + 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..c0c04e060dd --- /dev/null +++ b/apps/web/src/features/email-compose/queries/inbox-source.test.ts @@ -0,0 +1,72 @@ +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('keeps cached secondary-inbox reply semantics after a failed refresh and does not retain across accounts', () => + 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' }], + is_sent: true, + }); + const form = createEmailFormState( + { viewerEmail: owner, inboxes: source.inboxes }, + { type: 'replying_to', messageID: 'parent' }, + { + getMessageByID: () => parent, + getDraftForMessageReply: () => undefined, + } + ); + 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('pending'); + }); + 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..715c6171c7f --- /dev/null +++ b/apps/web/src/features/email-compose/queries/inbox-source.ts @@ -0,0 +1,44 @@ +import type { useEmailLinksQuery } from '@queries/email/link'; +import { type Accessor, createMemo } from 'solid-js'; +import type { EmailInbox } from '../context/compose-services'; + +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(); + if (!query.isSuccess && !query.isError) + 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/services.ts b/apps/web/src/features/email-compose/tests/services.ts new file mode 100644 index 00000000000..61f23c239ce --- /dev/null +++ b/apps/web/src/features/email-compose/tests/services.ts @@ -0,0 +1,62 @@ +import { vi } from 'vitest'; +import type { EmailComposeServices } from '../context/compose-services'; +/** Fake capabilities: no production modules or app providers are needed by a controller. */ +export function composeServices( + overrides: Partial = {} +): EmailComposeServices { + return { + viewerLoading: () => false, + onUpgrade: vi.fn(), + prepareSignatureLinks: vi.fn(), + readDroppedFiles: vi.fn(), + feedback: { + success: vi.fn(), + failure: vi.fn(), + alert: vi.fn(), + dismiss: vi.fn(), + }, + reportError: vi.fn(), + isTouch: () => false, + isMobile: () => false, + scheduleEnabled: true, + recipientName: (id) => id, + recordMention: vi.fn(), + makePublic: vi.fn(), + uploadEditorFiles: vi.fn(), + accounts: { + inboxes: () => [ + { id: 'inbox', email_address: 'me@example.com', settings: {} }, + ], + loading: () => false, + failed: () => false, + primaryId: () => 'inbox', + headerId: () => undefined, + }, + viewerEmail: () => 'me@example.com', + recipients: () => [], + signaturesEnabled: () => false, + hasPaidAccess: () => true, + saveDraft: vi.fn(async () => ({ + draft: { db_id: 'draft', thread_db_id: 'thread', link_id: 'inbox' }, + })), + deleteDraft: vi.fn(async () => {}), + sendMessage: vi.fn(async () => ({ + message: { db_id: 'sent', thread_db_id: 'thread', link_id: 'inbox' }, + })), + uploadAttachments: vi.fn(async () => {}), + addForwardedAttachments: vi.fn(async () => {}), + removeAttachment: vi.fn(async () => {}), + removeForwardedAttachment: vi.fn(async () => {}), + unschedule: vi.fn(async () => {}), + schedule: vi.fn(async () => {}), + archive: vi.fn(async () => {}), + markDraftSaved: vi.fn(), + prepareUndo: vi.fn(), + refreshAfterUndo: vi.fn(), + refreshThreadPreview: vi.fn(), + invalidatePreview: vi.fn(), + undoSend: vi.fn(async () => {}), + restoreDraft: vi.fn(async () => {}), + ...overrides, + }; +} diff --git a/apps/web/src/features/block-email/util/undoSend.ts b/apps/web/src/features/email-compose/undo-send.ts similarity index 84% rename from apps/web/src/features/block-email/util/undoSend.ts rename to apps/web/src/features/email-compose/undo-send.ts index 12fc4f6a228..2351d0d6dca 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,20 +21,6 @@ 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 @@ -41,10 +31,7 @@ export 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..52bc4c153e3 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 type { DraftFormAttachment } from '../primitives/email-form-state'; +import { addUserMentionToCc } from '../primitives/mention-to-cc'; +import { useCompose } from './compose-context'; 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) ); }, }} @@ -172,7 +121,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 +136,23 @@ 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) => { e.preventDefault(); - focusSibling('prev'); + ctx.bodyActions.focusSibling?.('prev'); }} onFocusLeaveEnd={(e) => { 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/email-compose/views/compose-context.ts b/apps/web/src/features/email-compose/views/compose-context.ts new file mode 100644 index 00000000000..75bb59d5cc3 --- /dev/null +++ b/apps/web/src/features/email-compose/views/compose-context.ts @@ -0,0 +1,21 @@ +import { createContext, useContext } from 'solid-js'; +import type { ComposeContextValue } from '../primitives/compose-view-state'; + +export type { + ComposeContextValue, + ComposeValidationError, + EmailFormRecipients, + RecipientFieldId, +} 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/block-email/component/compose/ComposeLayout.tsx b/apps/web/src/features/email-compose/views/compose-layout.tsx similarity index 94% 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..42a2f06e11f 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 { ComposeBody } from './compose-body'; +import { useCompose } from './compose-context'; +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: {
+ + + + + + + + ); +} diff --git a/apps/web/src/features/block-email/component/EmailFormContext.tsx b/apps/web/src/features/email-compose/views/email-form-context.tsx similarity index 64% rename from apps/web/src/features/block-email/component/EmailFormContext.tsx rename to apps/web/src/features/email-compose/views/email-form-context.tsx index b6085da3045..e694951122f 100644 --- a/apps/web/src/features/block-email/component/EmailFormContext.tsx +++ b/apps/web/src/features/email-compose/views/email-form-context.tsx @@ -1,14 +1,19 @@ import { createContext, type ParentProps, useContext } from 'solid-js'; +import type { EmailFormDependencies } from '../context/email-form-dependencies'; import { createEmailFormState, type EmailFormStateOptions, -} from './createEmailFormState'; +} from '../primitives/email-form-state'; -type EmailFormContextValue = ReturnType; +import type { + EmailFormContextValue, + FormAccessKey, +} from '../primitives/email-form-types'; -type FormAccessKey = - | { type: 'replying_to'; messageID: string; seed?: string } - | { type: 'draft'; messageID: string; seed?: string }; +export type { + EmailFormContextValue, + FormAccessKey, +} from '../primitives/email-form-types'; // `seed` identifies the draft version the form seeds from (see EmailInput's // seed key), so a composer remounting on a newer draft version gets a @@ -24,18 +29,25 @@ type RegistryApi = { const EmailFormRegistryCtx = createContext(); export function EmailFormContextProvider( - props: ParentProps<{ formOptions: EmailFormStateOptions }> + props: ParentProps<{ + dependencies: EmailFormDependencies; + formOptions: EmailFormStateOptions; + }> ) { const map = new Map(); const getOrInit: RegistryApi['getOrInit'] = (key) => { if (!key) { - return createEmailFormState(); + return createEmailFormState(props.dependencies); } const stringifiedKey = stringifyKey(key); let existing = map.get(stringifiedKey); if (!existing) { - existing = createEmailFormState(key, props.formOptions); + existing = createEmailFormState( + props.dependencies, + key, + props.formOptions + ); map.set(stringifiedKey, existing); } return existing; @@ -48,16 +60,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/views/reply-input.tsx b/apps/web/src/features/email-compose/views/reply-input.tsx new file mode 100644 index 00000000000..907112283c0 --- /dev/null +++ b/apps/web/src/features/email-compose/views/reply-input.tsx @@ -0,0 +1,960 @@ +import type { EmailRecipient } from '@app/features/email-compose/core/email-recipient'; +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 type { UserMentionRecord } from '@core/component/LexicalMarkdown/utils/mentionsUtils'; +import { RecipientSelector } from '@core/component/RecipientSelector'; +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 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 { isIOS } from '@solid-primitives/platform'; +import { Button, cn, Layer, SendButton, Surface, Tooltip } from '@ui'; +import type { LexicalEditor } from 'lexical'; +import { $getRoot } from 'lexical'; +import { + type Accessor, + createSignal, + For, + Match, + onMount, + Show, + Switch, +} from 'solid-js'; +import { EmailDateSelector } from '../components/email-date-selector'; +import { FromInboxSelector } from '../components/from-inbox-selector'; +import { MacroSignatureButton } from '../components/macro-signature-button'; +import { SignaturePreview } from '../components/signature-preview'; +import { registerToggleAppendedThread } from '../primitives/prepare-email-body'; +import { getOrInitEmailFormContext } from './email-form-context'; + +false && fileFolderDrop; +false && fileSelector; + +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; +} + +import { RecipientDropRow } from '../components/recipient-drop-row'; +import { + createReplyInput, + type ReplyInputProps, +} from '../primitives/reply-input'; +export function ReplyInputView(props: ReplyInputProps) { + let composeContainerRef: HTMLDivElement | undefined; + let bottomBarRef: HTMLDivElement | undefined; + const [editor, setEditor] = createSignal(); + const state = createReplyInput( + props, + editor, + { container: () => composeContainerRef, footer: () => bottomBarRef }, + getOrInitEmailFormContext + ); + const { + ctx, + form, + sourceEntityId, + services, + activeLinkId, + activeInboxEmail, + setIncludeSignature, + setScrollContainer, + composerExpanded, + setComposerExpanded, + quoteCollapsed, + setQuoteCollapsed, + showExpandedRecipients, + setShowExpandedRecipients, + isDragging, + setIsDragging, + setToRef, + ccRef, + setCcRef, + bccRef, + setBccRef, + showCc, + setShowCc, + showBcc, + setShowBcc, + recipientDragState, + savedDraftId, + initialHtml, + handleEditorConnect, + sendMutation, + uploadAttachmentMutation, + collectDraft, + scheduleDraftSave, + persistDraftOnSenderSwitch, + handleChipDragStart, + handleChipDragEnd, + handleRecipientDrop, + withDraftSave, + hasPaidAccess, + sendEmail, + deleteDraftAndReset, + handleAddAttachments, + handleRemoveAttachment, + handleSendTimeChange, + isMobileDrawer, + composePortalScope, + sendActionHidden, + sendActionDisabled, + scheduleSendDisabled, + scrollAreaSignatureHtml, + footerSignatureHtml, + replyingToSummary, + mobileDrawerCcBccOpen, + toggleMobileDrawerCcBcc, + toggleQuotedText, + } = state; + const editorConfig = createConfiguredEmailMarkdownEditor(state.editorOptions); + 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: () => { + 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; + }, + runWithInputFocused: true, + hotkeyToken: TOKENS.email.cancelReply, + displayPriority: 8, + }); + } + }); + + 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={ + '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' + } + 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={ + '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' + } + 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={ + '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' + } + 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; + services.uploadEditorFiles({ + editor: editor_, + sourceId: sourceEntityId, + files: fileEntries, + directories: folderEntries, + dropEvent: e, + onUploaded: (ids) => { + setIsDragging(false); + ids.forEach(services.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..6b2b5471f3f --- /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).find( + (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/block-email/component/AttachmentPill.tsx b/apps/web/src/features/email-message/components/attachment-pill.tsx similarity index 95% 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..fe51ab374e3 100644 --- a/apps/web/src/features/block-email/component/AttachmentPill.tsx +++ b/apps/web/src/features/email-message/components/attachment-pill.tsx @@ -12,7 +12,7 @@ type EmailAttachmentPillProps = { attachment: { fileName: string; mimeType?: string }; removable?: boolean; onRemove?: () => void; - onClick?: (fileType?: FileType) => void; + onClick?: () => void; }; export function EmailAttachmentPill(props: EmailAttachmentPillProps) { @@ -30,7 +30,7 @@ export function EmailAttachmentPill(props: EmailAttachmentPillProps) { classList={{ 'pl-2': props.removable, }} - onClick={() => props.onClick?.(fileType())} + onClick={() => props.onClick?.()} > props.currentUserEmail; const senderDisplay = createMemo(() => getSenderDisplayName(props.message, currentUserEmail()) 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..b3ee1f940bc --- /dev/null +++ b/apps/web/src/features/email-message/context/email-rendering-context.tsx @@ -0,0 +1,34 @@ +import { type Accessor, createContext, useContext } from 'solid-js'; +import type { EmailAttachment } from '../core/email-message'; + +export interface EmailRenderingTheme { + inkL: number; + inkC: number; + inkH: number; + panelL: number; + accentL: number; + accentC: number; + accentH: number; +} + +/** Rendering capabilities shared by email surfaces; no thread or block state. */ +export interface EmailRenderingDependencies { + theme: Accessor; + prepareLinks?: (container: HTMLElement) => void; + resolveImages( + root: ShadowRoot, + attachments: EmailAttachment[], + blobUrls: string[], + isDisposed: () => boolean + ): Promise; +} + +const EmailRenderingContext = createContext(); +export const EmailRenderingProvider = EmailRenderingContext.Provider; + +export function useEmailRendering(): EmailRenderingDependencies { + const value = useContext(EmailRenderingContext); + if (!value) + throw new Error('Email rendering requires an EmailRenderingProvider'); + return value; +} diff --git a/apps/web/src/features/block-email/util/emailBodyContainmentCss.ts b/apps/web/src/features/email-message/core/email-body-containment-css.ts similarity index 100% rename from apps/web/src/features/block-email/util/emailBodyContainmentCss.ts rename to apps/web/src/features/email-message/core/email-body-containment-css.ts 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..56aade79d24 --- /dev/null +++ b/apps/web/src/features/email-message/core/email-message.ts @@ -0,0 +1,87 @@ +/** 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; + data_url?: string | null; + db_id: string; + filename?: string | null; + mime_type?: string | null; + provider_id?: string | null; + sfs_id?: string | null; + size_bytes?: number | null; +} + +export interface EmailDraftAttachment { + content_type: string; + draft_id: string; + file_name: string; + id: string; + s3_key: string; + sha: string; + size: number; +} + +export interface EmailForwardedAttachment { + attachment_id: string; + draft_id: string; + filename?: string | null; + message_provider_id: string; + mime_type?: string | null; + provider_attachment_id?: string | null; + size_bytes?: number | null; +} + +export interface EmailLabel { + created_at: string; + id?: string | null; + label_list_visibility?: + | null + | ('LabelShow' | 'LabelShowIfUnread' | 'LabelHide'); + link_id: string; + message_list_visibility?: null | ('Show' | 'Hide'); + name?: string | null; + provider_label_id: string; + type_?: null | ('System' | 'User'); +} + +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; + global_id?: string | null; + has_attachments: boolean; + headers_json?: unknown; + internal_date_ts?: string | null; + is_draft: boolean; + is_read: boolean; + is_sent: boolean; + is_starred: boolean; + labels: EmailLabel[]; + link_id: string; + provider_history_id?: string | null; + provider_id?: string | null; + provider_thread_id?: string | null; + replying_to_id?: string | null; + scheduled_send_time?: string | null; + sent_at?: string | null; + size_estimate?: number | 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/fitToWidthZoom.test.ts b/apps/web/src/features/email-message/core/fit-to-width-zoom.test.ts similarity index 93% rename from apps/web/src/features/block-email/util/fitToWidthZoom.test.ts rename to apps/web/src/features/email-message/core/fit-to-width-zoom.test.ts index edd1b4b0b1e..1a1e316a934 100644 --- a/apps/web/src/features/block-email/util/fitToWidthZoom.test.ts +++ b/apps/web/src/features/email-message/core/fit-to-width-zoom.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { FIT_TO_WIDTH_ZOOM_FLOOR, fitToWidthZoom } from './fitToWidthZoom'; +import { FIT_TO_WIDTH_ZOOM_FLOOR, fitToWidthZoom } from './fit-to-width-zoom'; describe('fitToWidthZoom', () => { it('does not zoom when content fits', () => { diff --git a/apps/web/src/features/block-email/util/fitToWidthZoom.ts b/apps/web/src/features/email-message/core/fit-to-width-zoom.ts similarity index 100% rename from apps/web/src/features/block-email/util/fitToWidthZoom.ts rename to apps/web/src/features/email-message/core/fit-to-width-zoom.ts 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/email-message.tsx b/apps/web/src/features/email-message/email-message.tsx new file mode 100644 index 00000000000..d770e871782 --- /dev/null +++ b/apps/web/src/features/email-message/email-message.tsx @@ -0,0 +1,16 @@ +import { EmailRenderingProvider } from './context/email-rendering-context'; +import { createEmailRenderingDependencies } from './rendering-adapter'; +import { + EmailMessageView, + type EmailMessageViewProps, +} from './views/email-message'; + +/** Production composition for rendering a single email on any surface. */ +export function EmailMessage(props: EmailMessageViewProps) { + const dependencies = createEmailRenderingDependencies(); + return ( + + + + ); +} 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..e5b88e40020 --- /dev/null +++ b/apps/web/src/features/email-message/primitives/email-message-body.test.ts @@ -0,0 +1,89 @@ +import { createRoot, createSignal } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { message } from '../tests/messages'; +import { createEmailMessageBody } from './email-message-body'; + +const theme = { + inkL: 0.2, + inkC: 0, + inkH: 0, + panelL: 1, + accentL: 0.6, + accentC: 0.1, + accentH: 50, +}; +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 cancelFrame = vi.fn(); + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 1) + ); + vi.stubGlobal('cancelAnimationFrame', cancelFrame); + const resolveImages = vi.fn( + async (_root, _attachments, urls: string[], disposed: () => boolean) => { + if (!disposed()) urls.push('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(); + }, + isPersonal: true, + isBodyExpanded: () => true, + setExpandedMessageBody() {}, + setFocusedMessageId() {}, + isFocused: false, + }, + { 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(); + root.setValue( + message('two', { body_replyless: '

Second message

' }) + ); + await Promise.resolve(); + expect(root.body.host()).not.toBe(host); + } finally { + root.dispose(); + } + expect(resolveImages).toHaveBeenCalled(); + expect(revoke).toHaveBeenCalledWith('blob:email'); + expect(disconnect).toHaveBeenCalled(); + expect(cancelFrame).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/features/block-email/component/EmailMessageBody.tsx b/apps/web/src/features/email-message/primitives/email-message-body.ts similarity index 67% rename from apps/web/src/features/block-email/component/EmailMessageBody.tsx rename to apps/web/src/features/email-message/primitives/email-message-body.ts index fecde97d9ac..b078cb91d41 100644 --- a/apps/web/src/features/block-email/component/EmailMessageBody.tsx +++ b/apps/web/src/features/email-message/primitives/email-message-body.ts @@ -1,59 +1,31 @@ -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 { EmailMessage } from '@app/features/email-message/core/email-message'; +import { parseEmailContent, processEmailColors } from '@core/email'; 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>; +import type { EmailRenderingDependencies } from '../context/email-rendering-context'; +import { EMAIL_BODY_CONTAINMENT_CSS } from '../core/email-body-containment-css'; +import { fitToWidthZoom } from '../core/fit-to-width-zoom'; +export interface EmailMessageBodyProps { + message: EmailMessage; + isPersonal: boolean; isBodyExpanded: Accessor; setExpandedMessageBody: (id: string) => void; setFocusedMessageId: (messageID: string | undefined) => void; - isFirstMessageInThread: boolean; + showFullContent?: boolean; isFocused: boolean; } -export function EmailMessageBody(props: EmailMessageBodyProps) { +export function createEmailMessageBody( + props: EmailMessageBodyProps, + dependencies: EmailRenderingDependencies +) { 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 ?? ''; @@ -97,7 +69,7 @@ export function EmailMessageBody(props: EmailMessageBodyProps) { }); const source = () => { - return showFullHTML() || props.isFirstMessageInThread + return showFullHTML() || props.showFullContent ? parsedBodyHtml() : parsedBodyReplyless(); }; @@ -125,10 +97,7 @@ export function EmailMessageBody(props: EmailMessageBodyProps) { ); }; - // 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 isPersonal = () => props.isPersonal; const isMacroSender = createMemo(() => { const senderEmail = props.message.from?.email?.toLowerCase(); @@ -136,7 +105,7 @@ export function EmailMessageBody(props: EmailMessageBodyProps) { }); const host = createMemo(() => { - themeUpdate(); + dependencies.theme(); const hostContainer = document.createElement('div'); const shadow = hostContainer.attachShadow({ mode: 'open' }); // Style that uses a CSS variable to control image visibility @@ -169,7 +138,7 @@ export function EmailMessageBody(props: EmailMessageBodyProps) { a.setAttribute('rel', 'noopener noreferrer'); } // Raw mailto: anchors open the in-app composer instead of the OS mail client - interceptMailtoLinks(messageDiv); + dependencies.prepareLinks?.(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 @@ -196,30 +165,25 @@ export function EmailMessageBody(props: EmailMessageBodyProps) { queueMicrotask(async () => { if (disposed) return; - resolveCidImages(root, attachments); - if (disposed) return; - await fetchImagesViaPlatform(root, blobUrls, () => disposed); + await dependencies.resolveImages( + root, + attachments, + blobUrls, + () => disposed + ); }); }); // Process the email colors when: the theme changes, or the source HTML changes. createEffect(() => { - themeUpdate(); + dependencies.theme(); 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](), - }; + const theme = dependencies.theme(); processEmailColors(root, theme); }); }); @@ -316,9 +280,10 @@ export function EmailMessageBody(props: EmailMessageBodyProps) { } // Initial measurement after layout - requestAnimationFrame(() => applyScale()); + const measureFrame = requestAnimationFrame(() => applyScale()); onCleanup(() => { + cancelAnimationFrame(measureFrame); resizeObserver.disconnect(); for (const img of images) { img.removeEventListener('load', onImageLoad); @@ -326,63 +291,12 @@ export function EmailMessageBody(props: EmailMessageBodyProps) { }); }); - 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()} - - -
- -
-
-
-
- ); + return { + isPersonal, + showFullHTML, + setShowFullHTML, + isPlaintext, + host, + hasHiddenReplyStructure, + }; } 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..9bd37a32563 --- /dev/null +++ b/apps/web/src/features/email-message/rendering-adapter.ts @@ -0,0 +1,31 @@ +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 { EmailRenderingDependencies } from './context/email-rendering-context'; +import { fetchImagesViaPlatform, resolveCidImages } from './image-adapter'; + +export function createEmailRenderingDependencies(): EmailRenderingDependencies { + 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, + prepareLinks: interceptMailtoLinks, + async resolveImages(root, attachments, blobUrls, isDisposed) { + if (isDisposed()) return; + resolveCidImages(root, attachments); + if (isDisposed()) return; + await fetchImagesViaPlatform(root, blobUrls, isDisposed); + }, + }; +} 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..18ce7ef9b9f --- /dev/null +++ b/apps/web/src/features/email-message/tests/messages.ts @@ -0,0 +1,30 @@ +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_read: true, + is_draft: false, + is_sent: false, + is_starred: false, + has_attachments: 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..d423b94c1e4 --- /dev/null +++ b/apps/web/src/features/email-message/views/email-message-body.tsx @@ -0,0 +1,80 @@ +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 { useEmailRendering } from '../context/email-rendering-context'; +import { + createEmailMessageBody, + type EmailMessageBodyProps, +} from '../primitives/email-message-body'; +export function EmailMessageBody(props: EmailMessageBodyProps) { + const { + isPersonal, + showFullHTML, + setShowFullHTML, + isPlaintext, + host, + hasHiddenReplyStructure, + } = createEmailMessageBody(props, useEmailRendering()); + 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..34440e7de07 --- /dev/null +++ b/apps/web/src/features/email-message/views/email-message.tsx @@ -0,0 +1,242 @@ +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 { getSenderMacroId } from '@app/features/email-message/core/email-user'; +import { EmailMessageBody } from '@app/features/email-message/views/email-message-body'; +import { ImageGalleryPreview } from '@core/component/ImageGalleryPreview'; +import { UserIcon, type UserIconProps } from '@core/component/UserIcon'; +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; + 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 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 draftAttachments = createMemo(() => { + return props.message.attachments_draft ?? []; + }); + + const forwardedAttachments = createMemo(() => { + return props.message.attachments_forwarded ?? []; + }); + + 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.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 || forwardedAttachments().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 ( - + - ); + return { + showMiddleMessages, + keyboardSelecting, + armKeyboardPointer, + leaveHiddenChip, + setUserOpenedMiddle, + emailReplyInfo, + replyInputInFlow, + mobileBottomReplyMessage, + setMarkdownDomRef: (el: HTMLDivElement) => { + markdownDomRef = el; + }, + }; } diff --git a/apps/web/src/features/email-thread/primitives/thread-snapshot.test.ts b/apps/web/src/features/email-thread/primitives/thread-snapshot.test.ts new file mode 100644 index 00000000000..3a801e4a0e0 --- /dev/null +++ b/apps/web/src/features/email-thread/primitives/thread-snapshot.test.ts @@ -0,0 +1,40 @@ +import { batch, createRoot, createSignal } from 'solid-js'; +import { expect, it } from 'vitest'; +import type { EmailThread } from '../core/email-thread'; +import { message, thread } from '../tests/fixtures'; +import { createThreadSnapshot } from './thread-snapshot'; + +it('retains readable data during failure, clears on identity change, and respects a successful empty result', () => + createRoot((dispose) => { + try { + const [id, setId] = createSignal('thread'); + const [error, setError] = createSignal(false); + const [data, setData] = createSignal( + thread([message('one')]) + ); + const snapshot = createThreadSnapshot({ + id, + thread: data, + isError: error, + isLoading: () => false, + }); + const original = snapshot(); + batch(() => { + setError(true); + setData(undefined); + }); + expect(snapshot()).toBe(original); + setId('different'); + expect(snapshot()).toBeUndefined(); + batch(() => { + setId('thread'); + setError(false); + setData(original); + }); + expect(snapshot()).toBe(original); + setData(undefined); + expect(snapshot()).toBeUndefined(); + } finally { + dispose(); + } + })); diff --git a/apps/web/src/features/email-thread/primitives/thread-snapshot.ts b/apps/web/src/features/email-thread/primitives/thread-snapshot.ts new file mode 100644 index 00000000000..c03e6e4e34c --- /dev/null +++ b/apps/web/src/features/email-thread/primitives/thread-snapshot.ts @@ -0,0 +1,22 @@ +import { createMemo } from 'solid-js'; +import type { EmailThreadSource } from '../context/email-thread-dependencies'; +import type { EmailThread } from '../core/email-thread'; + +/** Keep the current thread readable during transient reloads; never show a previous thread. */ +export function createThreadSnapshot( + source: Pick +) { + const snapshot = createMemo<{ id: string; thread?: EmailThread }>( + (previous) => { + const id = source.id(); + const current = source.thread(); + if (current?.db_id === id) return { id, thread: current }; + const canRetain = source.isLoading() || source.isError(); + return { + id, + thread: canRetain && previous?.id === id ? previous.thread : undefined, + }; + } + ); + return () => snapshot().thread; +} diff --git a/apps/web/src/features/email-thread/queries/thread-source.test.ts b/apps/web/src/features/email-thread/queries/thread-source.test.ts new file mode 100644 index 00000000000..09d3e996ab8 --- /dev/null +++ b/apps/web/src/features/email-thread/queries/thread-source.test.ts @@ -0,0 +1,96 @@ +import type { ThreadQueryData, ThreadQueryResult } from '@queries/email/thread'; +import { batch, createRoot, createSignal } from 'solid-js'; +import { describe, expect, it, vi } from 'vitest'; +import { message, thread } from '../tests/fixtures'; +import { createEmailThreadSource, decodeThread } from './thread-source'; + +describe('thread query adaptation', () => { + it('guards resource reads, retains available data during refresh errors, and clears it when switching threads', () => + createRoot((dispose) => { + try { + const [id, setId] = createSignal('thread'); + const [status, setStatus] = createSignal('pending'); + const [data, setData] = createSignal(); + const fetchNextPage = vi.fn(); + const refetch = vi.fn(); + const read = vi.fn(() => { + if (status() === 'pending') + throw new Error('suspending resource read'); + return data(); + }); + const query = { + get isError() { + return status() === 'error'; + }, + get isSuccess() { + return status() === 'success'; + }, + get isLoading() { + return status() === 'pending'; + }, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: true, + get data() { + return read(); + }, + fetchNextPage, + refetch, + } as unknown as ThreadQueryResult; + const source = createEmailThreadSource(id, query); + expect(source.thread()).toBeUndefined(); + expect(read).not.toHaveBeenCalled(); + const original = thread([message('first')]); + batch(() => { + setData({ thread: original } as ThreadQueryData); + setStatus('success'); + }); + expect(source.thread()?.messages[0].db_id).toBe('first'); + expect(source.thread()).not.toBe(original); + setStatus('error'); + expect(source.thread()?.db_id).toBe('thread'); + source.fetchOlder(); + source.refresh(); + expect(fetchNextPage).toHaveBeenCalledOnce(); + expect(refetch).toHaveBeenCalledOnce(); + setId('other'); + expect(source.thread()).toBeUndefined(); + batch(() => { + setData(undefined); + setStatus('success'); + }); + expect(source.thread()).toBeUndefined(); + } finally { + dispose(); + } + })); + it('owns nested message data while preserving missing versus empty HTML', () => { + const wire = thread([ + message('one', { body_replyless: '' }), + message('two', { body_replyless: null }), + ]); + const decoded = decodeThread(wire); + expect(decoded.messages.map((m) => m.body_replyless)).toEqual(['', null]); + decoded.messages[0].to[0].email = 'changed@example.com'; + expect(wire.messages[0].to[0].email).toBe('viewer@example.com'); + }); +}); + +it('exposes cached data when the first observed query result is an error', () => + createRoot((dispose) => { + try { + const cached = thread([message('cached')]); + const query = { + isError: true, + isSuccess: false, + isLoading: false, + data: { thread: cached, hasMore: false }, + } as ThreadQueryResult; + expect( + createEmailThreadSource(() => 'thread', query).thread()?.messages[0] + .db_id + ).toBe('cached'); + } finally { + dispose(); + } + })); diff --git a/apps/web/src/features/email-thread/queries/thread-source.ts b/apps/web/src/features/email-thread/queries/thread-source.ts new file mode 100644 index 00000000000..30e012cd551 --- /dev/null +++ b/apps/web/src/features/email-thread/queries/thread-source.ts @@ -0,0 +1,54 @@ +import type { ThreadQueryData, ThreadQueryResult } from '@queries/email/thread'; +import type { ApiThread } from '@service-email/generated/schemas'; +import { type Accessor, createMemo } from 'solid-js'; +import type { EmailThreadSource } from '../context/email-thread-dependencies'; +import type { EmailThread } from '../core/email-thread'; + +/** Decoding is the only entry for transport values into thread state. */ +export function decodeThread(thread: ApiThread): EmailThread { + return { + ...thread, + messages: thread.messages.map((message) => ({ + ...message, + to: message.to.map((contact) => ({ ...contact })), + cc: message.cc.map((contact) => ({ ...contact })), + bcc: message.bcc.map((contact) => ({ ...contact })), + from: message.from ? { ...message.from } : message.from, + labels: message.labels.map((label) => ({ ...label })), + attachments: message.attachments.map((attachment) => ({ ...attachment })), + attachments_draft: message.attachments_draft.map((attachment) => ({ + ...attachment, + })), + attachments_forwarded: message.attachments_forwarded.map( + (attachment) => ({ ...attachment }) + ), + })), + }; +} + +export function createEmailThreadSource( + threadId: Accessor, + query: ThreadQueryResult +): EmailThreadSource { + // Status guards prevent a pending Solid resource from suspending its owner. + const thread = createMemo(() => { + if (!query.isSuccess && !query.isError) return undefined; + const data = query.data?.thread; + return data?.db_id === threadId() ? decodeThread(data) : undefined; + }); + return { + id: threadId, + thread, + isError: () => query.isError, + isLoading: () => query.isLoading, + isFetching: () => query.isFetching, + isFetchingOlder: () => query.isFetchingNextPage, + hasMore: () => query.hasNextPage, + fetchOlder: async () => { + await query.fetchNextPage(); + }, + refresh: async () => { + await query.refetch(); + }, + }; +} diff --git a/apps/web/src/features/email-thread/tests/architecture.test.ts b/apps/web/src/features/email-thread/tests/architecture.test.ts new file mode 100644 index 00000000000..338dc1aec88 --- /dev/null +++ b/apps/web/src/features/email-thread/tests/architecture.test.ts @@ -0,0 +1,184 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const web = process.cwd(); +const features = resolve(web, 'src/features'); +const names = ['email-message', 'email-compose', 'email-thread']; +const configPath = resolve(web, 'tsconfig.json'); +const config = ts.readConfigFile(configPath, ts.sys.readFile); +const options = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + web +).options; +const cache = ts.createModuleResolutionCache(web, (path) => path, options); + +function files(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) return entry.name === 'tests' ? [] : files(path); + return /\.tsx?$/.test(path) && !/\.(test|spec)\.tsx?$/.test(path) + ? [path] + : []; + }); +} + +function imports(file: string) { + const source = ts.createSourceFile( + file, + readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true + ); + const result: { name: string; typeOnly: boolean; target?: string }[] = []; + const add = (name: string, typeOnly: boolean) => + result.push({ + name, + typeOnly, + target: ts.resolveModuleName(name, file, options, ts.sys, cache) + .resolvedModule?.resolvedFileName, + }); + const visit = (node: ts.Node) => { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) + ) { + const clause = node.importClause; + const named = clause?.namedBindings; + const typeOnly = + !!clause?.isTypeOnly || + (!clause?.name && + !!named && + ts.isNamedImports(named) && + named.elements.every((element) => element.isTypeOnly)); + add(node.moduleSpecifier.text, typeOnly); + } else if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + add(node.moduleSpecifier.text, node.isTypeOnly); + } else 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)) add(argument.text, false); + } + ts.forEachChild(node, visit); + }; + visit(source); + return result; +} + +const production = names.flatMap((name) => files(resolve(features, name))); +describe('email ownership boundaries', () => { + it('keeps block dependencies out and dependencies flowing from thread to compose/message', () => { + const violations: string[] = []; + for (const file of production) { + const [owner, layer] = relative(features, file).split('/'); + for (const entry of imports(file)) { + const target = entry.target + ? relative(features, entry.target) + : entry.name; + const [targetOwner, targetLayer] = target.split('/'); + const report = () => + violations.push(`${relative(web, file)} -> ${entry.name}`); + if ( + /^@block-|^@core\/block|^@core\/signal\/(block|load)/.test( + entry.name + ) || + /(^|\/)block-[^/]+\//.test(target) + ) + report(); + if ( + owner === 'email-message' && + ['email-thread', 'email-compose'].includes(targetOwner) + ) + report(); + if (owner === 'email-compose' && targetOwner === 'email-thread') + report(); + if (names.includes(targetOwner)) { + if (layer === 'core' && targetLayer !== 'core') report(); + if ( + [ + 'core', + 'context', + 'primitives', + 'components', + 'views', + 'queries', + ].includes(layer) && + !targetLayer.includes('/') && + ![ + 'core', + 'context', + 'primitives', + 'components', + 'views', + 'queries', + ].includes(targetLayer) + ) + report(); + if ( + ['primitives', 'queries'].includes(layer) && + ['views', 'components'].includes(targetLayer) + ) + report(); + if ( + layer === 'components' && + !entry.typeOnly && + ['primitives', 'queries', 'views', 'context'].includes(targetLayer) + ) + report(); + } + } + } + expect(violations).toEqual([]); + }); + + it('can import controllers and contracts without transitively loading production app services or rendering', () => { + const visited = new Set(); + const violations: string[] = []; + const visit = (file: string, trail: string[]) => { + if (visited.has(file)) return; + visited.add(file); + for (const entry of imports(file)) { + if ( + entry.typeOnly || + !entry.target || + !entry.target.startsWith(resolve(web, 'src')) + ) + continue; + const path = relative(web, entry.target); + if ( + /src\/(components\/app|lib\/(queries|service-clients|urql-solid))\//.test( + path + ) || + /\/features\/block-/.test(path) || + /\/core\/(context\/user|signal\/(block|load))/.test(path) + ) { + violations.push([...trail, relative(web, file), path].join(' -> ')); + continue; + } + // Shared pure DOM/editor helpers are allowed; application UI imports are not. + if ( + entry.target.endsWith('.tsx') && + path.includes('/core/component/') + ) { + violations.push([...trail, relative(web, file), path].join(' -> ')); + continue; + } + visit(entry.target, [...trail, relative(web, file)]); + } + }; + for (const file of production.filter((file) => + /\/(primitives|context)\//.test(file) + )) + visit(file, []); + expect(violations).toEqual([]); + }); +}); diff --git a/apps/web/src/features/email-thread/tests/fixtures.ts b/apps/web/src/features/email-thread/tests/fixtures.ts new file mode 100644 index 00000000000..23aa3b12517 --- /dev/null +++ b/apps/web/src/features/email-thread/tests/fixtures.ts @@ -0,0 +1,56 @@ +import type { EmailMessage } from '../../email-message/core/email-message'; +import type { + EmailThreadDependencies, + EmailThreadSource, +} from '../context/email-thread-dependencies'; +import type { EmailThread } from '../core/email-thread'; + +export { message } from '../../email-message/tests/messages'; + +export function thread( + messages: EmailMessage[], + overrides: Partial = {} +): EmailThread { + return { + db_id: 'thread', + link_id: 'inbox', + access_level: 'owner', + inbox_visible: true, + is_read: true, + created_at: '2026-09-01T10:00:00Z', + updated_at: '2026-09-01T10:00:00Z', + messages, + ...overrides, + }; +} + +export function dependencies( + source: Omit & + Partial> +): EmailThreadDependencies { + return { + source: { + id: () => source.thread()?.db_id ?? 'thread', + isError: () => false, + ...source, + }, + recipients: () => [], + viewerEmail: () => 'viewer@example.com', + viewerLoading: () => false, + isTouch: () => false, + isMobile: () => false, + commands: { + archiveThread: () => false, + isThreadDone: () => false, + canMarkThreadNotDone: () => false, + markThreadNotDone: () => false, + isThreadMarkedUnread: () => false, + markThreadUnread: () => false, + markThreadRead: () => false, + getMarkDoneNavigationTargetId: () => undefined, + blockSender: () => false, + markSenderSignal: () => false, + markSenderNoise: () => false, + }, + }; +} diff --git a/apps/web/src/features/email-thread/thread-action-adapter.tsx b/apps/web/src/features/email-thread/thread-action-adapter.tsx new file mode 100644 index 00000000000..5e889d4708b --- /dev/null +++ b/apps/web/src/features/email-thread/thread-action-adapter.tsx @@ -0,0 +1,461 @@ +import type { EmailThread } from '@app/features/email-thread/core/email-thread'; +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 { useGlobalNotificationSource } from '@components/app/GlobalAppState'; +import { useSplitPanel } from '@components/app/split-layout/layoutUtils'; +import { toast } from '@core/component/Toast/Toast'; +import { useEmail, useUserId } from '@core/context/user'; +import { compositeEntity, setDoneOverride } from '@notifications'; +import ArrowCounterClockwise from '@phosphor-icons/core/regular/arrow-counter-clockwise.svg?component-solid'; +import { useNonPrimaryEmailLinkIdHeader } from '@queries/email/link'; +import { + blockSenderWithToast, + markSenderNoiseWithToast, + markSenderSignalWithToast, + trackExternalThreadArchive, + useMarkThreadAsSeenMutation, + useMarkThreadAsUnreadMutation, + 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 Accessor, createEffect, createSignal } from 'solid-js'; + +import type { + ArchiveThreadOptions, + EmailThreadCommands, +} from './context/email-thread-dependencies'; +export function createThreadActionAdapter( + threadId: Accessor, + threadSource: Accessor +): EmailThreadCommands { + const notificationSource = useGlobalNotificationSource(); + 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 = threadSource(); + 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 = threadSource(); + 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 = threadSource(); + 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 = threadSource(); + // `=== 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 threadId(); + setThreadMarkedUnread(false); + }); + + const markThreadUnread = () => { + const thread = threadSource(); + 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 = threadSource(); + 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 = threadSource(); + 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 = threadSource(); + 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(threadSource()?.link_id) + ); + return true; + }; + + const markSenderNoise = () => { + const senderEmail = getSenderEmail(); + if (!senderEmail) return false; + markSenderNoiseWithToast( + senderEmail, + toHeaderLinkId(threadSource()?.link_id) + ); + return true; + }; + + return { + archiveThread, + isThreadDone, + canMarkThreadNotDone, + markThreadNotDone, + markThreadUnread, + markThreadRead, + getMarkDoneNavigationTargetId, + blockSender, + markSenderSignal, + markSenderNoise, + isThreadMarkedUnread: threadMarkedUnread, + }; +} diff --git a/apps/web/src/features/block-email/component/BottomReplyButtons.tsx b/apps/web/src/features/email-thread/views/bottom-reply-buttons.tsx similarity index 84% rename from apps/web/src/features/block-email/component/BottomReplyButtons.tsx rename to apps/web/src/features/email-thread/views/bottom-reply-buttons.tsx index 6361406c98f..f896e02b5bb 100644 --- a/apps/web/src/features/block-email/component/BottomReplyButtons.tsx +++ b/apps/web/src/features/email-thread/views/bottom-reply-buttons.tsx @@ -1,19 +1,18 @@ +import type { ReplyType } from '@app/features/email-compose/core/reply-type'; +import type { EmailMessage } from '@app/features/email-message/core/email-message'; import { FloatRegionOrInline } from '@components/app/mobile/float-regions/FloatRegion'; import { inboxIconProps } from '@core/component/inboxIcon'; import { UserIcon } from '@core/component/UserIcon'; -import { useEmail } from '@core/context/user'; -import { isTouchDevice } from '@core/mobile/isTouchDevice'; import ArrowBendUpLeft from '@phosphor/arrow-bend-up-left.svg'; import ArrowBendUpRight from '@phosphor/arrow-bend-up-right.svg'; import CheckIcon from '@phosphor/check.svg'; import CheckBoldIcon from '@phosphor-icons/core/bold/check-bold.svg?component-solid'; -import type { ApiMessage } from '@service-email/generated/schemas'; import { createCallback } from '@solid-primitives/rootless'; import { Button, cn } from '@ui'; import { type Component, Show } from 'solid-js'; -import type { ReplyType } from '../util/replyType'; -import { useEmailContext } from './EmailContext'; -import { openEmailReplyComposerForMessage } from './emailReplyActions'; +import { openEmailReplyComposerForMessage } from '../primitives/reply-actions'; +import { useEmailContext } from './email-thread-context'; +import { useEmailThreadEnvironment } from './thread-environment'; function ReplyActionButton(props: { icon: Component<{ class?: string }>; @@ -21,13 +20,14 @@ function ReplyActionButton(props: { ariaLabel?: string; onClick: () => void; }) { + const environment = useEmailThreadEnvironment(); return ( ; + }, +})); + +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 deps = dependencies({ + thread: () => thread([first, second]), + isLoading: () => false, + isFetching: () => false, + isFetchingOlder: () => false, + hasMore: () => false, + fetchOlder() {}, + refresh() {}, + }); + const [target, setTarget] = createSignal(first); + const view = render(() => { + const state = createEmailThreadState(deps); + return ( + + + + + + ); + }); + 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(); + } +}); diff --git a/apps/web/src/features/block-email/component/EmailInput.tsx b/apps/web/src/features/email-thread/views/email-input.tsx similarity index 67% rename from apps/web/src/features/block-email/component/EmailInput.tsx rename to apps/web/src/features/email-thread/views/email-input.tsx index c2b2436018b..0a440fec7ec 100644 --- a/apps/web/src/features/block-email/component/EmailInput.tsx +++ b/apps/web/src/features/email-thread/views/email-input.tsx @@ -1,9 +1,7 @@ -import { useEmailContext } from '@block-email/component/EmailContext'; -import { revealMessageAfterLayout } from '@block-email/util/scrollToMessage'; -import type { - ApiDraftOutputDbId, - ApiMessage, -} from '@service-email/generated/schemas'; +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, @@ -12,13 +10,13 @@ import { type Setter, Show, } from 'solid-js'; -import { decodeBase64Utf8 } from '../util/decodeBase64'; -import { plainTextToHtml } from '../util/plainTextToHtml'; -import { BaseInput } from './BaseInput'; +import { revealMessageAfterLayout } from '../primitives/scroll-to-message'; +import { useEmailContext } from './email-thread-context'; +import { useEmailThreadEnvironment } from './thread-environment'; interface EmailInputProps { - replyingTo: Accessor; - draft?: ApiMessage; + replyingTo: Accessor; + draft?: EmailMessage; setShowReply?: Setter; markdownDomRef?: (ref: HTMLDivElement) => void | HTMLDivElement; unframed?: boolean; @@ -27,8 +25,18 @@ interface EmailInputProps { }; } +/** A reply target owns one editor/form lifetime; changing targets must reset the draft latch. */ export function EmailInput(props: EmailInputProps) { + return ( + + {(_identity) => } + + ); +} + +function EmailInputSession(props: EmailInputProps) { const ctx = useEmailContext(); + const environment = useEmailThreadEnvironment(); // 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 @@ -58,7 +66,7 @@ export function EmailInput(props: EmailInputProps) { return decodedHtml; }); - async function afterSend(newMessageId: ApiDraftOutputDbId | null) { + async function afterSend(newMessageId: string | null) { // Collapse the input after sending (Gmail-style). props.setShowReply?.(false); @@ -78,7 +86,15 @@ export function EmailInput(props: EmailInputProps) { {(seed) => ( - { diff --git a/apps/web/src/features/email-thread/views/email-thread-context.tsx b/apps/web/src/features/email-thread/views/email-thread-context.tsx new file mode 100644 index 00000000000..2771a054871 --- /dev/null +++ b/apps/web/src/features/email-thread/views/email-thread-context.tsx @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'solid-js'; +import type { EmailThreadState } from '../primitives/email-thread-state'; + +const EmailContext = createContext(); +export const EmailProvider = EmailContext.Provider; +export function useEmailContext() { + const ctx = useContext(EmailContext); + if (!ctx) + throw new Error('useEmailContext must be used within an EmailProvider'); + return ctx; +} diff --git a/apps/web/src/features/email-thread/views/email-thread-surface.test.tsx b/apps/web/src/features/email-thread/views/email-thread-surface.test.tsx new file mode 100644 index 00000000000..ad0ef790c54 --- /dev/null +++ b/apps/web/src/features/email-thread/views/email-thread-surface.test.tsx @@ -0,0 +1,73 @@ +import { render } from '@solidjs/testing-library'; +import { createContext, useContext } from 'solid-js'; +import { describe, expect, it, vi } from 'vitest'; +import { composeServices } from '../../email-compose/tests/services'; +import { useEmailRendering } from '../../email-message/context/email-rendering-context'; +import { dependencies, message, thread } from '../tests/fixtures'; +import { useEmailContext } from './email-thread-context'; +import { EmailThreadSurface } from './email-thread-surface'; + +// Replace only the large UI subtree; exercise real providers, state, and host frame ordering. +vi.mock('./email', () => ({ + EmailThreadView: (props: { header?: unknown }) => <>{props.header}, +})); +const HostContext = createContext(); +describe('thread composition ownership', () => { + it('constructs content beneath the host frame and feature providers', () => { + const deps = dependencies({ + thread: () => thread([message('one')]), + isLoading: () => false, + isFetching: () => false, + isFetchingOlder: () => false, + hasMore: () => false, + fetchOlder() {}, + refresh() {}, + }); + const Probe = () => { + const state = useEmailContext(); + useEmailRendering(); + return ( +

+ {useContext(HostContext)}:{state.messages.list()[0].db_id} +

+ ); + }; + const view = render(() => ( + 'thread'} + dependencies={deps} + environment={{ + dependencies: deps, + compose: composeServices(), + rendering: {}, + }} + emailRendering={{ + theme: () => ({ + inkL: 0, + inkC: 0, + inkH: 0, + panelL: 1, + accentL: 0, + accentC: 0, + accentH: 0, + }), + resolveImages: async () => {}, + }} + frame={(content) => ( + {content()} + )} + header={} + /> + )); + try { + expect(view.getByText('host:one')).toBeTruthy(); + } finally { + view.unmount(); + } + }); + it('fails clearly when a required capability provider is omitted', () => { + expect(() => useEmailRendering()).toThrow('EmailRenderingProvider'); + expect(() => useEmailContext()).toThrow(); + }); +}); diff --git a/apps/web/src/features/email-thread/views/email-thread-surface.tsx b/apps/web/src/features/email-thread/views/email-thread-surface.tsx new file mode 100644 index 00000000000..7ef19f30981 --- /dev/null +++ b/apps/web/src/features/email-thread/views/email-thread-surface.tsx @@ -0,0 +1,33 @@ +import type { JSX } from 'solid-js'; +import { + type EmailRenderingDependencies, + EmailRenderingProvider, +} from '../../email-message/context/email-rendering-context'; +import { createEmailThreadState } from '../primitives/email-thread-state'; +import { EmailThreadView, type EmailThreadViewProps } from './email'; +import { EmailProvider } from './email-thread-context'; +import { + ThreadEnvironmentProvider, + type ThreadViewEnvironment, +} from './thread-environment'; + +export interface EmailThreadSurfaceProps extends EmailThreadViewProps { + environment: ThreadViewEnvironment; + emailRendering: EmailRenderingDependencies; + frame?: (content: () => JSX.Element) => JSX.Element; +} + +/** Reusable composition: all application dependencies are supplied by the caller. */ +export function EmailThreadSurface(props: EmailThreadSurfaceProps) { + const state = createEmailThreadState(props.dependencies, props.host); + const content = () => ; + return ( + + + + {props.frame ? props.frame(content) : content()} + + + + ); +} diff --git a/apps/web/src/features/email-thread/views/email.tsx b/apps/web/src/features/email-thread/views/email.tsx new file mode 100644 index 00000000000..0184f17e2c3 --- /dev/null +++ b/apps/web/src/features/email-thread/views/email.tsx @@ -0,0 +1,133 @@ +import { EmailComposeView } from '@app/features/email-compose/views/email-compose'; +import { EmailFormContextProvider } from '@app/features/email-compose/views/email-form-context'; +import { isPersonalMessage } from '@app/features/email-message/core/is-personal-message'; +import { CustomScrollbar } from '@core/component/CustomScrollbar'; +import type { JSX } from 'solid-js'; +import { type Accessor, Match, Show, Switch } from 'solid-js'; +import type { + EmailThreadDependencies, + EmailThreadHost, +} from '../context/email-thread-dependencies'; +import { createThreadNavigation } from '../primitives/thread-navigation'; +import { BottomReplyButtons } from './bottom-reply-buttons'; +import { useEmailContext } from './email-thread-context'; +import { MessageList } from './message-list'; +import { MobileEmailComposeDrawer } from './mobile-email-compose-drawer'; +import { useEmailThreadEnvironment } from './thread-environment'; +export type EmailThreadViewProps = { + title: string; + threadId: Accessor; + dependencies: EmailThreadDependencies; + host?: EmailThreadHost; + header?: JSX.Element; + actions?: JSX.Element; +}; +export function EmailThreadView(props: EmailThreadViewProps) { + const context = useEmailContext(); + const environment = useEmailThreadEnvironment(); + const deps = props.dependencies; + const isTouchDevice = deps.isTouch; + const navigation = createThreadNavigation(props, context, deps, props.host); + const { + showMiddleMessages, + keyboardSelecting, + armKeyboardPointer, + leaveHiddenChip, + setUserOpenedMiddle, + emailReplyInfo, + replyInputInFlow, + mobileBottomReplyMessage, + } = navigation; + 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, + deps.viewerEmail(), + context.messages.personalSenders() + ), + }} + > + {/* Edge-to-edge on mobile/tablet: the message list carries its own + insets in-scroll and under-scrolls the floating chrome. */} +
+ {props.header} + {props.actions} +
+ { + navigation.setMarkdownDomRef(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) => ( + + )} + + + { + navigation.setMarkdownDomRef(el); + }} + /> + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/features/email-thread/views/message-container.tsx b/apps/web/src/features/email-thread/views/message-container.tsx new file mode 100644 index 00000000000..d227936c9a1 --- /dev/null +++ b/apps/web/src/features/email-thread/views/message-container.tsx @@ -0,0 +1,169 @@ +import type { EmailMessage } from '@app/features/email-message/core/email-message'; +import { FloatingInputLoader } from '@core/component/FloatingInputLoader'; +import { createMemo, createSignal, Match, Show, Switch } from 'solid-js'; +import { isPersonalMessage } from '../../email-message/core/is-personal-message'; +import { EmailMessageView } from '../../email-message/views/email-message'; +import { openEmailReplyComposerForMessage } from '../primitives/reply-actions'; +import { + revealMessageAfterLayout, + scrollFocusedCardIntoView, +} from '../primitives/scroll-to-message'; +import { BottomReplyButtons } from './bottom-reply-buttons'; +import { EmailInput } from './email-input'; +import { useEmailContext } from './email-thread-context'; +import { useEmailThreadEnvironment } from './thread-environment'; + +interface MessageContainerProps { + message: EmailMessage; + isFirstMessage: boolean; + isLastMessage: boolean; + isSelected: boolean; + allowHover: boolean; + isExpanded: boolean; + markdownDomRef?: (ref: HTMLDivElement) => void | HTMLDivElement; +} + +export function MessageContainer(props: MessageContainerProps) { + const { dependencies: deps, rendering } = useEmailThreadEnvironment(); + const isTouchDevice = deps.isTouch; + 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 [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); + } + }; + + // 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(props.message.db_id, expanded) + } + onSelect={() => context.messages.setFocused(props.message.db_id)} + onHover={() => + context.messages.setHovered({ + kind: 'message', + id: props.message.db_id, + }) + } + onUnhover={() => { + const hovered = context.messages.hovered(); + if (hovered?.kind === 'message' && hovered.id === props.message.db_id) + context.messages.setHovered(undefined); + }} + onFocus={scrollFocusedCardIntoView} + onOpenAttachment={rendering.openAttachment} + onReply={ + context.permissions().isOwner + ? (replyType) => + openEmailReplyComposerForMessage({ + ctx: context, + message: props.message, + replyType, + isLastMessage: props.isLastMessage, + setShowReply, + isMobile: deps.isMobile(), + }) + : undefined + } + > + +
+ + + +
+ + + props.message} + setShowReply={setShowReply} + draft={draftChild()} + markdownDomRef={ + props.isLastMessage ? props.markdownDomRef : undefined + } + unframed + /> + + + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/features/block-email/component/MessageList.tsx b/apps/web/src/features/email-thread/views/message-list.tsx similarity index 89% rename from apps/web/src/features/block-email/component/MessageList.tsx rename to apps/web/src/features/email-thread/views/message-list.tsx index b2dbb5d130f..981cfa31268 100644 --- a/apps/web/src/features/block-email/component/MessageList.tsx +++ b/apps/web/src/features/email-thread/views/message-list.tsx @@ -1,7 +1,4 @@ -import { useEmailContext } from '@block-email/component/EmailContext'; -import { isScrollingToMessage } from '@block-email/signal/scrollState'; import { StaticMarkdownContext } from '@core/component/LexicalMarkdown/component/core/StaticMarkdown'; -import { isTouchDevice } from '@core/mobile/isTouchDevice'; import { Key } from '@solid-primitives/keyed'; import { Button, cn, Layer } from '@ui'; import { @@ -11,6 +8,7 @@ import { onCleanup, Show, } from 'solid-js'; +import { EmailThreadTitle } from '../components/email-thread-title'; import { fetchOlderMessages, isTruncatedMiddleMessage, @@ -18,10 +16,11 @@ import { listNeedsOlderPage, threadMessageIsExpanded, truncatedMiddleCount, -} from '../util/scrollToMessage'; -import { EmailParticipants } from './EmailParticipants'; -import { EmailThreadTitle } from './EmailThreadTitle'; -import { MessageContainer } from './MessageContainer'; +} from '../primitives/scroll-to-message'; +import { EmailParticipants } from './email-participants'; +import { useEmailContext } from './email-thread-context'; +import { MessageContainer } from './message-container'; +import { useEmailThreadEnvironment } from './thread-environment'; interface MessageListProps { initialLoadComplete: boolean; @@ -41,8 +40,9 @@ interface MessageListProps { } export function MessageList(props: MessageListProps) { - const getIsScrollingToMessage = isScrollingToMessage.get; + const getIsScrollingToMessage = () => context.isScrollingToMessage(); const context = useEmailContext(); + const environment = useEmailThreadEnvironment(); // One selected message at a time, whether it was reached by click, tab, or // arrow keys — they all write the same focused id. const isSelectedSelector = createSelector( @@ -117,15 +117,22 @@ export function MessageList(props: MessageListProps) {
- +
@@ -174,7 +181,8 @@ export function MessageList(props: MessageListProps) { // Gmail/Superhuman). Desktop only: mobile edits drafts in a drawer. const hasDraft = createMemo(() => { const messageID = message().db_id; - if (!messageID || isTouchDevice()) return false; + if (!messageID || environment.dependencies.isTouch()) + return false; return !!context.drafts.getDraftForMessage(messageID); }); diff --git a/apps/web/src/features/block-email/component/MobileEmailComposeDrawer.tsx b/apps/web/src/features/email-thread/views/mobile-email-compose-drawer.tsx similarity index 96% rename from apps/web/src/features/block-email/component/MobileEmailComposeDrawer.tsx rename to apps/web/src/features/email-thread/views/mobile-email-compose-drawer.tsx index d43589a41ca..d63e39c4af8 100644 --- a/apps/web/src/features/block-email/component/MobileEmailComposeDrawer.tsx +++ b/apps/web/src/features/email-thread/views/mobile-email-compose-drawer.tsx @@ -4,8 +4,8 @@ import { } from '@components/app/mobile/MobileDrawer'; import { FloatingInputLoader } from '@core/component/FloatingInputLoader'; import { createMemo, Show } from 'solid-js'; -import { useEmailContext } from './EmailContext'; -import { EmailInput } from './EmailInput'; +import { EmailInput } from './email-input'; +import { useEmailContext } from './email-thread-context'; export function MobileEmailComposeDrawer(props: { markdownDomRef?: (ref: HTMLDivElement) => void | HTMLDivElement; diff --git a/apps/web/src/features/email-thread/views/thread-environment.tsx b/apps/web/src/features/email-thread/views/thread-environment.tsx new file mode 100644 index 00000000000..28683a0b9c6 --- /dev/null +++ b/apps/web/src/features/email-thread/views/thread-environment.tsx @@ -0,0 +1,24 @@ +import { createContext, useContext } from 'solid-js'; +import type { + EmailComposeHost, + EmailComposeServices, +} from '../../email-compose/context/compose-services'; +import type { EmailAttachment } from '../../email-message/core/email-message'; +import type { EmailThreadDependencies } from '../context/email-thread-dependencies'; + +export interface ThreadViewEnvironment { + dependencies: EmailThreadDependencies; + copySubject?: (subject: string) => void; + compose: EmailComposeServices; + composeHost?: EmailComposeHost; + rendering: { openAttachment?: (attachment: EmailAttachment) => void }; +} + +const ThreadEnvironment = createContext(); +export const ThreadEnvironmentProvider = ThreadEnvironment.Provider; +export function useEmailThreadEnvironment() { + const value = useContext(ThreadEnvironment); + if (!value) + throw new Error('Email thread views require a ThreadEnvironmentProvider'); + return value; +} 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 41b9c7a29d7..4a446d99b85 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 @@ -1,23 +1,29 @@ -import { useFeatureFlag } from '@app/lib/analytics/posthog'; +import { SignaturePreview } from '@app/features/email-compose/components/signature-preview'; import { ComposeLayout, EmailComposeToolbar, -} from '@block-email/component/compose'; +} from '@app/features/email-compose/compose-layout'; +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 { DraftFormAttachment } from '@app/features/email-compose/primitives/email-form-state'; +import { prepareEmailBody } from '@app/features/email-compose/primitives/prepare-email-body'; 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'; +} from '@app/features/email-compose/views/compose-context'; +import { useFeatureFlag } from '@app/lib/analytics/posthog'; import { useChatContext } from '@core/component/AI/context'; import type { AssistantMessagePart } from '@core/component/AI/types'; import { toast } from '@core/component/Toast/Toast'; -import { enableEmailSignatures } from '@core/constant/featureFlags'; +import { + ENABLE_EMAIL_SCHEDULED_SEND, + enableEmailSignatures, +} from '@core/constant/featureFlags'; +import { isMobile } from '@core/mobile/isMobile'; +import { interceptMailtoLinks } from '@core/util/interceptMailtoLinks'; import { useChatQuery } from '@queries/chat'; import { useEmailLinksQuery, useEmailSignature } from '@queries/email/link'; @@ -362,6 +368,10 @@ export function ComposeTool(props: ComposeToolProps) { }; const ctx: ComposeContextValue = { + bodyActions: createComposeBodyActions(), + isMobile, + scheduleEnabled: ENABLE_EMAIL_SCHEDULED_SEND, + attachmentFailure: toast.failure, subject, attachments: () => [], sendTime: () => undefined, @@ -409,6 +419,8 @@ export function ComposeTool(props: ComposeToolProps) { {(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/tests/email-rendering.pw.ts b/apps/web/src/lib/core/email/tests/email-rendering.pw.ts index 55efdcd8a62..ddf12f35f6c 100644 --- a/apps/web/src/lib/core/email/tests/email-rendering.pw.ts +++ b/apps/web/src/lib/core/email/tests/email-rendering.pw.ts @@ -1,9 +1,9 @@ import fs from 'node:fs'; import path from 'node:path'; +import { EMAIL_BODY_CONTAINMENT_CSS } from '@app/features/email-message/core/email-body-containment-css'; +import { fitToWidthZoom } from '@app/features/email-message/core/fit-to-width-zoom'; 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. 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/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/vitest.config.ts b/apps/web/vitest.config.ts index 6068f979d40..1a3f99c83a5 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -142,13 +142,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 +172,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/docs/AGENT_GUIDE/surfaces.md b/docs/AGENT_GUIDE/surfaces.md index e1c21940125..d084d3d1bd7 100644 --- a/docs/AGENT_GUIDE/surfaces.md +++ b/docs/AGENT_GUIDE/surfaces.md @@ -18,6 +18,18 @@ 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. +Replies appear inline on desktop and in a composer drawer on touch devices. +An edited reply remains a draft when navigating away and returning. + +While a schedule change is pending, immediate send and further schedule changes +are disabled. 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. + ## Search Sidebar `Search` button → `/app/.../component/search` with a focused query box. Results diff --git a/docs/EMAIL_FEATURE_ARCHITECTURE.md b/docs/EMAIL_FEATURE_ARCHITECTURE.md new file mode 100644 index 00000000000..953944a98e7 --- /dev/null +++ b/docs/EMAIL_FEATURE_ARCHITECTURE.md @@ -0,0 +1,212 @@ +# 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 | +| --- | --- | --- | +| `email-message` | A received or sent message, its sender/header, HTML or Markdown body, quoted content, containment, theme treatment, 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 + 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/email-message.tsx` constructs rendering capabilities and mounts + `views/email-message.tsx`. The reusable view receives one `EmailMessage` plus + resolved presentation values and event callbacks. A thread supplies selection, + expansion, reply actions, and a footer through those inputs. +- `email-thread/email-thread.tsx` constructs the existing shared thread query, + source adapter, viewer/contact capabilities, thread commands, composer services, + notification subscription, rendering adapters, and host-independent cache + cleanup. `views/email-thread-surface.tsx` builds thread state and providers from + supplied dependencies. It imports no production entry point for a nested + message or composer. +- `email-compose/email-compose.tsx` supplies real compose services and split-host + callbacks to `views/email-compose.tsx`. Inline replies use + `views/reply-input.tsx` with explicitly supplied services and a reply session. +- `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 live in `context/`. Prepared view-state types live in +`primitives/`, and contexts holding a mounted screen's state live in `views/`. +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 | +| `EmailThreadHost` | Optional location target, focus, activation status, and keyboard registration | +| `EmailRenderingDependencies` | Theme values, link preparation, and image resolution with disposal awareness | +| `EmailFormDependencies` | Viewer address and available inbox identities for recipient selection | +| `EmailReplySession` | Relevant messages/drafts, recipient options, reply request, and selection callbacks | +| `EmailComposeServices` | Account metadata and named draft, attachment, send, schedule, undo, and feedback operations | +| `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` decodes +wire values and guards Solid resource reads. `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. + +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. + +## State and lifetime rules + +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. +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. +8. Renderer resources follow their Solid owner. Image blob URLs, resize observers, + image listeners, and pending measurement frames are released on source changes + or disposal. + +## 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, +`UserIcon`, 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. Tests of a feature provider or +lifetime may replace the large rendering subtree while exercising the real state +and provider composition. + +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 and the shared +`EntityIcon` display type. None of these is a transport or app-context lookup. +Do not generalize these 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. `email-thread/tests/architecture.test.ts` resolves +aliases and re-exports to verify cross-feature direction and layer boundaries, +then traverses runtime controller/contract dependencies to reject indirect +production-service and rendering imports. Type-only edges are treated separately. + +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. + +### Migration verification — September 5, 2026 + +The pre-extraction email suite passed 94 tests in 9 files. The extracted features +pass 122 tests in 24 files, including 28 added regression tests. The full frontend +check (cache schema, TypeScript, and Biome), feature-scoped ast-grep scan, and all +five QC reviews passed. + +The local stack was started with `just run_local --instance email-feature-eval +--port-base 24700 --no-doppler --with-chrome --no-build`. Chrome checks before and +after extraction exercised hidden-message expansion, email rendering, newsletter +containment, and reply draft persistence across navigation. Final checks also +covered header details, quoted content, deep-link reveal, keyboard movement and +reply, standalone compose, and a saved draft's touch drawer closing and reopening. +The final interaction runs reported no page errors. + +The five existing rendering fixture tests differ from their committed screenshots +on this machine. The original pre-extraction renderer reproduces those failures. +A separate comparison using the original assets as temporary baselines passes all +12 screenshots, across both themes and narrow/wide panes, with **zero differing +pixels** after extraction. Committed screenshots were not regenerated. The local +seeded inbox has no real provider credentials, so these checks do not establish +Gmail delivery or native iOS behavior. diff --git a/docs/FRONTEND_FEATURE_ARCHITECTURE.md b/docs/FRONTEND_FEATURE_ARCHITECTURE.md index 775f2744866..bab6460f693 100644 --- a/docs/FRONTEND_FEATURE_ARCHITECTURE.md +++ b/docs/FRONTEND_FEATURE_ARCHITECTURE.md @@ -616,3 +616,18 @@ general exceptions without a concrete need and an updated explanation. 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/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..67347834bd7 100644 --- a/rules/ast-grep/ts-feature-data-no-ui.yml +++ b/rules/ast-grep/ts-feature-data-no-ui.yml @@ -13,6 +13,12 @@ note: |- 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..04549c86829 100644 --- a/rules/ast-grep/tsx-feature-data-no-ui.yml +++ b/rules/ast-grep/tsx-feature-data-no-ui.yml @@ -13,6 +13,12 @@ note: |- 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 From 7fe4ac77eeab237bf650ab8ae47ad0e5d964f3d7 Mon Sep 17 00:00:00 2001 From: synoet Date: Sat, 5 Sep 2026 21:25:30 +0000 Subject: [PATCH 03/21] refactor(web): isolate email body rendering from Solid --- .../frontend-feature-architecture/SKILL.md | 22 +- CLAUDE.md | 2 +- apps/web/AGENTS.md | 2 +- apps/web/justfile | 5 +- apps/web/package.json | 1 + .../primitives/prepare-email-body.ts | 15 +- .../context/email-rendering-context.tsx | 19 +- .../email-message/image-adapter.test.ts | 80 +++++ .../primitives/email-message-body.test.ts | 31 +- .../primitives/email-message-body.ts | 311 +++--------------- .../email-message/rendering-adapter.ts | 15 +- .../views/email-message-body.tsx | 8 - apps/web/src/lib/core/email/index.ts | 12 +- .../src/lib/core/email/parse-email-html.ts | 274 --------------- .../src/lib/core/email/proxy-email-images.ts | 23 -- .../core/email/tests/email-rendering.pw.ts | 204 ------------ .../tests/fixtures/github-pr-review.json | 5 - .../email/tests/fixtures/nested-quotes.json | 5 - .../email/tests/fixtures/styled-email.json | 5 - .../core/email/tests/fixtures/wide-table.json | 6 - .../core/email/tests/parse-email-html.test.ts | 208 ------------ .../lib/core/email/tests/playwright.config.ts | 32 -- .../github-pr-review-macro-dark.png | Bin 50717 -> 0 bytes .../github-pr-review-macro-light.png | Bin 50324 -> 0 bytes .../google-calendar-invite-macro-dark.png | Bin 76316 -> 0 bytes .../google-calendar-invite-macro-light.png | Bin 84169 -> 0 bytes .../nested-quotes-macro-dark.png | Bin 17428 -> 0 bytes .../nested-quotes-macro-light.png | Bin 18119 -> 0 bytes .../styled-email-macro-dark.png | Bin 12407 -> 0 bytes .../styled-email-macro-light.png | Bin 13817 -> 0 bytes .../wide-table-360-macro-dark.png | Bin 5691 -> 0 bytes .../wide-table-360-macro-light.png | Bin 5679 -> 0 bytes .../wide-table-800-macro-dark.png | Bin 8085 -> 0 bytes .../wide-table-800-macro-light.png | Bin 7969 -> 0 bytes apps/web/vitest.config.ts | 1 + bun.lock | 22 ++ docs/AGENT_GUIDE/surfaces.md | 5 + docs/EMAIL_FEATURE_ARCHITECTURE.md | 75 ++++- docs/FRONTEND_FEATURE_ARCHITECTURE.md | 15 + justfile | 4 +- nix-support/node_modules-hashes.json | 4 +- package.json | 1 + packages/email-renderer/.gitignore | 4 + packages/email-renderer/README.md | 140 ++++++++ packages/email-renderer/biome.jsonc | 5 + packages/email-renderer/index.html | 16 + packages/email-renderer/package.json | 30 ++ packages/email-renderer/playwright.config.ts | 20 ++ .../email-renderer/src/browser/colors.ts | 96 +----- .../browser}/email-body-containment-css.ts | 0 packages/email-renderer/src/browser/index.ts | 8 + .../email-renderer/src/browser/renderer.ts | 143 ++++++++ .../email-renderer/src/core/colors.test.ts | 61 +--- packages/email-renderer/src/core/colors.ts | 86 +++++ packages/email-renderer/src/core/css.ts | 83 +++++ .../src}/core/fit-to-width-zoom.test.ts | 0 .../src}/core/fit-to-width-zoom.ts | 0 packages/email-renderer/src/core/html.test.ts | 209 ++++++++++++ packages/email-renderer/src/core/html.ts | 256 ++++++++++++++ packages/email-renderer/src/core/index.ts | 14 + .../src/core/resource-policy.ts | 39 +++ .../email-renderer/tests/boundaries.test.ts | 76 +++++ .../tests/fixtures/github-pr-review.json | 5 + .../fixtures/google-calendar-invite.json | 4 +- .../tests/fixtures/nested-quotes.json | 5 + .../tests/fixtures/styled-email.json | 5 + .../tests/fixtures/wide-table.json | 9 + packages/email-renderer/tests/rendering.pw.ts | 284 ++++++++++++++++ .../snapshots/github-pr-review-dark-600.png | Bin 0 -> 42606 bytes .../snapshots/github-pr-review-light-600.png | Bin 0 -> 42705 bytes .../google-calendar-invite-dark-600.png | Bin 0 -> 79155 bytes .../google-calendar-invite-light-600.png | Bin 0 -> 79155 bytes .../snapshots/nested-quotes-dark-600.png | Bin 0 -> 12723 bytes .../snapshots/nested-quotes-light-600.png | Bin 0 -> 12885 bytes .../tests/snapshots/styled-email-dark-600.png | Bin 0 -> 11006 bytes .../snapshots/styled-email-light-600.png | Bin 0 -> 11026 bytes .../tests/snapshots/wide-table-dark-360.png | Bin 0 -> 1982 bytes .../tests/snapshots/wide-table-dark-800.png | Bin 0 -> 3230 bytes .../tests/snapshots/wide-table-light-360.png | Bin 0 -> 1982 bytes .../tests/snapshots/wide-table-light-800.png | Bin 0 -> 3230 bytes packages/email-renderer/tsconfig.core.json | 6 + packages/email-renderer/tsconfig.json | 13 + packages/email-renderer/viewer/main.ts | 90 +++++ packages/email-renderer/viewer/style.css | 5 + packages/email-renderer/vitest.config.ts | 9 + 85 files changed, 1896 insertions(+), 1237 deletions(-) create mode 100644 apps/web/src/features/email-message/image-adapter.test.ts delete mode 100644 apps/web/src/lib/core/email/parse-email-html.ts delete mode 100644 apps/web/src/lib/core/email/proxy-email-images.ts delete mode 100644 apps/web/src/lib/core/email/tests/email-rendering.pw.ts delete mode 100644 apps/web/src/lib/core/email/tests/fixtures/github-pr-review.json delete mode 100644 apps/web/src/lib/core/email/tests/fixtures/nested-quotes.json delete mode 100644 apps/web/src/lib/core/email/tests/fixtures/styled-email.json delete mode 100644 apps/web/src/lib/core/email/tests/fixtures/wide-table.json delete mode 100644 apps/web/src/lib/core/email/tests/parse-email-html.test.ts delete mode 100644 apps/web/src/lib/core/email/tests/playwright.config.ts delete mode 100644 apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/github-pr-review-macro-dark.png delete mode 100644 apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/github-pr-review-macro-light.png delete mode 100644 apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/google-calendar-invite-macro-dark.png delete mode 100644 apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/google-calendar-invite-macro-light.png delete mode 100644 apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/nested-quotes-macro-dark.png delete mode 100644 apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/nested-quotes-macro-light.png delete mode 100644 apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/styled-email-macro-dark.png delete mode 100644 apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/styled-email-macro-light.png delete mode 100644 apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-360-macro-dark.png delete mode 100644 apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-360-macro-light.png delete mode 100644 apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-800-macro-dark.png delete mode 100644 apps/web/src/lib/core/email/tests/snapshots/email-rendering.pw.ts/wide-table-800-macro-light.png create mode 100644 packages/email-renderer/.gitignore create mode 100644 packages/email-renderer/README.md create mode 100644 packages/email-renderer/biome.jsonc create mode 100644 packages/email-renderer/index.html create mode 100644 packages/email-renderer/package.json create mode 100644 packages/email-renderer/playwright.config.ts rename apps/web/src/lib/core/email/transform-email-colors.ts => packages/email-renderer/src/browser/colors.ts (72%) rename {apps/web/src/features/email-message/core => packages/email-renderer/src/browser}/email-body-containment-css.ts (100%) create mode 100644 packages/email-renderer/src/browser/index.ts create mode 100644 packages/email-renderer/src/browser/renderer.ts rename apps/web/src/lib/core/email/tests/transform-email-colors.test.ts => packages/email-renderer/src/core/colors.test.ts (65%) create mode 100644 packages/email-renderer/src/core/colors.ts create mode 100644 packages/email-renderer/src/core/css.ts rename {apps/web/src/features/email-message => packages/email-renderer/src}/core/fit-to-width-zoom.test.ts (100%) rename {apps/web/src/features/email-message => packages/email-renderer/src}/core/fit-to-width-zoom.ts (100%) create mode 100644 packages/email-renderer/src/core/html.test.ts create mode 100644 packages/email-renderer/src/core/html.ts create mode 100644 packages/email-renderer/src/core/index.ts create mode 100644 packages/email-renderer/src/core/resource-policy.ts create mode 100644 packages/email-renderer/tests/boundaries.test.ts create mode 100644 packages/email-renderer/tests/fixtures/github-pr-review.json rename {apps/web/src/lib/core/email => packages/email-renderer}/tests/fixtures/google-calendar-invite.json (51%) create mode 100644 packages/email-renderer/tests/fixtures/nested-quotes.json create mode 100644 packages/email-renderer/tests/fixtures/styled-email.json create mode 100644 packages/email-renderer/tests/fixtures/wide-table.json create mode 100644 packages/email-renderer/tests/rendering.pw.ts create mode 100644 packages/email-renderer/tests/snapshots/github-pr-review-dark-600.png create mode 100644 packages/email-renderer/tests/snapshots/github-pr-review-light-600.png create mode 100644 packages/email-renderer/tests/snapshots/google-calendar-invite-dark-600.png create mode 100644 packages/email-renderer/tests/snapshots/google-calendar-invite-light-600.png create mode 100644 packages/email-renderer/tests/snapshots/nested-quotes-dark-600.png create mode 100644 packages/email-renderer/tests/snapshots/nested-quotes-light-600.png create mode 100644 packages/email-renderer/tests/snapshots/styled-email-dark-600.png create mode 100644 packages/email-renderer/tests/snapshots/styled-email-light-600.png create mode 100644 packages/email-renderer/tests/snapshots/wide-table-dark-360.png create mode 100644 packages/email-renderer/tests/snapshots/wide-table-dark-800.png create mode 100644 packages/email-renderer/tests/snapshots/wide-table-light-360.png create mode 100644 packages/email-renderer/tests/snapshots/wide-table-light-800.png create mode 100644 packages/email-renderer/tsconfig.core.json create mode 100644 packages/email-renderer/tsconfig.json create mode 100644 packages/email-renderer/viewer/main.ts create mode 100644 packages/email-renderer/viewer/style.css create mode 100644 packages/email-renderer/vitest.config.ts diff --git a/.claude/skills/frontend-feature-architecture/SKILL.md b/.claude/skills/frontend-feature-architecture/SKILL.md index 34acde6605d..c999c411047 100644 --- a/.claude/skills/frontend-feature-architecture/SKILL.md +++ b/.claude/skills/frontend-feature-architecture/SKILL.md @@ -111,7 +111,9 @@ to retain rows during background failures in primitives. A source reports data availability; it must not return a precomputed screen view-state that moves those decisions into infrastructure. -Solid accessors and factories are appropriate in these contracts. Accept Solid +Solid accessors and factories are appropriate for reactive use-case contracts. +Plain transformations and framework-independent renderers take plain values; +do not introduce Solid merely because their caller is a Solid feature. Accept Solid as the reactive foundation instead of wrapping it in a custom framework. Preserve enabled inputs, updates, pagination, owner cleanup, and the chosen resource-read semantics; a promise-only replacement can lose required behavior. Factories must @@ -124,6 +126,24 @@ or interfaces for every helper. If a wrapper merely forwards values and protects no independent behavior, do not create a new layer just to justify a contract. See the document's “Feature-owned contracts” section for the feed example. +## Keep framework-independent behavior independent + +Extract a standalone library when substantial behavior should run and be tested +without the application or UI framework. Keep deterministic transformations in +its core and browser effects in an explicit browser entry point. The feature +adapter translates accessors into values and owns disposal. Supply theme, +resource policy, and host actions explicitly; the library must not read app flags, +clients, routes, origins, or block signals. Do not build a custom reactive framework +or move message/thread DTOs into a body renderer just to share them. + +`packages/email-renderer` is the example: parse5/css-tree preparation runs in +Node without DOM libraries; Shadow DOM/colors/layout run in its framework-free +browser layer; `email-message` supplies Solid lifetime and app adapters. Its +vanilla fixture viewer and browser tests use the exact production API. Test pure +output separately from pixels, and control browser, fonts, viewport, theme, and +remote resources for visual comparisons. A screenshot harness that rebuilds only +part of production rendering does not verify the whole pipeline. + ## Choose context or props deliberately Put an ambient capability used the same way on every surface in one feature diff --git a/CLAUDE.md b/CLAUDE.md index fde7257055a..f06f026e8e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,7 @@ cargo test -p {crate} `just test` does not exist. Leave `SQLX_OFFLINE` unset when you run `cargo test`. Run `just prepare_db` only if you changed SQL queries. -Email rendering snapshots (Playwright HTML fixtures, not inbox e2e) live in `apps/web/src/lib/core/email/tests`. Run `just test-email-rendering`. Add a fixture under `fixtures/` then `just test-email-rendering-update`. +Email body rendering lives in `packages/email-renderer`, independently of Solid and the app. Run `just test-email-rendering` for its Node and Chromium suites. Fixtures and visual baselines live under `packages/email-renderer/tests`; add a fixture under `fixtures/`, run `just test-email-rendering-update`, and review the resulting images. `bun run --cwd packages/email-renderer viewer` opens the standalone fixture viewer. These are renderer tests, not inbox e2e. ### Pre Commit ```bash diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index ed2ed992be1..3238a0c1155 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 diff --git a/apps/web/justfile b/apps/web/justfile index f65a7daa05f..f7b30348c5b 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 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/features/email-compose/primitives/prepare-email-body.ts b/apps/web/src/features/email-compose/primitives/prepare-email-body.ts index 69ff6feee13..d2214e50973 100644 --- a/apps/web/src/features/email-compose/primitives/prepare-email-body.ts +++ b/apps/web/src/features/email-compose/primitives/prepare-email-body.ts @@ -1,10 +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, @@ -172,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. @@ -351,12 +351,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) => { 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 index b3ee1f940bc..6543c6d02da 100644 --- a/apps/web/src/features/email-message/context/email-rendering-context.tsx +++ b/apps/web/src/features/email-message/context/email-rendering-context.tsx @@ -1,25 +1,22 @@ +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'; -export interface EmailRenderingTheme { - inkL: number; - inkC: number; - inkH: number; - panelL: number; - accentL: number; - accentC: number; - accentH: number; -} +export type EmailRenderingTheme = ThemeColorParams; /** Rendering capabilities shared by email surfaces; no thread or block state. */ export interface EmailRenderingDependencies { theme: Accessor; + images?: ImagePolicy; prepareLinks?: (container: HTMLElement) => void; resolveImages( root: ShadowRoot, attachments: EmailAttachment[], - blobUrls: string[], - isDisposed: () => boolean + lifetime: ResourceLifetime ): Promise; } 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..3ae2ccec3e7 --- /dev/null +++ b/apps/web/src/features/email-message/image-adapter.test.ts @@ -0,0 +1,80 @@ +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() })); +vi.mock('@core/util/platform', () => ({ isTauri: () => true })); +vi.mock('@core/util/platformFetch', () => ({ platformFetch: fetchImage })); +vi.mock('@core/constant/servers', () => ({ + SERVER_HOSTS: { 'static-file': 'https://files.example.com' }, +})); + +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('https://files.example.com/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'); + let finish!: (blob: Blob) => void; + const blob = new Promise((resolve) => { + finish = resolve; + }); + 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/email-message/primitives/email-message-body.test.ts b/apps/web/src/features/email-message/primitives/email-message-body.test.ts index e5b88e40020..9183732f09a 100644 --- 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 @@ -1,3 +1,4 @@ +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'; @@ -32,8 +33,8 @@ describe('independent email body', () => { ); vi.stubGlobal('cancelAnimationFrame', cancelFrame); const resolveImages = vi.fn( - async (_root, _attachments, urls: string[], disposed: () => boolean) => { - if (!disposed()) urls.push('blob:email'); + async (_root, _attachments, lifetime: ResourceLifetime) => { + lifetime.onDispose(() => URL.revokeObjectURL('blob:email')); } ); const prepareLinks = vi.fn(); @@ -62,14 +63,14 @@ describe('independent email body', () => { }); try { await Promise.resolve(); - const host = root.body.host(); + 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( + expect(root.body.host()!.shadowRoot?.textContent).toContain( 'Quoted thread' ); - const link = root.body.host().shadowRoot?.querySelector('a'); + const link = root.body.host()!.shadowRoot?.querySelector('a'); expect(link?.target).toBe('_blank'); expect(link?.rel).toBe('noopener noreferrer'); expect(prepareLinks).toHaveBeenCalled(); @@ -86,4 +87,24 @@ describe('independent email body', () => { expect(disconnect).toHaveBeenCalled(); expect(cancelFrame).toHaveBeenCalled(); }); + it('keeps the Macro Markdown branch free of hidden HTML resources', async () => { + const resolveImages = vi.fn(async () => {}); + createRoot((dispose) => { + const body = createEmailMessageBody( + { + message: message('macro', { body_macro: 'A document mention' }), + isPersonal: true, + isBodyExpanded: () => true, + setExpandedMessageBody() {}, + setFocusedMessageId() {}, + isFocused: false, + }, + { 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 index b078cb91d41..54d8ec942e4 100644 --- a/apps/web/src/features/email-message/primitives/email-message-body.ts +++ b/apps/web/src/features/email-message/primitives/email-message-body.ts @@ -1,5 +1,5 @@ -import type { EmailMessage } from '@app/features/email-message/core/email-message'; -import { parseEmailContent, processEmailColors } from '@core/email'; +import { prepareEmailBody } from '@macro-inc/email-renderer'; +import { mountEmailBody } from '@macro-inc/email-renderer/browser'; import { type Accessor, createEffect, @@ -9,8 +9,8 @@ import { untrack, } from 'solid-js'; import type { EmailRenderingDependencies } from '../context/email-rendering-context'; -import { EMAIL_BODY_CONTAINMENT_CSS } from '../core/email-body-containment-css'; -import { fitToWidthZoom } from '../core/fit-to-width-zoom'; +import type { EmailMessage } from '../core/email-message'; + export interface EmailMessageBodyProps { message: EmailMessage; isPersonal: boolean; @@ -21,282 +21,53 @@ export interface EmailMessageBodyProps { isFocused: boolean; } +/** Solid only translates reactive inputs and owns the renderer's lifetime. */ export function createEmailMessageBody( props: EmailMessageBodyProps, dependencies: EmailRenderingDependencies ) { - const [showFullHTML, setShowFullHTML] = createSignal(false); - // 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; - } + 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: dependencies.images, } - } - 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.showFullContent - ? 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) - ); - }; - - const isPersonal = () => props.isPersonal; - - const isMacroSender = createMemo(() => { - const senderEmail = props.message.from?.email?.toLowerCase(); - return senderEmail?.endsWith('@macro.com') ?? false; - }); - - const host = createMemo(() => { - dependencies.theme(); - 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 - dependencies.prepareLinks?.(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 rendered = createMemo(() => { + // Macro Markdown owns its mentions/editor integration. Do not mount an + // invisible HTML renderer or start image requests behind that branch. + if (!showFullHTML() && props.message.body_macro) return; + const body = prepared(); 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; - await dependencies.resolveImages( - root, - attachments, - blobUrls, - () => disposed - ); + const host = document.createElement('div'); + const renderer = mountEmailBody(host, body, { + theme: dependencies.theme(), + adaptColors: props.isPersonal || !body.hasTable, + normalizeFonts: + props.isPersonal && + !props.message.from?.email?.toLowerCase().endsWith('@macro.com'), + expanded: untrack(props.isBodyExpanded), + prepareLinks: dependencies.prepareLinks, + resolveImages: (root, lifetime) => + dependencies.resolveImages(root, attachments, lifetime), }); + onCleanup(() => renderer.dispose()); + return { host, renderer }; }); - - // Process the email colors when: the theme changes, or the source HTML changes. - createEffect(() => { - dependencies.theme(); - showFullHTML(); - const root = host().shadowRoot; - if (root) { - if (isPersonal() || !source()?.hasTable) { - queueMicrotask(() => { - untrack(() => { - const theme = dependencies.theme(); - 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 - const measureFrame = requestAnimationFrame(() => applyScale()); - - onCleanup(() => { - cancelAnimationFrame(measureFrame); - resizeObserver.disconnect(); - for (const img of images) { - img.removeEventListener('load', onImageLoad); - } - }); - }); - + createEffect(() => rendered()?.renderer.setExpanded(props.isBodyExpanded())); return { - isPersonal, + isPersonal: () => props.isPersonal, showFullHTML, setShowFullHTML, - isPlaintext, - host, - hasHiddenReplyStructure, + 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 index 9bd37a32563..6b754f5043e 100644 --- a/apps/web/src/features/email-message/rendering-adapter.ts +++ b/apps/web/src/features/email-message/rendering-adapter.ts @@ -1,3 +1,5 @@ +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'; @@ -20,8 +22,19 @@ export function createEmailRenderingDependencies(): EmailRenderingDependencies { }); return { theme, + images: { + remote: 'allow', + proxyUrl: ENABLE_PROXY_EMAIL_IMAGES + ? `${SERVER_HOSTS['image-proxy-service']}/proxy` + : undefined, + }, prepareLinks: interceptMailtoLinks, - async resolveImages(root, attachments, blobUrls, isDisposed) { + 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; 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 index d423b94c1e4..4991dc77604 100644 --- a/apps/web/src/features/email-message/views/email-message-body.tsx +++ b/apps/web/src/features/email-message/views/email-message-body.tsx @@ -14,7 +14,6 @@ export function EmailMessageBody(props: EmailMessageBodyProps) { isPersonal, showFullHTML, setShowFullHTML, - isPlaintext, host, hasHiddenReplyStructure, } = createEmailMessageBody(props, useEmailRendering()); @@ -50,13 +49,6 @@ export function EmailMessageBody(props: EmailMessageBodyProps) { ); }} - - - {host()} diff --git a/apps/web/src/lib/core/email/index.ts b/apps/web/src/lib/core/email/index.ts index 829568e7ea0..b1ffeb0c968 100644 --- a/apps/web/src/lib/core/email/index.ts +++ b/apps/web/src/lib/core/email/index.ts @@ -1,15 +1,11 @@ -// Email body parsing utilities +// Compatibility entry point for the editor's HTML decorator and quoted replies. +// The implementation lives in the framework-independent renderer package. export { parseEmailContent, sanitizeEmailHtml, - scrubActiveContent, stripColorSchemeMediaQueries, -} from './parse-email-html'; - -// Image proxy utilities - -// Color transformation utilities +} 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 ddf12f35f6c..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 { EMAIL_BODY_CONTAINMENT_CSS } from '@app/features/email-message/core/email-body-containment-css'; -import { fitToWidthZoom } from '@app/features/email-message/core/fit-to-width-zoom'; -import { expect, type Page, test } from '@playwright/test'; -import { DEFAULT_THEMES } from '@theme/constants'; - -/** - * 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 4cdae37c508d2acc3d81e14157000e4efb0e9fda..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50717 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 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 f320e3f66e2d8bdf8f048773c5d0c5798c96d108..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50324 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Ν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 X8wK5QM+e0m@Cis&NmH>x-U9hQ?om~q 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 2b2a0211149da52d4036458a7917395ec93d2f2d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 84169 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 H2n+cyH}g1> 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 ee87f8eff6cf9e3bbf2d974e26d1f335bef3ad36..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17428 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 dxTGvhDckppG|`EL;5$s3_x0{o+Nr@LV&2K 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 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 72ed6f38b8920ffcebdaffdc2609c59f7a133a43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12407 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 k0RF>i>CS~GXw|MThp0Ub9b&;S4c 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 a52921ace86ed392d1f9f458b1370530cfb446cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13817 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 aORXP2B8x1mRslYdvbpMbrSj5)Xa5I2S(?xQ 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 67fa0456af0db96bca78135aae4fd71ffb8fe09d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5691 zcmeHLSy&QS8@4H@Ma!CIa!YN}bS%?S(@?~J($N}qtQ;dtB}>6_OG#N|X_<5yO>!kf zu`(rp7fefsL};W`6d{*17eo_KgG4YF6y>Mux%h8p?z%hAInQ&>^Scc3+6&|F|GK#W@#pK}4SFV4M{jJ1CckK`D9?G(R9>iEG z(S2AI7KD$L2t43CCm2t~gY%rjqUPU%E!H`Qpf;TJS-S4lvaxRm=t0)(y5!#so>*f3 zV>Z72!zPQ9zW=~c!9I)uW^BEMdTgUmv$hC9i8a#Yfpam{Ho)=Cl{Dru0k59e2NHir zL%xQA=1BYO)ohEk*+fF>NHhne;si1!M`*?ZY#@HQx4t@cVdN*qe02hrZ8}<;w@=;= zV)(^WJ@rX>9X-ex({)&yP4@&YH=1TOb~j(Jva5{REIiz{8{T)y*9AxU_@dE zyow<;PHp!xuN#Dt#gozY7&%_E;)@Wv49CtjG&XY)}6-FSY;6pgKDc$HoI{#?PV;3|8o7H0`% zRR+V4>p|K{3w-<d2LNR)|&o^GyFdWb6eG7)HvMj?< zVk&69f*tA@gPbrGEdDAjdzmpMdH-uiV4po^f2=O>`x+WkelAPuz87%Ay>G zFH?)515SXxbWinog%1uImw;axW@+Ebg0zTY6I0-qro(V0s41Si9H^Gu3BEU5|}saF_NGHhSWMloyg3x$c>;UXBPl#SUJmTM9WJ?^Izyk#_BE$>`2_ zzq#c&e-e)MebtN8(55>iKwj)V4wYpGq|9NrGgXFm{>F$HwF4 zK7d*1B#(NpE_7PS=d&PACQze(OT*85_|fJ=P$9qQ-)T$Fezq@Hb*Dsd_yMaY>eGb#Y+G7KT;W17g>k$YEw zUT*4Hwo@jgK%+Je;M2DZ_DdWT3f(iYjBL^okjOJ9RNajJ=@J zi8fS+%)I@$;}Fo*a2P#;s@X0ZO-}=G8cns=wNewLn>f9 zJO}pFc`v+Gfo?5%3R&(U=1@X2)$=rb*El(&jcc!E)iW@BILnY{tvs50lYskF3K)S$ zq>kS>coKE{D zS;6^uR4;ink8lHHCk|$PDswQS!LJxmOK|9g2yKwir_0G%7A0^VV9p#E0^*zDlSEhc z6bEdFTOJL=sV6Kn(}8$5y(0XYO1RwLm=<*=Br2bA$M`JB(8l2p7^406@A~(bM%}xI z)uQIzA#t0F>l%>s8TZ04I9jDJ?j0VHlCFXPY+aiBX zmR2`Hz1Z(0g5;o;{dhA98>Ibb40;&w$rXF;;3W3Q+&|ts~ zr|EX%`)m)3#}luuE%b9MfQNk}&GjeQeT7azovTgUhNRMiuz8v8K%T-Rt<;m^)PvQc}njbL^5qVzae2*NCYZPz!Asr{VPB-FQc6#nF z`H|k?3AHUpN?&+~Lw|KxguEIu*s^U;%*1VYj7)3E zk##xJwjvIxT7Oa<)?rr0X#JdcQAKo^wJjayEYHc1J@sF8B=a6y=XTh5syL;(DFPYO zB=VO$Quo#F<~JMm^FS-I&_8OL7lwutSXh_52$4qsrKclw7V6^q;sEM0>IOZif#)U_ zwHzWg4}>8EWyJ78Q#~z!Fpy!%d=)I}#(^Fe{)2S~>bT#5Ll3KyQU|(Y!sLa_O+@^|Rww{RZdB4HF(yV3|-%VO| z%|* zA>18slAlap$q%CTUa72hIRX!T#5HT}I@kU_)vmXuD&h(-s3|@L^3zO}_>m(uF+5@K z{rpVUr=t9X;kp0XOx9P{ytq>SvfeOFkAv%c9BZ3utNj=2t|pP}P~DKi)5gL9H>+QF z$*FgD__5L7F|`3zUeP93JQvst(of@r}5HHcmRPT~EJSMpyj{8t;LFZ6z)_wTKDS$6U{ z9UWcK=UKqN)S-V7-WTEhU3g!__D@Z~zQos;`1=3xr8dw`el;57Ct=>&#n+$n{dyhN YDF15odg4Cqj?Rgne(|n3cH!p#06jmpjsO4v 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 63878f5f891b41c2c1cc2645921ca8c00dd231f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5679 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 Jf1dlF{{_My#|!`f 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 5aa93770f1c2b0bbdba882878fd6999db6dd889f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8085 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 G&Hn=|guU

+ + + + + +
+

+
+ + + 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 72% rename from apps/web/src/lib/core/email/transform-email-colors.ts rename to packages/email-renderer/src/browser/colors.ts index 47e66774f27..f581468dca0 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. @@ -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/email-message/core/email-body-containment-css.ts b/packages/email-renderer/src/browser/email-body-containment-css.ts similarity index 100% rename from apps/web/src/features/email-message/core/email-body-containment-css.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..8d1ce849321 --- /dev/null +++ b/packages/email-renderer/src/browser/index.ts @@ -0,0 +1,8 @@ +export type { ThemeColorParams } from './colors'; +export { processEmailColors, stripContentBackgrounds } 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..f1a4684c98d --- /dev/null +++ b/packages/email-renderer/src/browser/renderer.ts @@ -0,0 +1,143 @@ +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. Call after connecting the host to the DOM. */ +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('a')) { + if (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; + const measure = () => { + if (abort.signal.aborted) return; + content.style.zoom = ''; + content.style.overflow = ''; + content.style.overflowX = ''; + 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.style.setProperty( + '--macro-email-img-display', + value ? 'initial' : 'none' + ); + measure(); + }; + applyExpanded(expanded); + const observer = new ResizeObserver(measure); + observer.observe(host); + lifetime.onDispose(() => observer.disconnect()); + // Capture catches non-bubbling load events, including resolved CID images. + shadow.addEventListener('load', measure, true); + lifetime.onDispose(() => shadow.removeEventListener('load', measure, true)); + const frame = requestAnimationFrame(() => { + if (abort.signal.aborted) return; + if (settings.adaptColors) processEmailColors(shadow, settings.theme); + measure(); + }); + lifetime.onDispose(() => cancelAnimationFrame(frame)); + 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/apps/web/src/lib/core/email/tests/transform-email-colors.test.ts b/packages/email-renderer/src/core/colors.test.ts similarity index 65% rename from apps/web/src/lib/core/email/tests/transform-email-colors.test.ts rename to packages/email-renderer/src/core/colors.test.ts index e7342a1abf6..ad942abefd9 100644 --- a/apps/web/src/lib/core/email/tests/transform-email-colors.test.ts +++ b/packages/email-renderer/src/core/colors.test.ts @@ -4,8 +4,7 @@ import { normalizeRGBA, parseRGBA, rgbaToOklch, - stripContentBackgrounds, -} from '../transform-email-colors'; +} from './colors'; describe('parseRGBA', () => { it('parses rgb() format', () => { @@ -90,64 +89,6 @@ describe('rgbaToOklch', () => { }); }); -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; 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..034d81f1f16 --- /dev/null +++ b/packages/email-renderer/src/core/css.ts @@ -0,0 +1,83 @@ +import { + type CssNode, + generate, + ident, + type List, + type ListItem, + parse, + walk, +} from 'css-tree'; +import { type ImagePolicy, imageUrl } from './resource-policy'; + +/** Parse CSS without CSSOM. Reject unparsed constructs and implicit loads. */ +export function prepareCss( + css: string, + inline: boolean, + images: ImagePolicy +): string { + try { + const ast = parse(css, { + context: inline ? 'declarationList' : 'stylesheet', + onParseError(error) { + throw error; + }, + }); + walk(ast, { + enter(node: CssNode, item: ListItem, list: List) { + if (node.type === 'Atrule') { + const name = ident.decode(node.name).toLowerCase(); + if ( + !['media', 'supports'].includes(name) || + (node.prelude && + /prefers-color-scheme/i.test( + ident.decode(generate(node.prelude)) + )) + ) { + list.remove(item); + return walk.skip; + } + } + if ( + node.type === 'Rule' && + /: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) + ) || ident.decode(node.property).startsWith('--'); + walk(node.value, (value) => { + if (value.type === 'Raw') unsafe = true; + if ( + value.type === 'Function' && + /^(?:expression|var|url|(?:-webkit-)?image-set)$/i.test( + ident.decode(value.name) + ) + ) + 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('normalizes frameset documents to an empty inert body', () => { + const html = ''; + expect(prepareEmailBody({ html })).toMatchObject({ + html: '', + hasTable: false, + }); + expect(parseEmailContent(html)).toEqual({ + mainContent: '', + signature: null, + hasTable: false, + }); + expect(sanitizeEmailHtml(html)).toBe(''); + }); + it('is deterministic and leaves inputs unchanged', () => { + 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(typeof globalThis).toBe('object'); + 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 result = parseEmailContent( + '

A

--
Me
' + ); + expect(result.mainContent).toBe('\n

A

'); + expect(result.signature).toBe('
Me
'); + }); + it.each(['

A

', '

A


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

A

')).toContain('A
B

')).toContain('A
B'); + }); +}); + +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( + '' + ); + 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(html); + expect(result).not.toMatch( + /onerror|alert\(| { + const result = sanitizeEmailHtml( + 'mailwebdatarelative' + ); + expect(result).toContain('href="mailto:a@example.com"'); + expect(result).toContain('href="https://example.com"'); + expect(result).not.toContain('href="data:'); + expect(result).not.toContain('href="/app'); + }); + 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'); + }); + 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('strips nested dark mode rules, host selectors and unsafe CSS escapes', () => { + const result = sanitizeEmailHtml( + '' + ); + expect(result).not.toMatch(/prefers-color-scheme|:host|javascript/); + expect(result).toContain('color:red'); + expect(result).toContain('color:blue'); + }); + 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..339cfbdb5e4 --- /dev/null +++ b/packages/email-renderer/src/core/html.ts @@ -0,0 +1,256 @@ +import { + type DefaultTreeAdapterMap, + defaultTreeAdapter, + html as htmlConstants, + parse, + parseFragment, + 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' }; + +function isElement(node: Node): node is Element { + return 'tagName' in node; +} +function elements(root: Parent): Element[] { + const result: Element[] = []; + for (const child of root.childNodes) { + if (isElement(child)) { + result.push(child, ...elements(child)); + } + } + 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(/\s+/) + .includes(name); +} +function scrub(root: Parent, images: ImagePolicy) { + 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', + ].includes(name) || + name.startsWith('data-') + ) + return []; + if (name === 'href') { + const value = node.tagName === 'a' ? 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); + 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), + parentNode: node, + }, + ]; + } + } +} +function documentParts(html: string, images: ImagePolicy) { + const document = parse(html); + scrub(document, images); + 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' || last.tagName === 'style') return; + if (last.tagName !== 'br') { + trim(last); + if (last.childNodes.length) return; + } + } else return; + root.childNodes.pop(); + } +} + +export function trimTrailingHtml(html: string): string { + const fragment = parseFragment(html); + trim(fragment); + return serialize(fragment); +} + +export function sanitizeEmailHtml( + html: string, + images: ImagePolicy = IMAGES_ALLOWED +): string { + const { document } = documentParts(html, images); + return serialize(elements(document).find((node) => node.tagName === 'html')!); +} + +export interface ContentOptions { + removeSignature?: boolean; + removeTrailingBrs?: boolean; + images?: ImagePolicy; +} +export function parseEmailContent(html: string, options: ContentOptions = {}) { + const { body, styles } = documentParts( + html, + options.images ?? IMAGES_ALLOWED + ); + 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') + ); + let signature: string | null = null; + if (options.removeSignature !== false && signatureNode) { + signature = serializeOuter(signatureNode); + remove(signatureNode); + const prefix = nodes.find((node) => + hasClass(node, 'gmail_signature_prefix') + ); + if (prefix) remove(prefix); + } + if (options.removeTrailingBrs !== false) trim(body); + return { + mainContent: styles + (styles ? '\n' : '') + serialize(body), + signature, + 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 parsed = parseEmailContent(full ? input.html : replyless, { + removeSignature: !options.showQuotedContent, + removeTrailingBrs: !options.showQuotedContent, + images, + }); + const shortened = parseEmailContent(replyless, { images }); + return { + html: parsed.mainContent, + kind: 'html', + hasTable: parsed.hasTable, + hasHiddenContent: + !!quote || + !!shortened.signature || + 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..cf51a0ae148 --- /dev/null +++ b/packages/email-renderer/src/core/index.ts @@ -0,0 +1,14 @@ +export { stripColorSchemeMediaQueries } from './css'; +export type { + BodyOptions, + ContentOptions, + EmailBodyInput, + PreparedEmailBody, +} from './html'; +export { + parseEmailContent, + prepareEmailBody, + sanitizeEmailHtml, + trimTrailingHtml, +} 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..83f4993060c --- /dev/null +++ b/packages/email-renderer/src/core/resource-policy.ts @@ -0,0 +1,39 @@ +/** 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 { + return Array.from(value) + .filter((char) => char.charCodeAt(0) >= 0x20 && char.charCodeAt(0) !== 0x7f) + .join('') + .trim(); +} + +/** Relative images depend on the embedding app's origin, so are excluded. */ +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' || !/^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:|#)/i.test(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/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..01c094b580e --- /dev/null +++ b/packages/email-renderer/tests/rendering.pw.ts @@ -0,0 +1,284 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { expect, test } from '@playwright/test'; +import type {} from '../viewer/main'; + +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([]); + }); + } + } +} + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +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(page.locator('#resize-host img')).toHaveCSS('display', 'none'); +}); + +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/github-pr-review-dark-600.png b/packages/email-renderer/tests/snapshots/github-pr-review-dark-600.png new file mode 100644 index 0000000000000000000000000000000000000000..98d95f8838e586b9950f31f97708a8c2868f64ea GIT binary patch literal 42606 zcmd42V{j&I`|cYX6C0Dk=WFbFL$FmQG_h`$x!+8P%a*cUJv zaS=6-tn+t=pZi-KJKK~emi|-d7i_a5RQ!V4oE;)Bu$Ny}EC(nn=hCm8tTqKwv&#Je8 zUZcOeM_55bUtxm!j0`wd&`ka*sQ;fY5W~#yR0}6keP)>==rJPx6ta6z$C&9Py`C67 z^}+{C9?X@rzQg{#>Q1nlVYCG3I*r|v(r0R6!)A`+6d~6I3Z9cfT0^D5T>QtTmeLIW zwj>={u{X{f=JC-kgG%x3_kF1YZS&ARBR@@pXmTh_x`j#!eo=wQ`NmWaO_lJPhZ2?> zoxZ;Zyam6l=6*is4p`9-M1DTyN`pRsZ%@Ck1CfG^Zn%Krh6&%4->7b1fqFG>kl0Yw zIC-%$3L3hff;Yo+&C-=Q!$6-WI`Inr^JMftVbL7d;1Bh2zj7F;al>0sad{g`=1Ogmad%`~z ziq$_Ofj7AdI6%E*+)uVwrrB(8{kQG%Tf?_vfp<;#&etj3zAu|XNuS~Ou^+QQtvB!0 z!O!Xbo~N+SI@))|Poh)c2-c_Hhr>tP#XE@PQQuoJ>Y#?zHXOd@Gx3vMnz+I~b?zf` z0(edG;miDb3>^3nd-f%gg#SD;d~^P+L8WWB5t0cM+W(jpdf(pq6u`WyC$nq3u_*?{ zYOZPdy2@5@ajnrcTrnjKu>NACRU@`!dXC&&+^{Yu@Bf9U|E3%Grug}TR(|Idm~Gp= zwz$C(jo|u`_<3ijat8FcnKm;2`z}fyqgrMqdiz%8C}!1R99CB3j}wNIDI<5)EKLs( zj5iJhE?me!W8OjlCY}1d!>RQ6Mv`=1EL=p+qb$ZdXDnqlf?9E3d}VAKw^?O&CJUfq zxY}pS8^XEVFf`_n)JBum*4pa=6;>) zb6Bn#2`sXjMDKFk9d2u{;P~hgg1KCX{b|PROvv@~!SGc$>M>%(e9G;QUF@8S)oKOc zfbAw>BK3^uqKws3#J*qU@|`$hmNhEYea&*)djDSMZk3#sfPv9lI23twFaF^{?XI~v z1|bofwt4Z9K52-YyL|p?v|RRq>ruo{iEY4$SqQwRm!(~&q|~dB<$h;jIg1-Pd{V_9 z&OD|1o6ow4O@2J86aKJ@5fKPsxV?mrg#}@%T5W2UebeAeiWDxs;Y4E09VurslB8XE zqMxi`#bH#{XvE3DtIqeb5)&2jhHpDV$;Ymix4K(qJ1J2b5n=UoPE5x zXt_mJj*G2`uV-Z^AX+%tOA0qYMEEXJwprPN}bUGvvZda+s055nLD zuk5(N0Gz=}poB8|E5@6?0j_vR{pTTj|D$^xcU2Qt*zr+6IDc=rqybb(^Bed1L-a+-uHy|MIi_PV~RZY#4!t1*~cqE?l6#cfVL)whD;z7KZ zucweq2>x+9p-ZtNglGL3FW}_y*Z}t4aJ8m?A zl<3EkZWsy=uxSp_d^3f%xK|$5ZGp62=hJ_|VtFDM7w({9>J8 zRzK99yOIR0CHnVZN|fp1>TJ@9DMre1{TXFdZXQ-d&pW{5F~py+FeQ?Llc`_rp`AE3 z3Eiy!{DIDTDQsFK(;HHTqZ6O6grq8s>{GSyw!YD_dgUMM@vLB-eHT!&_e%;fsiWpx z!@?$1-nrDFdNrj)EYK_V9nZ*aOitdI-%|cN#ZGPp3C)=D+(%HJzXc}W0e{;rCNWa6 z)Ele%caYh8dSmQe8?#DIdlqM5^Vw!FzvENo6!xR9lq%MjKzv zv-4y@Vo%RVSzdyklJd2Ybk3CfRyAy0wdLRtPn~iRx)GpD5pJescJY3E_E@RdLk$yQ zx++FDgXv7>8*f8&EY2bry%CMuVSOWPHJG!qVsa~P)=vwx#JHlUn=Q8xJ(k-hqxi^! zcwF%6nHb!7nIZJ@HuF;o@?Og%d3N0+!hm_Fb{Vo)c()5wLu-RnCn&BQ@cqv{WtwTn z9E-$3C#J6I{M8cM{ym_xnx2BsK--u}3$krcGao&Y!C`ts@s)yMD?#aRWyc=dBd^HU zKDv@Ps{M4-!5qd3&M>fCKRV%Kt z0;*^wU_xA*j$#dENyURcnPG)=eOeQicDB#&0casi0;!e8oc8y}f=;J@#)78jix9O8 z7><)M`RznTf= zPQTYG?H^%vSJ4;q<*%AzuE9TtWBI?bkRkMXC-mvO#|Q3|{MoAiLU5e>ez^T*8X|7u z^XEG#c6ZO=GXYof$C1vk#blwEo1{ zvm#u603AZwIR#HJSUpH+&jCEh{#w3m<!Cq} zGC8YZ4h=eg{g1ZWuKlUs68O79HbejsQV1@0Luw>YtMZziw40>`f@DS|CDFS}Bb+j4 zOnDN>2IfrDloUD=hZ+p*YyPNPF=K8?hK^cU@_w9IH@UR=WfK;vS^TG+jQR2RNO(nz zY^P~=n}ATVcgAd3kG!PhnUx8|OMMkh6TjzNO^;^JkPnXE*KQnta~@ik(!Q2V`z|9i z-Il=l10VArRlZ78h^7)J>t1G^1b$m-S8wj96 zuEa<5B8kG1oAlAsT`^Y{#eMM{?KCpT8o#i6`AMsED1$e66Pa1GLq7{BY8c;EK$`T- zpFA;ql-y=!Z>kV?I4*T)kA-%HO^!wIP4X+1o~eM?Vm9w4CD(QW}ytr~M!s2e%Wq73T$}f=?gx&qwe4WrOmi*g^IJnvh z*FCyHmnzneP+TQg*G&mErG9so_(`#`!WHq56(Sv`jgPu{n>LAA@VNb|AHImyT0PHhUX2%!W0ck4s9G|!HA zqs`WgMNS`zAXr_5=jfUKYCzk)D1pF4-uIj=Kj%Mm^`urh4dP52b(sgJ6-zq}RwAY! zfnPf_wX?({e;$odz`P5Nr;HQo(mPWRf>CLw@{2)%M_rEoD|KP7`eifnBhE-+^SI$N zb_$)Yu4R1mZuD^RHp$l& zI4C1~vtQ8K@wX^{sw^JfA-OhV<0&0K`v{8hr`WmE3@>`C$(kl;=14{eT)+oX{O-pC8zIg-`Bu#F?| zU##z6TRLAg&SjHaV!$iJ>4x6ac@b|%2`E`x+XalX;5p{agr$sJ|8^#G?DPO8_NlBf ze{|3sc0sI@EsCN)8K>Zc$R{No?dTm&JaVZ6H)`A$Q4qKhEbEky>%rVIB?U-gs8$d^ z(%wV|+2k}SyD##r7Dr%tqQBGBW_VK2^pf7I+v@W}n%x0jPh zF1~I4FAL~lWypc9yp%h7-dNobZ;?00prUUm`?8nyo$MF}pGYqK1AWRnJe@^<#-|~x zjzgPeHft7tAUm>sFcu4EljbIBlgtha{2?4%-hCm37v+UqS8e=W&Ec25xk6UxpgE0j zZ`JL(E=8Yd*{gU?Vc>453zn2&1fN-hdwD#rLa1-MT9LV!z9E)Z9z(NfA=9FMaoN@n zQ1wb>F_w0{U%1sr{`E63(y{%(;6a3JKnzL+d7w1p<^ zv1JAd$jUP~|K>OUNh|F6HQGAJS1UDo!pM+F@r!x)_ESTcJwbk2K%yjX(lo*_TTugd zQDRun=M}*VWYFqtt0R*f3&I(tjEO^8g~t6HzGTY-m75YHqTW(yCd3S?6W-@IfcrR> z{ZWB#bCco`Nf2JYBBk6)bF0j7sRJ}&Owe#;bri)r9h$PH0BkHBHAgXIP*HtvP70l^ zsxE=Nv1Nx3CY#H^fNWPX-|b~@)AuMa-ZfwY_p%uNMwxpbUOnPEZ9JYHzNF7U%_2nH zlb2)q|1}h3N#a{822$=Of4U(8g3zK|_&4ts*|Va3XVe7Ga0+KVZ22B&bD-?PC!xr> z1Z>c!spgBId~-R1VD@9H#qwdqaJk@7gLuWb0O^RFh!E9tBgKzGL4`Ug##TF*I5gdc zg9>Q~+^?b8OUl=EHI{PA|H4@sgcRArBj0~f!% zcfOei4`O{}@0heG%tXLuMaoi1@l+Jm?6(J+H!^C_g_|AVJB{i_F(A2# zA+Ai##S3aUw4JfPKgYFukgD$BEdGr&gqqw*8*-&+ssD?3I1;DFk9HgC)0;y2)-NmQwkJL!n(rC zQS=XRpy)@-!wFPEzVi*CWRmb#{Yl1rpi)*O;QxP8ZDDa?I`53pDsG&q|3z0Iv?Uh~Ny8b{WXap>iWn?%baB8WC*t;aH}1 zaqq+QK=Zd8CxJ>Tvv4&JERJ)>a2IIej@}vO?Rg$G>w7mz&&-={OzUis`j>zMoj}KR zft@3f0GOo;#Ds4-lzV8_3bmhfrsd=5<#Z(|?tK*MG@6W|Q^;%Q?qMyZ$kmNKf=D}WguCd!s4>fa2lUbZ#B-mSFMYJ5%3L>Oe9rqxWMVn{FlnNN@b$nlVTFFc z5?>r#Pgk&WaiG#|1P1p<4?YP5t2TNOIms`1>@R)g#aR_wV#qBGm;NS}tWH6nd!Jp3 zRF-qz^ET9s+p}isC`wFDczSzs;Pl11F|AyG+jEXT6MH6fgW~u}@}1 zfW>z+c@WnX)+PwgfWud?n!mr*HhJX2Eb2N}z(`vik{L?E} zVJWq5C!^fjO;T;GKL!#3mf({4^C*vK>(~$S!e-dlmERMvRy9z%^)SloX~fL;3r$BM zhUvBiGc9%L8XIxAGu+8x`>3D})QiVID*O34v5)$<&F`8W5~A)8!fnsWp|36yb+gsA zJD9%qRwW+V@iv5b9y1P?R`tLWof0YdTMcrDTwGZQ0t1btv-3G^b)pYRjV4L7WS^e& z2PE#*y;t*iT#z8%<`ym-a=4K3Va&6@lNk%ky^7*2uyUYB)Pg-zs^xAF@c(*J!SIcM zifGP(lkOV+@!GMq*XiY}*_xCFrifj@G3&-TiT%|WRsP?>W<2`^ai4Ef3XIm=eHm=w z)lpFOHqX}DG@ohiq9Qu?Z{@ zT`i1i95BFFci^wj<43SN{6TGW$=i+jF@kb+msj=Jv_H_kv2-(7XPFyvrMlU@E~riQ zh$KlxthR+{)8V|HJ3r>R@iYi^Y+iiY9qr z)+-0Ho__d_bOeH%!fU3+T`iruIrI0SG>g%7a4quY$1iq(?&Zp~2O-{F^s{cy$BWI& zUJ<`@^L5<#3$m`Dvs@-4nb1uQ$rdz)GC&`U6CXkb<4%4EZGLm=H7 z-O+9Mhz9I2e-k#g9=w&$uV!OYVgqMii6(u-eR&vLA}10Y0msR{*q0b$1ucW4^1{eZl5>yV2c{n#!rXCr&4Dy-OSqCWC(S!bM6&;5BQt z;GOC5hwZ-Y6Fq?@?-;Q(wtrgN;Q1G;q3JU_D3^W5$WK}$#t|p8F3$>D@yQz2^ipLf zVf1}xWBfKy!UxvMFeO)Er{(aVd2MZM_?{aHmK;rFkqk!i1% z`8zO|fc>!qy%;3VrjYPwRr61}CG=JX0m$83E5i%Yhw3om8+p$@vhJWprg258Yt38z z(^q!HPq)tIp-r&If)0U~uA+3;Mw_s}tc$G!zEI2t+v2LeszD~5>3)N2tXiPN&%X!( zcY+S=n0)Y`ok|!!(Z7Ff`Z}#^|L-eIjH%^rLhX5sdoOHk;rJ2Y}Nal`JwhZ=fN@!Ov z5&eqP{JlYg1-=@%q6II|jAV93rWRLEOO2K8XL9`Z5f?IKw2+FZh?Zn3QJ$4;oh+Bb z)KY^p$mNgSo>Hto*U>H4-^Q0IHOTcFSFbL++RpBweH{6{f`N`xW(YpuT29{@S zfp46SlD6mfd#ysQUU;jC?L(8!4FSHu=pw+)7&+T6Gg82LCwIBHw5@?YtlF{NHNW6n zcR2iax9cz$gR)aunmTsvPRz{rI2)Ub#+!P*!sU3&mYRhSt^{+7)6-$uOP(Wf);Ee& z_#t2U3lyTjD!=naCe;Cer?y=(904}tSSZjySqQ;K2#J;SX6UFFKD9I%0s%`s8MkM< zT0Ns(3_gPLuGI63!Y_DTgFmluNXI0H=tMk0ITgqkX`bts<(2S&ypU$v?AI4QWm^=J z>RBt|%*=isE8S0OQ%bKwr)S9}3!d^qIBC;*r87{*hEJaKUS@;|nwfecg3x69p^I?J z6d-^N*<|mL6rM8M{MGseSq^nLS0`F`VfVap%&VYRt~Z6pR7v}3`#jwD_R$n?%jadr zx=GEK@PNYdetXQpf@gQn)?7aDU~6~=TZ$39$2$-a1}_7m&VtV`>r2|z5ECg?GhI4U z)lzcmLs&?;D{dDw!>0Te7k3YxO`^ma07Gn&xm{~2ee1s54pXrvV{Y*U)rK8XlVlY! zv=aqA^!rK3CR??#JE67CM*}Sak6bQgxP$9XnY>d~y>zY`g6kAZu*JRnx*Xh9g&{qS zZ4Atx8uFRWrxXsV!jqww?#SM72igQr-Je~ZbK?>z^>1-B5rk>@?HnP#<8P+PuKE45 zQ?9i&l>UrdfFTV60gJ^b4aLbEcSg^bA)E|RQ+CX8MW*JOh08WZL|dTs6$$w6oR0Mf zL~iL+!Lxi%DT_DnzIcZAZIs<=_pmTTgprhsvA@7;7F@0+4a z;ZmlCqp(ihvV(K_J?if)>g$A?Fa$l!{vl@eC2MKT5%b-O(*{vcap+0npWF!~1)-;Y zPs;RY7kM@=MsS9zZLUg*lqq^hch=LhAwUsbmtP{E9PoO!QKB>}>d5{D_v@NbG z65coZSXx7D^p0hfZ%iV0eB~P5M#=*H&n~tbr+z)XuhN0#1S}FU_?rh~AtUMN=jPTh zVu0F!NJ3iZ=FX)ijqmVLYU9yf;gbXSQ>D#TK5O*pc^VlBBUn@A0LScEVJ?+66gZ>- zhWa_owuROqG(*9lNpA7&3p!tl1_6=N`c1&}sBH`c%IR(g0Y%e-n=b89zFG|1w#Vc` zD0c@#)hqTnv_KcJmCCD^S%RZUZ=mNkJW12G32zR@q+CoriWbum><%iDA!#Ox@PAnV zSt+C?-;8y#AW}s%5)bMW^(oUwmkMJujN_Y_2rn)NQ6@fNjx_5X2rU#4;L97KfsL~B z*}Ejb=5m?krGYoQftaWGU0E1&CInsV>4DuNJbaN0{psttS?A9bc#%0vmn_f994y#s zl07!5 z^1uH?t?^d$TTiR3F!rkEWK!e8E!%hkUW}=Jb`Ek2xL_GO&6~SM$ z!OtH92%t&zG*BBh_9%?_lYt>xNSZhul$oPeVs)I{>D}eIm8R_52>cEw82*#vWgg+`)uuwO|7?Ph!M@@wcP{J5}$hPG5+% zm`P#2itk;?hw+ZMMdoFrqERm#gTzF8m%E8Ro`oDv=0|hSy`6L@9V=5t9ql z4BAc?s@3Ck>`a?bPojhs`;pv?$9k;o_#pH|a-5J(Ana^UyEvg$E|Vd~dcTTyliX{s zbPPYtK0Cev1(?Qjo0mEMQfZj6rp%T~+0#YsUNEuKdWE`9%GB|QX+ka_L!Fi$%`d7WM?@iY2! zYI_oLJDmzZMT5?^XzjX4wu55?$E9+q%evQ7!f`wj{ebm(k!Bvy_cN)4*Dxl~iLow4L6r%t4Xu}bmQ6kWqWXp>DUZJbXoy%bhJ~9D zOR&{Y^nmt5s@x8l8NTB;?RUOz@H6w6@I=QcTKDHy8Y_Oc6{H5!v+}F+S~@% zXftKRay4HZSwj}D?NmwH#dD?w_;}$W>!iRLq_MDH`s5E#s)q;lOhN6G)<(l`3+_hD zaX~Q%rqs9xh=j|!gNW0dHK7mYDjE5#j>gCBA{E=ai{{8JibmWg=qrP5qG;&bc=$`U z7a@u(PPGkb-7wlvxNsSf2Db8T!f4gPpTA+fiv$>b@>F{BH9jc>-78fqjQ{9E-fe; z+14d7y|4DXZ$plLGK9_-v33yJIQmD1cl%9p#cal7xBDKFWq*?>TpB<&Y`wIlBRjlb za4lkNc5QNp8Hih!_Xyz2XjP8)p!BDa1DOmFfQ&5#4QUNIF-KEDUEOg9_@M>@+i$DH z3TsgK#6mK2i{^h`OCYupP*Q(HWaZ*T3pzoyWK$vpGDr1O^PpSF4`-PX4jek(5QgG} zp(HJ~(mEvzedvCo$cWc69qp!+&VRaGF#H4y)e$0YCHE&zDwxvzI)rT8Du@9ETd64U z;aoEowb+)YFRD>H^u=TmJYQk9XnQs6AOUwz4dg0u!nqu zJXkfh=_uigb$Z@R_6!1909VjwYMzh?Wugum(zBdv9b8aPrKh4x;&VheJrb@0MB5~E zZsT)(lHYv`+cA5x$VXc{XaEmVUlMG8CuiYYk0oY*N^iZM(guh#J*2SV>3}5Hy#K?32 zSoI#~VO^21I?0+2__-sAG#$3vAPNc4Ex#Ug?I>O=)B0=n+ew`uEw*Cv^`f~JK)1Bol8#<7s=>hG1$oTxv}QX6*H4!`F8mgb zK6>x=cogFxEDI~uem6a?@3>4S17So3y((K(_fJ1@NuLD0XXs)CoHeKi&LEhn%WlM`6)A1RVxzSKu6d_2?0 z$9?&#QT4cpc5I3_0GeHc5e^k3_WLGEaygRYJaK84O_8DJKcAv51GmhKcjQBaLvH0_ z4W*IuBxpM2tah#ks;-WgD6J5Z&voo?)P0nxchvYgwf+#fGenTrws9%%bP!%0*5%eU zhyR;hvvsp8$Zhu^F2>#7sLP_haM~2app%{>9YN?;|Z!MI3O9?n*jPD zAIvS;Cq)l9rIdaV!JQ)K=unxAPV`OkQQ>T;8&Na+IlClTgY5ojSH=K21)}hf_{Wss zXWQQidK#FBw8{y3uyBEi2NOG8&SnMQ-yHw+ISnENqWH)Kz1mOqnX<8+BFVQN8`@L7JrRt>v;`i^-Ccz8}VtM;|E?{w;>vQvvkhJKm zz8_LQF z3&G;E*4EJHqOHrvf0q_!mC&9~b`e6DNuB%`$Z03~;k1jEf3?pk1&7j4DKbH+=r&+7 z*Bp>Q=UrAvs2sz}$xkl;Al~TVnV$Kjzwmu!Lnf&l8X0W+nA~umVI2TEmCs!KRJqpKg18lVf70 z(Q8}EzZzPcH!<;hc^b|E&NQoqPVrDQ--CLGR}%>9Wc9OF9mDA&e=)ua<%R?7eg=G_ zGjvNr3+Unw5FCh*y(9F+ZVyT+sDX6yrybStZd#Yt>-_ZiafSJQDlF4@eRZT0?%-!h ziS89zn?byo>#`o|Id&3hTCx25iiuh8^vAJr7pF0|tBo`2f#heULem(}OVM)H{ZCc@`2sh-gu`}DTZwU^d!h*mg7O`clL z-YSs2;wxV7nUfMP@dids#ehcK=4;nx+S-;@;KwT+9uPa%2!0?f=IPuXpK+qa=y3d| zdi%H>nDpRq$l!BV57YVDeIld_OZqrQ1kbVM(1;U6q--*8h8}v z?nsAO=!(7%VRH%`-`nuQB$0iy8TZwtIewjgt&%$$ml)$>z#;K{p8XF=O>uC+6eRrf z9)9-BGLkS#)?|8L=Brp>@3QG)?ih$t4^~a#7d7LWqA6&T1<6(Km$1y_;~X$5D*yiU zVEZ;IT-799uU#wc{sFEZoEgW{6N}GnHzW_)L-;pX$PRuV@#IM}!3D#doTH4oxc2CK zhd_wz-Hz$%Kti$+dVt@B&0$HqzTJl#$4J%wRz!@-)UJYC>AMq0a&#zCqoundpnb~I zxN?q6^>EZy*PXVSFYG?A97HXyp~ld?(za6=BF zUYa|W@;h79RF=clQZZ{28rQ@Wup6qR^b7xbR0?7Gj33p2t@M_N1x4A&@YvzzTTeOy zf~W&M?Fg8Z)}U@SoIhN{vGx)@L{U9Fg|Okasu9_%Xg}!}ZlgAm0hrTjplaY)w$WQH zYN~R_0%z6E5-SXWJr?#$sGRi2g*gQoTd`>91D(|WE6YC5Z2Z3wYJW)RTA;6_J0D+x zvdp*9&TwRw!W>zADHMquU_$dl&m$FU;y@63V5{s`4rrkmmwP9ig$1>qJPrWJjk%6`*fum-XIUlwqkP@MQLYIJ~I zS`bP{HP9Dvith6mx1vAAl{E(T|U?=EIO^b%6}w6 z7vlXdaeQSiA2KO`|KbOZJv)XXLHBSdE0rRi9rkE8DxSw z;z$S^!GooufPxf&j zO$H>!Kt<;OL-;^OcG`c~KdIgP`&_Pur%&NVvq-sY4*NYr;Vd4?YR)R%`z_jJJCu*_ zFuiIz%iW{k@%46+ZW{V0&eL{xu|{o+Y}P0lpgRj_!u58uWFeOEkX<0R;JcPvwhuJI z5dX)+CIk ztzv=>r-$ET+z#ncQe)=U*#QA^m4YWWYs~K+hD0rF$XURgg>ZFNShLG8@%Vf&8QFG& zX{~4-raaEogWbu{Y^P9Xmp&CNPb&&BJ`_K*$5ubU`TI3d46N{+%mS9sugGPp3YNDmc3l%Lh~Ps z_76^_Z6cpL7i&5YdZg3kWa&edKtWAC^o?(Wj(9Ts2K>2bWq05fgPnBzmTTLo?$d=f zw?Xmb=Jx?b0Q|duYn~N4z|8W1=T%H1eU6&^)HYyFy|MbfWkjo`!T1*keN)CB^skYL z9~iZ=g5W`qnj~_SJ~ESEW!36sQ|o5of6MBZ`}{x=T|TYovdMZPYRJrGWk-Mw$f-_B z!BF?QD_LXC3YKMa?*BP2B|r5lsfxJ`BCIH|zNMUkWqsyvJ&{>l3qw;*D!!$S6UDAT z4v!9%1x;qYgau$^7nk><1F2Iz4AdO@9p6O0C1$9{dWvw9wzyVJc}RBL9z`$IiPC14 z^aSNTCb8mHa@5Tci~AH7B4n=2XopUz=4_QM>6lN*AcIRXp(df=iY%`C*j6NH#k+=8 zxGk)w%}ntQH6vU(KHD?Ln!meCxYV%T+ulG7<*>SNie(>{u?0@Bu}iQxn&cdP2{Eo> zMY8e575b?d4=rPt8eXS@cBY@YcYAhULLG~R3Tdj(h45>Lg0={N%;jJfJRgLP5p{pr z`xRl){9-3_UD;>JZ?M&HoG8i%yuV}4B{fiinHh7yVk<(AlOtTACLZxcX-^izkKTPP zwfr)lGLyBZap5IX&QdJ#i>*r2B`5rX#=qps(k0n#dpsr6$VTIf>MsXc&2tS^78)Wfl68%r`9n)~4#swC)dT{VEej?@{oW-i3>E}$t z=~c)B&Uu0>qi}iR^p%i$My-+rhK;OYk=lQ+_@&K;eUPgu#kf$dKg{-thExg{5;r0* zjo%mMtS={V)iz^@N0j9!3hPoR$f%@oj`@NVxsp84Y#HA`uVs06dE`VxWgq*}9LAqE zY1PBTs`t0hK*Y-UvjUgX$g|!1Rh7Qo4^et^-@5^? ziq6`5bv>5xlr~$V@*yZjn9h7b>JyM$4sp64t)|iA>mLG=Y9uH;iRhBZce87<)D>&@ zvVZxf^$SBW4qOyI30x(8+?TpY5CMSFJ1haaU>~P4>-Yulj-ugY64xxY+5b}4m z)8ufBpC!KPZ25YLahz*2cMuj1oHhTt;G@1$<{~#o&RlwPF0N)Nudf2R8{B9Z8oMg6 zagC%$WR;s7v%F(mLZG&3yMRJU$7s1ZZL<2(>YktZ$mv@bT@UHv=-;aEK|O!@)2(Fo zbiAX22o5x76JL^+ucek*b-jwFz2Zq>STf?k6MgL7!r+$d(8mZo)B!vZRj6Mr(!0@T zEeRA};~*;GvY2|K{1s;n>6r3_FjE2Y{03a)StV>}5ANBf`a^fJdHru+&bI{)4fH9| zg5F+^uU#(oo%E0vM$D~f9!9D@ytu%OE?i29Hfks1cxbEus2C8PADZ4sweNizq%66cNJ|s z>ZZ4yotAR9QA?EP%&zqgFqNON+~g zIwAb#1o{(U(H+kdev5l+)@h|M!5>B_VLdH|!rc&s=RDjU`jBqh6Kw{F@++bdRM!Pk zCm!>B8Bk_D8ett4X2J>HF1jq;cFDf``+|mCGdAYY5uU zt;`<2q?xtt6_Yk^M;t0Togwf<2vC9#^k-;*H|LKoCt69v>lg}Z^}H7cZM+NZjz#O) zQ;K_szt<7{bd*nvb{auNi7`Kr>ohLBbb?M{LKMBLqDIPax+?Oi`QdV_y;~aCi9zj5 z7(WqO?QBr)?D%TJ@)| zhB11_OIY(T;@%2I%g-jKLwtaan_M9gQ9iAKCx((_6tQ&Lv8{D$GoaxIcbnj@>08X) zygFmDMQ$fwVjdPotGhb77hmx;HuT`hFp;UIA7GI`-cE{*`#v5g+H1Ru~$ zVyGm^>_U#~%-4+9T}IW_q(eo!vx@m&a%;%(9Y$PDt5)cExO36jm0OD(5!1zIpCD2G zU_=T__exPPc0Tkhm6uTVB1Nbe-(R71umWEv^;5`S95Mnm=%vK_~|h^j*`W$CsWiW%yv1Ilr>E6&vo|J$x$5>=zIMwMdFIc z@K%-^f{4QoXp!%|6N-59bxJU{S1-4~@kbHb5{DuIxQ5X_lD38OK>*&QW$W-iZv*{A zJcv4R`K{~&ng`a5KWY?z_Fhs8+(-q<=*1<4D*2oB%9m+qI%M7MY(ccQe#_bPT0%gU z{t*iCH7fA8qY~N7Qd$o;06S_*-lv{=F%oGKlcL<`6Hkg=kJ2<|9Jm-1OwIlRXYumu zr|WsT*z1y-QYN)mi-z!RzVmQBoG;;9BX!DM@YU2D#j0T9&m#l4sWw6<3e##mn(Ufm zYR9V?CveR~<4k5f3G`bFDY-C6t~@6~Lrm+4Et~qta2A)?spy0Sj1-DGu=kzw91X;5vsd?PuAoAp%dV6ifsc2r6LV3J{tof}Oy@(0qo;M0&a; z1k_Ao1ymd;)zFvDE?+{ZUF}dL3qM@3Si--M^!i#ghyYpc5Q6qyVIZ@{V#X_0f%~$~ zE9q_*BRXw)Oc4)X-bc>ri&H}ht}C|(Yz`Z4@egW;r+oQF0+nUk!eOdV*+RhZOF^8+cq>n-($c6!wPf@cuTgx%qsop*8%|`geo2 z3iFg*+c_JnQgK7~l$gunU}Tvk`9oVgnC`1ID)HpJRAQZirA|?)iRD=F-Wzf~14)B+ z5QNlgSr%wMJL4kkE1W2cotblzP!4{CB(1!wmO!LAm!lA8kN#Lc5z>Z%YEShx&}Z+F z9G)Juq&8)g3DxbK$m`u2k>}Z_=^&;&kZxBl@O`t@`x{x#!y@sjJ-!A3%3C#uTR%vF zp>a57jCyj&;?9v>4UK%A8g=f4+o!3FR?6qWDhvZF`1P<4azKW~iHkWGATd@{E zyg~JA`7Qv|i+WiuaDNI?AQDgq>j)~Q3GHe&Tu{d!eKl7n;+$B9OS7zBbFyA zpr(iy&C-KoMJE&dl`E9^AsF{9f&=NO5?s3*=6f78=BFui0KDD!Z-Z;8P!k^B;4|rS zXciiezo8x<27m0vZl?f<4CV}LUlq_>g#zrM|PHoCi z`iu3L(=`9OOChj4(nqNv$z3VWGvnqmoim?SuMkh)E0ZW+hrXqx=Gu-+z_!w1fAwQ| zf}R8i%#sJcqM#?eXt4BKDE6G6g zcAa!tF?xPu0HjMf<;|dY6~bc7oxD!seLng7@7zK zq5k<~76)H&AYe;E=X9c_O%*ITi^-%cQu4j4Z1ioi6EoYA2>zNc70iF*2Kc;{Eh6S^ zva0B9{XEG~)ol2GBr~2-OnLt6{p#aiHvh{4{^JADQS$~Bsh80SLDj>5;|OPR(d>_> z-E-mp(+HYLhp?&bpZaF>87FTUs|#0`oH;;(Heb3X{~#NLe#3QI=TB-haVIh^I<9Dm z5zhX{{>t+P9sX7F^1%2**KXZ<(zCAm(oWS2v8&My-C^IJZCW9-6PGhOSMZEf9pv~R zKZ>l?pd6S({l!@NpS5vvDZG&o7WXvjz7@%|cmPbuw=?B9j0hKxDvqYJt42rYsItcK z`1~>+mSd#sZ;TD(-WOupMig}QdWUyvlo@=9y5m7riBso{4NmuAHKgg<`H*=f6lRzScddO|P5q}RJ0S~|1Z^2GC;>hdMt*10S&(xu0x z1Jv=>J92(+Z96R;Gp;JWlcuk3SlY9)-CahpxFO9JpoF$#YQ)2rag4((0e{%Y8^#vg z#zuVF?m46L0RQb*l`gu*5bIw)@a4oNw*8Vq$=G`tqD~>q1lms;6AvM-sOSt7wO9s% zh=8a=CEER%o5xd9?erJ0IckO1YP!vr6v`{6cUK)9~zN`Dm)-83x?Zen7upwopbJa-*NwX@4p$z7#VBtwbpmeXU@-)nZ3%(0ZTAk z&H5#mlgT=TyxZCksp3VWMYXZ6ol!_@@g&Nup4M(dl7P0b){HjL`hTi-y!;7fr<~un z;tdPTMsk)gOnKRlA}xJ4)H&EO0zc6;*AbeDMaF*W7Cei4l|&mgZj@G$wbslE*=$z3 zs3YY$I5AnP0LKawVGhw^srWXVGP+3-K}8Fj%Et3f$dnx+d}8xFh64*G*NCjc02#tj z>}L6`TU8r=b``+Sp;i$Uo)gnl+tk)DpyjR<=vdHYsZHvfT$rEQ2fyTj!=0Hw;W&rG z>Jd^*9YU@kcPUcT%OKBFI{H`m&T}qNAETG{L6CLu*oO%>w9H(=8n-l=a92Yi`PjWY z>>iFuG^eevEfF3E-+DRhipr=5pRxQ6X39mOp7AnsDk9x{S{Gyj#~d_8 zhtKdV*{sAS19;n}#O5QD5AhJ4Eb1(Hpdl~YUBf!mVo3LIT)pqn$2EO$`7F%=;t0-m z+;$j8Sbie-cAGv+0~y51A0^uI@&+d80&R8TTrPkl!dtZ0eN| z2PF`Znf7Vqm)5Pauy;Lc9fGLWmOd*T-7TaGzxLz`VGTo=!l{}I{W z<8F%a5@tIkfEqyw3@`LLAIxifqM|UV-Vk zb;5$D36#^+wK~qNAy0b89KeJQ@Y^$gVdz~htAjyhmlEC=U8fPL%ibK(B7&MC zkJFY{!dgKfe{`fwrFgw|rVF#?hwgVmIy(hh+7BntdB+BDBC9Tn{+0bn7$n0Hh79<4 z_p$sCf5K}Vf|xeQRi4RDcmeqDkFwR;neGmFVFA~YR6D!I^2BpSg1w7Buxhq@4zsQg@g6-M z4Sh4Z#cmNqGKn#lWxKkDcC5c4w-Zdxm?fU|&U)*n0&)2vqUhOHQu`Wa>U51)$pGw7 z)#OM|-4(DKnA6d!cA1m7xoEx)MVz{&=x+e9(?UxBpYpr?qGihBh6SJJDSTeRaNG;1 zUfDg%ZuuR$u{tWE@NrncQ47#;X^|oDKxK+=?=l&>%x5oN1AwrbO3wM zijj(fy1X+U&_UcC{W4XvVu9oQ=h zIkv3hp#Yc~s{U*0{^aK-9AvZv?FIqCxNkz`@&q?nWG4O1LytHJZiML& zsEV^o0QSPiO&}qRhg%0N=R{gt%SJ@@CY^4>s26u40+OZ%PN5Jb3n=$P5)<$BZ(X44)T=EGvc$>z}k;GuE5k^H=rsht2 z4oJzFgj;0Wx#hxN*Qta)@fj6Li4??|pYB!>+@oRxurq9h_a_#u0+({r)OJD6$u?93 zbZ$)?@!tf=?BC#1{i+R5N0bmWiF2-A$53TKyZIO;X6V2VRMj|ah$=&w565bvo8Pj` zd)q*UNb6QHaiHzbCHI%tUuuSb&>a7X0kgw6`!x@mu=;s+T_V2;1R?XkjRtV2nv>tR zy9~cfB?@@a;@W;XNPC9K4|}=3C*_bu{WH_1z;L8ia}!d&wMERv6Qr8hllme7#;g`J z{pnNd#&ZFzT%X%Te*z231@KD=_vH>HDoG{hmlAx_pdG#UuHS)0TkKA!K70qu^Vw&6*IKc^85p05o^9%&^ zDuoE3CwSXmx|y@Bt)s&LOr5w-QA^@xQGmiKRcTS#YI})^fRJQ{1M`rop?PgEDxN{C zGFUj`+2FMWNBiNPJA+XN^LMCP?V=u&96m}StIfB#pM{wRDw)3$>8^jilQT%dozPVO z)|RzEzeHe{Gumdtjpw^qNA+cuw40Z-gKYYtrPI+(lngJ+iOM{<-{cdXlNZK<4pZWT z(3AxZMu2*StkG9I3e@?QKie35U)~DD-3c|`&$R{yON@Px-wSidWWc0t{>uEjF%5`C zETB%#a046M^(cuUM2{YCV@BuY+8;>SdLYN8R-lYQw>NVT+EqX2gCF)=wk;tdO1vOHDZoOVc|#KHxh$ zA<%gZHVbqw9U)mSIm^=P?NO9>e)YU?-u%e)`7vmEA;iKQ6JrK!ytoi?vm{pDNs~K@ z9W3H;J-;PGQ*ow()5XW=N5}T<^e*bddjx^{utEyOxV2ecXM21OgN=b`XFkK~WpcSC z)J2=D2mN_c>9;ctk+z>sK7qycTxCQu9+sUXu46RXAWyr~cUcTS$qo8+e|7M$%d>`- zf(n-_DxP-$!`0r%{xoGlh#Cv!a30mY1a}t!X~0;^v6I$^wluuW+R zUvvX$9&+}V$);ak=9<8bKIZDKJ9wPJwNuzp&koxZUZ4)#WE~ENF$xpc@rrGi{=nIJ zbYqt4nP;Wzv&e-!I`;k=%1MSuO3|~39j<(p^0+~vOL}+Z2QbA4&ZjZec2LLGWdrmtzPSjJ#ErF|EqQk?wJe5lDj z0x{;QSuVwz?f@G+vGmAzM}nle`!79-Ve&MgYxyzc3<59b=tkQvRZ(ik8Bs$b_%vSr z7TaDjk7slg3zJks*ReeR!rN-f@79z(kVqGH-UtCN4>p)?$9|0j7nMM@FWSI(^;E%a zyA*#65bWWr9yYhwp zD9vbhPh}d~yV)wHQn4Zu7gbD|QP8(8C!N&m#2YKn_@fganzSevytkL5T7uu9fnqg6 zytk^k%U%gnicf%`H80hh#`0a;$bcmBw6W{PPP2g6p*8td7fkB<)Av0+^u(fCYS}}a zac1R^??PFowzBQz+>=-zBSS~k-kD@5ETk0-MfL+$*GyUGUtJai%gpke;+Kj0vnEe( zyY^HdCD4MP^ez(jOQn1EQ|KR`De=3XgC(3W1CHyq!=x4+m>u(6C^ed7hBV(dgms4i zeM_7Hh)NJf4lah&Iz?aem#f2rAu{t6em7<2QCNxAL?dz82aJ2Bhxvz_@fD02m30S;5Ot z(_GBrD>XuGz9FYl4F==K{(JdZeswt`k9Yay9|t1~d;4*h2<}Z3%pZP%<9A@q78}!MH|o1k6@2z|&dgHU10r!$y+;{2il1 z#V-CNryct!rT@nrOB6XzxINsa;~JS;VjD*RaGQ<4*oi^DaC<(_lYmFf*#+HxSpQ6t z!j%FNPe8~=-b$V`(mi(S1o(m36Tcu%Yuc{!apw!r`udH2sqg#VB5^%Lp4CfC?wF(D zEjQzi8)Q#=u|&VEsw2F%$tz7N5H{=>GmI)v#ux9P$KX^&(O;{4^enUmg_BYcuHU$H z8Mzg}A_Wz$avMg+|i?6{@j4YV|Y_x~Z(1iGagN$-v@Ww_&?9h<>ntoL6tIN5-SqKT(!{LUe?NO3|`R6_hcDajV*W_Btm)*cX}EL~Gp&e7bokm+7OEiZkNI~`4NH^?>U=T; zOwpsZ8|-&aZmljG5k{R)ri=|Juj_k`e7RgzKAp4zE=~=H3Mx)lVy1z|-c2n^TSk6V zj3krHCqFWupF!n3Z2UrUS!{-$_gB6w4~z#*|4ulR!Vo7pI_tDU);j2D95iC((R zE8-N)sZ4c$SLjAI{_wQ5>CSN4cze>+=aGqE?(_OHA?FKvIy=|k^LEBJ z(E4bTgqT*VOj+_4pUV6A`W1EAeyS2~Cix*-kL?8kZ^I0gu?txr6?@F+Lbpa76XFYz zdP-dvWAHm}Edr#lSt}W%^4XDW=wmhrPDa3PCW~hh8_hL+gGIJ|q#OI|RGP8Eh^eln z3QMBx;_pOOv7rkRmhe}1nd;jPCp^CZ-_UN#3KkUk&~wV`iZFtLb*4o!a=*x(15X$B zXZEr>5GC~A(}nx|UXg@(69&nHF)ww-XWYx=IX;J*o4^>=;2O8EV2!YfJ;CVHXWbCM zzus;vqMUUS-a5bTXCctFi`GkT+)wB-Hq$%?)AG8ZLUMIb-pJL2Q09$S@;*#u&O((K;O-dT`1g-o|IN4a%Ym#=)%*DR$Wi= zu-_AbzOq)sP+ke*b`s4k_)b7EATBAN-|;>)g1P8eCgC8qT?xD0TZiyj>w!k9qS#F+ z(Eh`5Et2FfW{5~`{R%`f1y#Hg%m6MT{3a4GWXO zWldAjcVHwfOfe*YFv&1C*pgO`nRzMM9N$0FbX`$UI@}GyfA({5;p>JL9;T_)4pz+y zKbYuawK_2c$Hw!wfCHkpzxXu3T%BkLi$n7Z?nOId7wZ`>67f4uTX$_3#x zO4-TTacSl`>`sWFmQH0h;%zoC%-=lc8aE2 z3l~I&e?55OAR2h+C<*GX<_&N+;#t#as5 znsy4GPp6Ka@yM?`;ETDRn-`pc_Gcl+XFzW+3131YTZOQ!XDu~5q5z`>(Y73pL(rGJ zQiT4!W0oVDB}eAWqA+T8(Iz-6_3mED?^9nzSB^W-PEW%0mz~*1dQZTV^T!6z3RZJP zTbg9UeLq!Fz~7l>JFwf@H+3EH*XRAUmeu+qxS>pn78xopAZ^I%>oI%}%+rrrv(U2@v zAcqJ9Y3xa*>9OA5pH;`Zp>pVK%@~FdBa#X}7pM%sK-_ypwC(vVE-GnVe;Bp<%$eDO zWSRr-eJ5^^PC;67WYDBdjDOX~F8nHG|= zw0R6IZ>nY55Ue3X?TQZCc!k%EQtrzdCa0yt>1fLPQKD`B2n3oV90ApfQ5po4;~*mw zx?#K7@U7~*XZbrVl4XCVw@Ka%`v~ea0|0ngdC3Lt-YmXZ&^PMSX!7?lb!f99{!oNll@FR7au;(PO1+gn$#R^PX zqXCa5W{4OuaEwdVWTL#h!Vi%Js;xTrZB=no~B zN|6p9RT-{|6)*6TZzf0eLi0aDLvU*p`zjhWK64ci*pvRNLWjuwIurbW%|EO=7J2zF zNnA0KrXq`6RdZa}<+@S5b|hz=p2fCt^$iz$R)?I!F}~|Qs3G6Ln3p|;PP#1BO#40R z=%p|gF1$y}_LY95bSQfewQNOhsEWtP@8OEaQcdJ2K|Pa)&k*eKWm9VKU)c);0Q8w2 zo*?Div%5sTi4|w;c`Y`U2sGd2E!#tQevM&%^CZK|xToNC%mGzG$0R-GUC}`PZsXc~ z>I1)--t|Xg03@4x3uor{V&Vfu##SJjcvuyQj((QQ@0Yd^@kHh7*)U8Y2z6`^T#v}R zjBJ=g`zJ86Y0&PgvmQdo{L7H~z5rtfCMlRy1p5}D-g&-=K+vTK^03Qr=O3+K*=?_8 zjbHFDef+T$I8onW!*0ppH1jA3H7gZDM*+fBG^uSFomCqonBp)swR+%sL*8cvswbC`crh(wtj)R8d9&H zH=F;iVjcx13bJ{c?L9E+;KPfuD;^hi`@d`2i{7rOgv5sQUFT1r(ZCAX5P=7+wuUo0&}h+Omt~2J&Bj=I+OP05kT9CeZhD8&E$3PJoeAL-rMu#;-7+tKX_%$_*#D+`iWqHW)!%;+P^a_skVQ; zq5sPwftvzO;jXY}paZ3(YyHHxB<0T&=i0$_{ebbPeoQ^h`TV{Wi4Le_!@jeEd~Mw! zc(uhX4z6S)On#v-FtD9Ei_CUhLf7+5*X#AS1`Fw-B@480;`Ci6~uP&>Yg!o50j`qx( zBMahQUpR>zbW#U+UG^o06>=dG`0ai(+bAxw{(p?l!(jLYwQ2?0DXzK2}_E3v_U{!NV)>2|f^V1y* z4VC*vu$yr*UEg3+j4VF2s;bG-=61|g@~5b}U#MQL$ZuwEA_k}!wpn++wNQz^WvoHV zq`>GTXnLN;QeOk`gxERM5! zb0@)D?k5z@7$fDfBmP04^*G0st7C1+AHupR4CEt$y7`(nMZI=$bS!1TrC7va_XDvP zP|8||HKf9C>9E0}|MNg+GN0xLrCkr;KTrv56fq5&f@US4X%gO5<cNvv!PfTJ_cPwEtqbe1(B@Ni;pkp7!np}#MHU@CgB)VN9LpWn*eALq?r&&aXL>o>A*D4k3r8856P_27=VXybplSuZZM=R{l931LzjHhXJ%hz|Hd zZG@jZs)UolEiFvs{^ETSfT4)1&1tTtua7uEII%eFgv16ncf~cSbdk6CwqqjIgzE$4 zhoam;W@@d2xfvF{K~AYAE($MI(k&rb`Ok^Ea~$+Rl-5HQSGQ=^JByMD311+8si)vg zq}hzCQDVJ8WI&%u9)w#s0V7D8`J!h(r%4@R!?+ZA+8T|?iz}5g#Fk5lsR6+V?C>Hr zlec1Yxm2Lu`_a*H4~12l$c2b$#;FLIO3An8-`oTOKJ!|HGaug_$Q+vtTbZI@kbkfc zQKuH4+!n3zFmYwaJNF;oos46^yYOeQq{8t6>10{xaMsp>l>H~ZvWC49 zOCc$XQY-qK>JG+5Uf^H?lWrpdztoy{p@3dltWPdAt{K1%_y0R{Ty7Pd}~ ztn~NH;XNwH|4hZ1{UmFdip8>N4@6sDOt5XEv0xE^OAq>^LHehNb9mm>sD4AFJ7$bz zsokAcAXN7u46MSi>QlO^g!f=qak>hm)qY-296-V{=C644++xfnY znffbi(G^GwkjFQH4V_ov5#IbXK#Rzj^96-?dnFrY@S-=3d59T3>@E7}1D^mA4U;0w zI~}2$9AY#%Q{m9`eEoy_MLDtZg3_!y$D;Km^nJQ5K}rn*xKswJ9GFO#=%lw3;f4HQ z7DFzDQ0mDSlTes@KNZ5ehOz-^*BfQ`x|bLd@ zxxk&d+O*DEd%IbNG6QmZ+LO0n<=3gudWrgU`3#d^<~`WCw_@9XxyPiPa3})I(aEBUYlGlQNM-9b-oQUvYcOo6WvijAn7OZpoC#vpr2eCQk6c`@QXN&;B5rw3KhHr#y*|LXvz{4KcCd=&aE z8hAk6Gq24PIVZaRn)ajm)?%RnYKRNkVtZ{dvT7_-NPGz@HfO(t3Gl4~4s=tie{n%< zS_8zINrXP!)M>}QaJyLEEKG74;rTlX=X|yTs z-{T_l9OF28{+DTZ&%K;a;XZU%xj7*ZjxJDByl2|6+L=Vu71tf{3v}#FI&kK1@sbx^BF5z+6Ao7V?J>FFCLPaE5*EvN<{5uzlAz zlPLxV)sTa?Es|_~r1axQF-M|$vzGJ2x#7(07-R`TRlJ1c;93V5m4DI3k8jLp$_}ER zvZ^;zAQHQ&cLXvd^-?kj%Yw>p?QxzM)=p%4(y{1L?cE89Xb3FD)I5o58o=sD|>a%lFG)!T>72VBfj6 zW8)&qr_5s>}Iry_!Ep^kyBl$UH)A99PQFg~E#r25XLOCf5xA0 z{6<~cBZz>l381H(dCO+wb|p4wZ?nQuSgoa0iDjW6v$#2tq&XM^_+RJ7wv22JgyhV6 za&Hec+FNNhJNCW&RAa`5a?|2Xg2PQ!My_W%2Z7<=$?^{0m1*^=>9RFKZjP6xJQgJx znc$3$gbjO%@-hm85vH9&_mXLo;n(6q7D3d}jLIWEj-&pQN@mlq#hXf@_m%+=3dKJj zz@*XqttLT;!FGr}r8m8nX+1j1tJP7Y+#_Iqsy@eDQdvGKJTXDJ-|@%3a}l+dKd^Iq zci(%9A3yl^g;~m*YFAVgBZ?mcnJUpZloI^)Vna8GBQ$H8kcM)AIp}TQ?|2Ox^{R;A zf=J@L`XU=uWb{Yi!6GeFe?teWU?4h4xOdE{1uTHACB5h8DSoC@nTi%EDA<0GKOX3N zL8Jt_RlC88W0$hW8)cK! z)IAW0S6NR<;1T^eL0C`N6Ia%_nY!q~+PYqUQTpITSMk-HEi{qxYd3JpjPy&xr=+Wo z1hGnLWm-Ocow5d(!fzLqF0{44%6Wd{F2=uNT5&YItp45%%p(WTp0|A2xczo6jgN}` zuaUL_I;X1IEEQhG*+TV0R`QxW0q(ei-Kdj3BH@#59NG{3Z3OXmgKUrx9_X6O2{lAQ zPq{c_9G65f_J2dz{+^dh{L#B$ery~VDJAzs{Nzz?DDV*r+ouyb{48&$Y(>vY=JR{`Ytt2=uv&)*ov3i?ug`g1d40 z4|)ySXhuSEQ|Da1p`{H?L9TG%8W!<|wG7apQ2a)<(V+)#qm49_&HQASJV6slrs0S6 z{}yf2(P%n<-QKR=NCSwe_>Q9=E9WJSTXdCs!(CMlpLW{b&lDl9cE3$a}aCQmdE(iZ-6xy9-FQ2>CB*#8#gKD&7^!Ll}R!V=0)JH=x zN5VpVaxMTSjycNwC`W?jILvSJaZnKWw$(aP)lZVr=Q1#zKJE$z#`!C8OXj7@o9$)3 z*qqZE8B?6sp$gQ5!D+qM&?xgkRfO`z*UivHCL(wjpG}xlt%jP(_5@~Z68fDz38o{n zY$@hb?fc*_(A-qsfLlSlpPF+CnY$|3TK???z%GqD))Yx41499*8K}O#@Hm2uOM$x8 zo0bG)p%3_pZh*Q?qu!U*2fr?Is)u^+{iE>+btNd0%>~?5%VjoH{?Obd6fo?OJy=is zH*=`C{U2axQBV?CO@8Fv8mM+(n<++NVtR2LSXvp)3f_VEH9Nx?-T+ezW*wSvZNt;` z?LPR|G863GFnX-qOW0KZ^-JKxw-FY+9i5K+KHD#E_O-EJTrH6+=}TG>+GhHuhVKz> zM|pUY`eUQX_s^(NjylJ^!*dcu>CaOXzrAeh99yHZIt{plw$h%vz<+jC3@(_MUP|kp0HUqSsJ#^4U_WvavB=|Pl4+>%; zn5ivw92+ZlL79!&MId5!A+LK}f0mb@7KlirLSHPkq{|%UbJUz?;x#w&2+-Mw1Vkb& zItM~p-=1zK->Cpt2p@=nYb59$m%C;V=c4YJ8rr0De2NK?VZGFQW1o+OzLzaG;cw=~ zjC})1JNFdowH9W?7GZR|b53t`fqhLd{gc3+wI76fj@)YR=l6!*xI0hhNK(Cu7F1&4 zM)a#|Df&8ReJ}D*_4KRZyI2ahz`Y#K3dC5jQLhgtHh8h}HcO?gkBw9Ygv<3E{SK!T zvvDhB!0aT#xC>C+zA@*;UwWyc(IrxAkB{Wz#Q=l13i^haroXu|NJonzXPB`wFh~9D zotm0*x`_QBhyh%+CS?qm28DL>S<~asL>96E^MDxg+c)ezZ1q(0JG1Im`SzOVW z&o;HV)DZ{_b#+DVrSlW(^3&kdvZ8=(C#FG>Sk`%_ho5Y5HQy>?^HQj^+vtP_nG++p zZ!F?LWf|b65FQY5QhB&=upmlC?_$0LyfCzJas`7bN5&!Ii687gc=r?oOXr z%Zz7BClp|b6NNPnkHVpMM+IX@Lo!{+iOz1m*}zV*9=y}xeESw~Nb4$r3?`bD5A0+f zttjWG3)Ht!*-LH(;%>~#Bgd_&P{0|nQv7HBA}Xho=OeX6BgWQGUl>HpDGoc3gJgk9#ol66^v*voctV-VAAd-{9|H| z%%N|WsYnu4gArb>A-TPy4VO3SP-GoMu!vud=k8Lqa8E+-chbz9haa`38Uh#X43y00q1+;SQQGv zPkmqD;}P`sD|*eJ-;}&;nOYv>l$H`{bLC8LDI}4+{aDg9eqCZy0C;g8!L!)7j(dqG z=;TN@b@*_Y5q<0h(*A~7A0Lwt#??lX%=lg6+61QuAVPqiRUIcYw2_PbC=+kq5@#AD zEU=9G$rwQO1H#=UMOPRIkKtID*)qSV9iyzYXkkiG=cU?`2>p@7HK|zRIN#PP{*F4u zemHLo1#IM zopL{T$YzKjC@I?r3HZK>IH?5Q{30aySDmoe8WTjGbwJN(#qZA~qg3gA(k`uJ?Cnk7 zt}qCEUq2-t;@`;THi^Avc?SIrly1699iU#|aR*LYP=x~59Awd%3#9~+>H({>X~2U` zcSV065hCR`p|4xTylee|r<1?}F1`;X*S#6ZklybpFM~DJtz)AO{1Bq`u+FXFmUC_w z!5b`NH*%1FP+DBZ!+#63c32K6+ccjhUo_d2hqte;&hmTv6Ripykp@-I0p0hyiGF@2eL#W?a?Yad35Yu-)JTK5uUN zd(liX5X4Ya|2YhnW`8LJWzWIY^p%RF$H@i_`%k)A1EdyGak{Oc=XwXrC(iiync9U( zkDzpgVjG>YLHSL6`6bX*0+OfuxFoyNu1Y=ZV9OB2YNaC}#M= zA~H>xDJa64>9%}e=D1XzASXYsc6ekoXx{YVQ(1{RL`N6 z=l96?-3^VmguF=l(haLREi*5Xr)mPMXg(|R+>Z0t5Md=?Yc*Y(aW3kF`txVC1BLRh za#alk-oXqeOc9TQX~FA3Nb4OFhck4vEV`pr)8zCK$P#)Ceq08@+0uG$xN}4M6c^5Nv|{u(_y*<3 zERYL~dV9Jx++@00>c|2|Aiy+MPM173Za+{CBOL7iA2tGJ*d4>viOu65C)*{AoXdkH z{qkz}7;Py7KHi#0)6p<`Wfyg) zD|+6BFeg|cs5&=6J8a{*e*Ras*UnnBC+bh5nOAxWz7o=#VUswX! zjNS?V0oT9G@iJ>vGGKH!(_^~pu1Vno;B`i4803ANw<84|DnQTX9@f7o2L*fl7DoYa zx|-coO{RjUjfU`7PucS^BQ~fO{vi+bw^sb}%Tt7ZllURy&GpMrDh`EViQ4{?v^!I5 zXK#6#;ih8;ReQ@zzqs(6f2##Fu9mm##w`(Wi8F!~8?qmPbeX-@Wz3`lMulBPv1E!a z?dv1jC2$u+hXz#QL3yDe*I4RaJw?u$j6JWwIK zEY_FaGQkhl)tmARFoEu7my2acI1qliflSX19kB>_zf zow`5afI?A?vm~L*SsZ-5`9ATi$mOo4UMhJJUoPWH;3I8JNkx_7MvbGCq`;8u7y)xQ z;Z|Cuw3G;0vz)}Bq2Awx<2~b1UWh5F|2O9hys1CHS{W@qcDE0o{-p$O%CtX(pd{*fEJW>EzfL^V2=bMt5z?oM?w>LC?NPw2 zUd2+Xc;`e*Q(;b1E+6LdJMJx*hL%|hB`;gG?`-0WCVQ^`T>rUcG`ey%hcmu8pX4Js zS={5-uN6J|o`g}Khn%EbJ7uY?{9=oV&~Mp9q25n!pT*@~Rl~3!0)a;>5>yA!n++)? z1cGF*yQcRCDEAMI&$aTGRmf|`92GVwtaYu7ejMYiFoJo?Iaco{J~eMnS7t#f7gYX2 zoO3-r(4g&&TP;R*NWFVrCAHKotxUfuD$%W2Tq{$*L(L35;LBYpzP32xT_nsMAqiZ| zX9%#urs1#vtiqbavCp+H`*mclne;+KF4>^IjBQNdGY_qwXQm`{7OwvXm~T(bcI7YE zv`GGyTJEG{pA%QKEAgWYFH^Yp_kDdkM~{$W2>U&zF;vEyCxoq;_ z0EDa_J>JFRvagk!bPL%1kjkM`#zVzJOg_ovxI+e~;+A$~VW?aMoBU<*PgqQn!CHXg@DgYfHeN?Zaah7AMF{%c{_moyjdYqkP8syZ)Rv)woju6lBp5SD(NEFY=qlYxMy5=Ar#t`1ze*;9*Y;4 z&z{!cVNSA06<2gPhq@-9Qh%BOj>ryi)D>z!^t@=6J^?V-PFiCO&P~HPV9lnQ87r%T zN?UuOFdq|PC~QxDU=9}1Y{V>X%DmlYuCht&{B-!8?pvlQ@932K$SmHx=4cdG8A+GD z4Un)xRBht&Mi$XFhE{hvpDh%UrqKb_zQ|*PUw0?&t@B{5XbXo^Li?*Fh@CY9ZR;%V zI{&EpElg33mn0*zkGjsfw*6t-HgK(?()hv+HkqfSu`1Np?0Y&Exu^A`zKKg_AAg;I zah&JM!<>k3$-lh-xru-557=e+aJ<+>A$l5@kt#3 zzpn*3H^HnnpL?&JcA|+WNU0qAQNcUlrFunk9D$EI;YypXrK2TQ%~V%05aakh$%@E^ zm_!T=3faUoH&Y4MV-KaV_$iIyzqDGj&%dm@5NV&~7NN$1qmV^Llra`tShEtBY*lTyaV%?03KK)gAUd_GF zdbVzMavNj_w5keu`tcI4wcmBr-YM(n8IU(}H){vxxR(rRzFhq4kr2#`D_`^lT%&0! zs*mVIt(^qcdch~eXkBk`3b@7_$D;>D8_o1)2-&A*7V-shE(P9fYH{cbgm~-$g3!=I zi7WjFD9fO=IWvv$toP3iwy{Nq!SL2WNFJQiLTIARgTfAzvII@F@@xnXMdvhQ3V$VL z>(nODG$~&<(9|+_I1|NaRI>Gi0Wk z&_$k8CMWIy(N=wUra;~m$GBZWX8}U)nHDkwpME<^M6y!Z2nl+(?+sskRZN_!4Hr2{S|V!BaWz zXA-p9SOBZMzV%5Ilb+UJd(+Qr98XZDbo9|+TrKdi6U8e3{vD-etwxaqwq9~n($ijF zTjw_-=nBD{6l#=*PUWiYsFPFoMAQJyql;fhHbcd(T%B)B)$$~X1kB;%7Q=%jeiy;# znBwhr?<%swEo|^Ab!Vp}2yQv%K9rsR!5a&`Nwt#%tJ7c)pC#VT@PknddU-!29_`8> z-z3iyNcK*s}|Mg;jtRPuQmFkoilW)`_ z%5oPZ-8cs78&?TEOedl=*J3D_)V3P5y+EiB1|GPI_x6nmIUGMRboZy3oM6$={-orN zzM=e)ig|PRQ594%YlmmOLr`fAGizJKi!BWM%`RV@{SUJF&EIq#?9EKXi&%7t=I1<# zCg@ZkF4l#EU4)*r^5(fVFWsy%WPm&72++z4rlx5RSQk69zpSxc&Wo`IdMk%!-(DSU zU{5&TKhbf2yp5+N&HhzCb%mN2NHZu6;6~(f7FPt4ACZq2d-+VI~2Hs8r%1!g= zJ~^`9y+E4$)FOcQ^12Duhz>lBJs2<{w z?rD<{X9m_QyWJ8&WPiPEpV!?dY&%eL=@TbfhV zmzQj*)_z&l)W;}04*i+2f?&__)ud%?k)qoISxqUtNMI_77QK(az3WIf=|+Pm8 zw$ND$DAN6B#A&Jh?PLF}l`|VPbY}OvrMAvYJ~(k3#x4`xvN5HU;nBu+u5fYl>v--M zq+@aCLx5v&ncDDMwbU-6XJiS;NtGg1Qx;h3PQxCQ z+3AOf;VaOnnIY2x;8(6Y_09lIF|#dUD^u3H^xkf# zkfCnYcekfiw@-^>6|sr=bT~yr!Rg`kYyv4~=r(;%D2w_egb;W$mYBPz$)I>`hHSj9 zC$R^c_-axU)Wb~X$%Z-r-+VuL!1;WuG&8kA$~^d`MgZB%4(b2t?X05Oc>A@DOR*Lx z#c9zLm*9osZiS-7-Jy7~;#w#Wq*!n-8r-$T-3jg*+zRCj?fZZC{@#PV);`&%v$EzO z^Gq`Hd#?Ms>qbZ_j`5-{9g~S@M8|BRlofHC3L~49lEy%Sb_IeZx6P3bu&T|(tJ6jD*++#s(fEwIRnd!6F? zYF{;ABEZr{`dfSH4NA1>4U4!m`jh{l7I5n|>nTGhqDy2qk%o?+%l0nwyAsFJd~@QL z7R%!If7^B+vcb5+&Y&1~d}d8zZ)_VZIU1LgSr0X7d9z!A4Q1Q;gB2h_fr#2=`M?O^ zc)s%?Z9}o{DoM)g&f;-6RzL~16INTf@9bcgi8BixU;G#kmq|+f? zeqJ-`nme<(IH{BoI?Lo|zpQ%c(h|rdNJ#1vw9cYGjz_8T0vhE)a~r_0UAsSvIAjo$ zwH~~a-Nkb^`x1WF>qC~!V!Tj&YoAai`F7I|a{|umDD%Dr+bv#xaw!km3O?*RRVd^7wB}H*kK%qFX7B{YVtn%D^Nwpf% z3%dj65ScHY8Q*2wu=KJfpW@+8pbIQ6$zT3Tv8cq1Vug0JA97#p-!85bu;ZWqBf}$@ z5YICq$F1$8vy#sH05`{RLDB?=nN1xwDRH)d!y1MQ|tBWpIT|OEH)&$vt zHdkT-xk-EJD&3io!$JHI#DHWHa_=7k0mw_7I+?-=-CS&1PJM3_GN3C7CEyAYj)`sO zOrNREEGogq`GM-ZrDGv%x$4J9lRvrj3Xf&?L6>~YlI&jiVx2o`e*p4S2ckY-q@QpFP*N|_L=+TSo zZkE68NH{C;W8Hy=k<}~+R1*J-j-_C(*R*YTyUkzv+F$##}!Hz3~?2c)UeYrj~ zKE%dw{Q$1-qkaIORW`j!-)lUs{>2R|aV+F)f{cN-kH(c2tnendlvvL79mgKYIWY;8 z5U4VNY`I{oUX%}ccYfC?a!AIM>gVLM6DybFtj;hB(k;$kP7z8o`?}6as+H((Qf%$X z)Cjdajh*5dXTNnRykK)FdM!}8+B$lirBwIzzI97`1^eQUK973WIcVzmQX85w#&UG7iL@B(=cV*3`lY9%Iso(L4A7BYv}6O;_JLSI_+|RqW(ZjnBGHKyRWrQWAEK4 zH-G87=*qSX@UT0@G?-oPpiPivFELO5h+@60M2J>jQd~)3S&gkau zUR7yRteGiLqi!(%m>D()OQw*5*s@Iz-exu$sC4}jRdP62UB6Q9ZS}oHY2B{4OqU9u zz0!rfGwy>K$0%=w-(WmQ$Ogz3@W1lqTZ7Bup(p&kqlEo->|T>r2(C)_9kUmogX%<0 z*vkWa?$6!LVHI$Q18%>72mkNL?+{r&MsnvZ)TyM|+2S-hooIPSwoZN@%Un3jnT5`B z5*mRZLGXIeVqqB=H|H-MyXVVD3X)VgF{`MO3BoB>2nc1#4%HkF`J%i7>Bf2%6mrn7Hj(34?=U-6tQg+t_H?@2E(-W3yohMeFu zWy%Go)AViZKD)$jkRq3`Eq|TMEY>b5G31D-v^QvRZFslVqD2)EnjL-^OMKmWDTGY{ zn!@~N7SJOrxg(vbxLK)HVnUc$J|Rx%>1x+xadkdmEw)?Mg4`oaLUleapKF|W)u$`N4~N@PE6 zTVj1<;c7UPKo52g!sxeQZ?aS9W%czW&gkfgcNhJ|35$nFBacwYR&(gPF1-G&o`04E zxx$YFC7@A#kdus$@?+WJs+&O}ivO-PmKG`-jX+cADLpvb$lUsjtVLGA-f7|J`?~W> zthjkr0uz8{%f70vFoYvzc2|U3kLNqOjWDra>6yw>83Ag}G+A!1tECHtj=!eFQ2}ZH zjJw$AI6He{IKtDL?3h)(g? zPpH=RD#A%kB$_FCPv&(^85yd^I{soYElx{|>(}kS{LRM6AO1!k;pfx5iIp7DCTfj8 zWEE$Kc-h51fLUOu+`tr$Hd89VCbz`(=9LqU~#-BrSK0jRX464n*4J zCPm{5iiCllL`HQL^`QJ})fosWYT7$nVe;A%M}(8JW3(Q1O9$n*iUIrf?qW|41*8L* z*|_Dxr({7H9UR4fU>)Yxf6?+^ueswurm-hcQWvLDxjI|{Zx~PhE-HKv2s>zG5%d*a zMZf9&`+>SfC76>;`HTH${LLkMU^7YH(uKu>(Hyd#Foo@ZcmBi3EFZ*EYBXks`hbsg zyQTL@k=V)IY%lm}M=v`)WpnOw2J?EV}fzfFz0ml5z9zhj7G9SS=jL&p%BHlP+tJh_k z6M=D<61QeN?J3+vI5(-0hcNm!l~+oU*1RCtcI)EX{p6R`tss>HOzqtyf1FhE+GaR7 z-|U0QXCkqIdT*kCjNk0uC)I74WA-MzNCaBRZjm z!v+Z+BcHsZp>Fel#^S`*jv(L_uNs=2UwFf1#J^YYU!!PN!Qm)ku>)WV{L__uLUS`o-a}r=*Gv zbM;!-)#{9z@R^=-?9_fr;c4_u5$tJNI|a`gcJk0u|8);hY#S>*J-yH6EuZ=B=8N^z=>Bz3fuoKW=koz*tw4d)v+m z66xp&cqyBJ@7}6$KQ1K9zhu;OMdDziOuH9Rpy!lrd<3!!j%sdbD~q8bUaWF+z2oOytoa* z3Q0Rp56i<}#`!(ep;TwE;zQeK48rt4OeM%IGSA3c_26+^8}>5Ms-l>2XP`fq-Mt7^ zpl;xr!q71dk$5673oy+x@-71xJb>XNBhAzbvAFJz`Iqlbf5DZ2IpV$t#3^~7%U&35 z*w#n5wu_KHMq6)PUH3z|(1ww40bFVNP2t(+zXG_@8+d7KIQ3|yQG9U)zXLAQ5HJq- zYXC$ykqVu-$c!176DT9cp(;#}D2*+6K$m2VKeL19A6;NC?P%d`wks4KDfVT#nsjo( z>Dr}Ph%_SCEMNA;xe`ohRLo!ZCP({8e={eacNL4(x?^XyaV=41bndbCh0h;UeE9{l zb715(So3G_VgjSjq$EME%$gv<7xlipar2-u*peUQ*O&cMIOtPdf2C#Xan^S%Up4FD zFtE31=$(0v{Du>=J&ekWb3MfqBIM?+^8PIoLz>WnS=UDdi$*#ggUsFIEwr?=25($6 zsjR@4#A`r=k7!}#yRpjLn1xNL(UZ_!UvRz=F*%NCIEy3pw`idVGc0-1!7)-_>CFxZ zDt%b__=pb<8~uD!L{F8sEI>1;xV@yx_@?_A$iw=k(gMgdZTB36T$PT{7taS6(k|=f z$PW#1aOt9TYKnW~-I>jb$(+YyVzfCBaI9b0TB8G-UfehJS2hZLPRFkF^qm6h&uxNj z`pgC~a6l}mgWN4=a<}E1vd%ENQw#6n$F>)x>%zW>gdm1h&fgR4Q?^96<`vw6{;Z#` z#3-z`0dH_#$2qt!Kh_`FRbh?x zRr|Vc95d3xDnbH^D9Y(4`1*Y^%Fl=H*(7yJy+NngI7!nBfw)LXjc*s&*>!xn z+?cPj4HzREx-wBtN0FBQ93d+ro`YGwJi#(a4L1ZG_)Lkr_1AQTOsrULRw*#pFC$&l zAwkbhSS%^~kXY4Y!oMf#6uuKd(!{YOjI2|@y(H7R)v%!}Db9UZV5WiJY^)%yMY79M z?8+)O5AH@q@*7bFCvs4Y+ie4b6(43kq_Dr^P_Vvjl`wCBGgFLg-b;+~z{LyR+BlTK zx9eRNvg$gF@_U36ja$6@6g!-J)d)1Je%B!-T3cmfiNH=PBOacmlc|eM5-P2%+nL@{ zE5old@VYL~yvHgtK>>Ir-tNTTf9@W=BN$TSx+!FV^k8v0lsL9|?d(()hKG5_?56eZ zi(9lj-72<+0ov+<=S>`n{|)y~MR#ITQcr*|rR_|!CB@Y-di5w?A9<28?*^7wjZ6k7 zGhzU%yefzEcEp7z2bqEtJTm_$+zYVwi>_C%9}L9BDD`9AP@0>S@%Gw4te3|QJOM`d z685#Xf_vPrPKi*|mT;$FEvE2^iXTy~XKa>{7GzkBAs(iE5APNr)Rwu5t4|phA={AN z!>~~OTlqIU(*4_V4b=0VH6I|mNwqmgN_dENBM%n!s-g2QP(FMATIFL47>70WbQ4ps zK$!y*pdK?y9%+wN=eTct)aojUmrEYgu5H)Cv_s0}M-PbMI)K5> zTEi183-)huNl|W5p}!IBiznaf32wgI(Ib_@GHy>iefEV*02lEg!NZbZMXNwirI-07 zfYrS}a7sXni=bWIp5mMzmJB?32Ab{RBHLu{>iFSjy(-a=A`exxgeU4or|~&2 z3&7kQ++AjSc9=Mrl@_>m0S>h*vzVzIAD+U~(@kW|ZrC=K=%v$zdPFO~$-A(qr9}&f zy@MG%Nja*q27jeU)59F*MQIS`1hT`zrYu`z~tq^*J~RuQse+Zu>7OG>__jtxF;(r}i&42-w!qzjj`d+uan_BEEGFm|eB&lz^g%e!7W5VX^M~6?}jMz`72qA@ZOBZ-_*Fx;h)R|+_fW;vh0YbfyZ`d z6TJm&D6FaGR88ZdprSUd_K}M$`Hh>YJ%)jBGxLv&6CpfzoZs{fc8NS&ToVc8s^9MH zC9b1`g(l0*ld=341y6rE|5OGfzCt`V(y4nl7y#tss+6^W^fG7QldZ<6g%FY^zY;plUF~*u%MhIvQhC8{Jvo}XZizRQXz5Sgs zJ|p4ZHhdPYT1)VzMNft>mt+X`;Cw}Le9gLc;J44hFeVf>SZ{Dy=aBK^8Q?TNqqtzW}L;fBaLvT2eBAzsTcx7+R{;Fd47&DWJr9H z+GSdibAnv-ZM@#`eSxm43x9ADapvEfmR>fleJG<)?Yl56YS^zT-oMyftto_IOL-?ppRXuI3o z;65agGx(lVcc3({$q0^2bU|Qh5j^$wSyY;x{mJH&1Ev9sZa1}!an6XUe5Ryx{|`br zE+{W|s%W1(yOe2_zkqa@0weMIZ2mq5Jg7}VryaEkUDY&Z-10`6-q8Wc%1SeKQ)Rpm+%S%#Ef$06j8bJ2Fj zkX=i>hF=)(&&R1|zV3j;oUT5@pueU>$~!SbUkjuX79Dsp9quBf!VUhj0T6ftLjU(x zK=h+}mc5+x0OMbK1OKN7QXWVV5FQ?Y30lVp2ncA8KikL%!A>{H?m{by@FNJal1dU~ IVuk_#4}vZ`^#A|> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e7517bf54c318fc932b29972c2bb4f76064b29da GIT binary patch literal 42705 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Ν7x+ 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

Ug=KN<%8xaE?}oQ| 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eecb7c7db50b42ce56221e3ba14cb97550b5d745 GIT binary patch literal 79155 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Ν7x+ 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

My reply

', { + replyType, + replyingTo: { + ...replyingTo, + body_html_sanitized: + '

Read Architecture

', + }, + }); + const dom = new DOMParser().parseFromString( + decodeBodyHtml(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 = decodeBodyHtml(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 = decodeBodyHtml(prepared.bodyHtml); diff --git a/apps/web/src/features/email-message/attachment-action-adapter.ts b/apps/web/src/features/email-message/attachment-action-adapter.ts index 6b2b5471f3f..8d5d3011bdc 100644 --- a/apps/web/src/features/email-message/attachment-action-adapter.ts +++ b/apps/web/src/features/email-message/attachment-action-adapter.ts @@ -39,7 +39,7 @@ export function createEmailAttachmentOpener() { refetchSoupEntity(document_id, 'document'); - const fileType = Object.values(FileTypeMap).find( + const fileType = Object.values(FileTypeMap).findLast( (type) => type.mime === attachment.mime_type )?.extension; const blockName = fileType 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 index 9183732f09a..ce157a03dd3 100644 --- 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 @@ -26,12 +26,6 @@ describe('independent email body', () => { ); const revoke = vi.fn(); vi.stubGlobal('URL', { ...URL, revokeObjectURL: revoke }); - const cancelFrame = vi.fn(); - vi.stubGlobal( - 'requestAnimationFrame', - vi.fn(() => 1) - ); - vi.stubGlobal('cancelAnimationFrame', cancelFrame); const resolveImages = vi.fn( async (_root, _attachments, lifetime: ResourceLifetime) => { lifetime.onDispose(() => URL.revokeObjectURL('blob:email')); @@ -85,26 +79,34 @@ describe('independent email body', () => { expect(resolveImages).toHaveBeenCalled(); expect(revoke).toHaveBeenCalledWith('blob:email'); expect(disconnect).toHaveBeenCalled(); - expect(cancelFrame).toHaveBeenCalled(); - }); - it('keeps the Macro Markdown branch free of hidden HTML resources', async () => { - const resolveImages = vi.fn(async () => {}); - createRoot((dispose) => { - const body = createEmailMessageBody( - { - message: message('macro', { body_macro: 'A document mention' }), - isPersonal: true, - isBodyExpanded: () => true, - setExpandedMessageBody() {}, - setFocusedMessageId() {}, - isFocused: false, - }, - { theme: () => theme, resolveImages } - ); - expect(body.host()).toBeUndefined(); - dispose(); - }); - await Promise.resolve(); - expect(resolveImages).not.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), + isPersonal: true, + isBodyExpanded: () => true, + setExpandedMessageBody() {}, + setFocusedMessageId() {}, + isFocused: false, + }, + { 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 index 54d8ec942e4..2bfe0173c56 100644 --- a/apps/web/src/features/email-message/primitives/email-message-body.ts +++ b/apps/web/src/features/email-message/primitives/email-message-body.ts @@ -42,9 +42,13 @@ export function createEmailMessageBody( ) ); const rendered = createMemo(() => { - // Macro Markdown owns its mentions/editor integration. Do not mount an - // invisible HTML renderer or start image requests behind that branch. - if (!showFullHTML() && props.message.body_macro) return; + // 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'); 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 index 4991dc77604..6aea99eaa62 100644 --- a/apps/web/src/features/email-message/views/email-message-body.tsx +++ b/apps/web/src/features/email-message/views/email-message-body.tsx @@ -49,6 +49,13 @@ export function EmailMessageBody(props: EmailMessageBodyProps) { ); }} + + + {host()} diff --git a/apps/web/src/features/email-thread/core/thread-messages.test.ts b/apps/web/src/features/email-thread/core/thread-messages.test.ts index d8ce062e26f..45ade1fe02e 100644 --- a/apps/web/src/features/email-thread/core/thread-messages.test.ts +++ b/apps/web/src/features/email-thread/core/thread-messages.test.ts @@ -1,8 +1,34 @@ import { describe, expect, it } from 'vitest'; import { message, thread } from '../tests/fixtures'; -import { selectThreadMessages } from './thread-messages'; +import { selectThreadMessages, selectThreadSender } from './thread-messages'; describe('thread message selection', () => { + it('targets the original external sender for sender actions regardless of transport order', () => { + const original = message('original', { + from: { email: 'originator@example.com' }, + internal_date_ts: '2026-09-01T00:00:00Z', + }); + const later = message('later', { + from: { email: 'newer@example.com' }, + internal_date_ts: '2026-09-06T00:00:00Z', + }); + const viewer = message('viewer', { + from: { email: 'VIEWER@example.com' }, + internal_date_ts: '2026-08-01T00:00:00Z', + }); + const messages = [later, original, viewer]; + expect(selectThreadSender(thread(messages), 'viewer@example.com')).toBe( + 'originator@example.com' + ); + expect( + selectThreadSender(thread([...messages].reverse()), 'viewer@example.com') + ).toBe('originator@example.com'); + expect(messages).toEqual([later, original, viewer]); + expect( + selectThreadSender(thread([viewer]), 'viewer@example.com') + ).toBeUndefined(); + expect(selectThreadSender(thread([]))).toBeUndefined(); + }); it('orders messages chronologically without mutating the source and separates reply drafts', () => { const newer = message('new', { internal_date_ts: '2026-09-02T10:00:00Z' }); const older = message('old'); diff --git a/apps/web/src/features/email-thread/core/thread-messages.ts b/apps/web/src/features/email-thread/core/thread-messages.ts index 0a4a5fb0dc6..ae3a8e60c42 100644 --- a/apps/web/src/features/email-thread/core/thread-messages.ts +++ b/apps/web/src/features/email-thread/core/thread-messages.ts @@ -1,6 +1,15 @@ import type { EmailMessage } from '../../email-message/core/email-message'; import type { EmailThread } from './email-thread'; +/** Sender actions historically target the earliest sender other than the viewer. */ +export function selectThreadSender(thread: EmailThread, viewerEmail?: string) { + const viewer = viewerEmail?.toLowerCase(); + return selectThreadMessages(thread).messages.find( + (message) => + message.from?.email && message.from.email.toLowerCase() !== viewer + )?.from?.email; +} + /** Select chronological messages and reply drafts without mutating the source. */ export function selectThreadMessages(thread: EmailThread) { const messages = [...thread.messages]; diff --git a/apps/web/src/features/email-thread/thread-action-adapter.tsx b/apps/web/src/features/email-thread/thread-action-adapter.tsx index 5e889d4708b..4ae719781ed 100644 --- a/apps/web/src/features/email-thread/thread-action-adapter.tsx +++ b/apps/web/src/features/email-thread/thread-action-adapter.tsx @@ -38,6 +38,7 @@ import type { ArchiveThreadOptions, EmailThreadCommands, } from './context/email-thread-dependencies'; +import { selectThreadSender } from './core/thread-messages'; export function createThreadActionAdapter( threadId: Accessor, threadSource: Accessor @@ -396,35 +397,18 @@ export function createThreadActionAdapter( const currentUserEmail = useEmail(); - const blockSender = () => { + const getSenderEmail = (): string | undefined => { const thread = threadSource(); - 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; + return thread ? selectThreadSender(thread, currentUserEmail()) : undefined; + }; + const blockSender = () => { + const senderEmail = getSenderEmail(); if (!senderEmail) return false; - - blockSenderWithToast(senderEmail, toHeaderLinkId(thread.link_id)); + blockSenderWithToast(senderEmail, toHeaderLinkId(threadSource()?.link_id)); return true; }; - const getSenderEmail = (): string | undefined => { - const thread = threadSource(); - 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; diff --git a/apps/web/src/lib/core/email/index.ts b/apps/web/src/lib/core/email/index.ts index b1ffeb0c968..a8818f2f0ce 100644 --- a/apps/web/src/lib/core/email/index.ts +++ b/apps/web/src/lib/core/email/index.ts @@ -1,7 +1,6 @@ // Compatibility entry point for the editor's HTML decorator and quoted replies. // The implementation lives in the framework-independent renderer package. export { - parseEmailContent, sanitizeEmailHtml, stripColorSchemeMediaQueries, } from '@macro-inc/email-renderer'; diff --git a/docs/AGENT_GUIDE/surfaces.md b/docs/AGENT_GUIDE/surfaces.md index 75e3605717e..3c06c6384d0 100644 --- a/docs/AGENT_GUIDE/surfaces.md +++ b/docs/AGENT_GUIDE/surfaces.md @@ -21,11 +21,13 @@ 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. An edited reply remains a draft when navigating away and returning. The three-dot button beneath a body reveals quoted content and a trimmed -signature. Ordinary plaintext displays literally, including Markdown punctuation; -Macro Markdown messages retain document mentions. HTML/plaintext bodies use an +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. diff --git a/docs/EMAIL_FEATURE_ARCHITECTURE.md b/docs/EMAIL_FEATURE_ARCHITECTURE.md index a8e6afcb594..30543d5d6b6 100644 --- a/docs/EMAIL_FEATURE_ARCHITECTURE.md +++ b/docs/EMAIL_FEATURE_ARCHITECTURE.md @@ -120,10 +120,11 @@ 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 remains a separate app rendering path because document mentions -and editor semantics belong to the app. That branch does not mount an invisible -HTML renderer or start its resource requests. Ordinary plaintext is escaped and -displayed literally, including Markdown punctuation. Missing replyless HTML +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`. @@ -154,9 +155,9 @@ sanitization/color helpers delegate to the package through `@core/email`. 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. -8. Renderer resources follow their Solid owner. Image blob URLs, resize observers, - image listeners, and pending measurement frames are released on source changes - or disposal. +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 @@ -234,44 +235,38 @@ the full rendering pipeline. Committed screenshots were not regenerated then. Th seeded inbox has no real provider credentials, so these checks do not establish Gmail delivery or native iOS behavior. -### Standalone renderer verification - -The fixture viewer and Chromium suite now call the same public preparation and -mounting API as the app. Node tests cover preparation, content/resource policy, -pure color calculations, and width fitting. A separate TypeScript build excludes -DOM libraries from core. Import checks reject app/framework dependencies and -browser imports from core. Browser tests cover CSS/layout, color round trips, -quote expansion, image visibility, resource replacement, late cleanup, and -adapter failures. App tests verify the Solid lifecycle and native image adapter. -The package's Node tests are included in the app's default Vitest projects and -existing CI job. They also compile production core without DOM libraries. - -The new visual baselines cover all five migrated fixtures across both themes, -including narrow/wide panes. Resources are blocked before insertion and the -suite asserts no external requests. Screenshots have a zero-pixel difference -tolerance. A comparison with a captured bundle of the previous production -renderer found identical calendar and wide-table images (six screenshots). -The paragraph fixtures have contained outer margins: the new shadow host keeps -layout and paint within the body, increasing the captured height by the margins -that previously collapsed outside it. This is an intentional containment change. -The package README documents additional explicit content policies, including -literal plaintext and exclusion of origin-dependent URLs and external fonts. - -Use `bun run --cwd packages/email-renderer viewer` to inspect these fixtures -without an account or backend. Full inbox interactions still require browser -checks against the running app; passing package tests alone is insufficient. - -The completed extraction passes 53 package Node tests, 18 Chromium tests, and -121 app email tests, plus the full frontend check, package type/lint checks, -feature ast-grep scan, viewer production build, and five QC reviews. The package -Node tests also pass when invoked through the app's Vitest project list. -Linux's Nix dependency artifact passes a forced rebuild. The Darwin dependency -hash was generated and verified using the same recipe with Bun's Darwin/arm64 -installation flags on Linux; a native macOS build was not run. - -Chrome checked message bodies and quote expansion against the previous and new -implementations. Final checks covered deep-link reveal, keyboard reply, -standalone compose, a saved reply surviving a fresh browser context, and the -mobile reply drawer at 390px. The standalone scenarios reported no page errors. -The combined script encountered full-page navigation timeouts before and after -the extraction; persistence and compose were verified in separate contexts. +### Compatibility audit — September 6, 2026 + +The earlier checks above did not establish full behavioral parity. The follow-up +[feature inventory and verification record](EMAIL_REFACTOR_VERIFICATION.md) +compares the actual application before both email refactors (`5a3d970fb`) with the +current worktree. It records confirmed regressions, fixes, browser evidence, and +paths that still require integration or device testing. + +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 seven 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. The full-app comparison additionally uses +the old and new production pipelines, mounted through their real Solid hosts; +standalone fixture snapshots alone do not establish parity. + +Current checks pass 67 package Node tests, 31 Chromium tests, and 127 app email +tests, plus the full frontend check and package type checking. See the verification +record for the separate browser interaction matrix and its limits. Literal +plaintext is an independent package API capability; the app retains its original +Markdown fallback. Default URL and CSS policy preserves browser resolution and +cascade semantics. The optional remote-image blocking policy is stricter and is +not the normal app reader policy. + +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/EMAIL_REFACTOR_VERIFICATION.md b/docs/EMAIL_REFACTOR_VERIFICATION.md new file mode 100644 index 00000000000..e3294c37561 --- /dev/null +++ b/docs/EMAIL_REFACTOR_VERIFICATION.md @@ -0,0 +1,204 @@ +# Email refactor compatibility audit + +This records the September 6, 2026 audit of the email feature and renderer +extractions. The purpose is to preserve existing behavior while changing ownership +and dependencies. A cleaner architecture does not justify incidental changes to +email content, recipients, navigation, or requests. + +## Baseline and method + +| Version | Source | +| --- | --- | +| Before both email refactors | `5a3d970fb` — the architecture documentation commit, before email code moved | +| Feature extraction | `2b987feb1` | +| Renderer extraction | `b07d69a2c` | +| After | `b07d69a2c` plus the compatibility fixes accompanying this document | + +The baseline app was exported to `/tmp/email-parity-before` and served on port +24830. The current app uses port 24710. Both use the same local backend and seeded +account. The baseline has its own Vite optimizer cache. Its only Vite configuration +adjustment allows fonts to load from the shared dependency directory; its app +source is the baseline commit. Comparing a captured parser alone would miss Solid +attachment timing, app presentation policy, composer wiring, and keyboard scopes. + +The local stack and its shared Chrome were started with: + +```sh +nix develop --command just run_local --instance email-feature-eval \ + --port-base 24700 --no-doppler --with-chrome --no-build +``` + +`--with-chrome` is the actual flag in this repository. The shared Chrome exposes +CDP on port 9222 and its display on port 6080. Comparison probes create their own +browser contexts. Both versions receive the same viewport, theme, account state, +and fixture body. Rendering probes substitute only the thread GET response in the +browser, then exercise the real app rendering path. Action probes intercept writes +and compare request payloads. The draft persistence probe writes only to the local +seed draft and restores its original content afterward. No email is delivered. + +## Feature inventory + +Paths in this table are relative to `apps/web/src/features`. “Browser” means the +behavior was exercised against both running versions. “Unit” means targeted tests +exercise the extracted logic. “Source review” is weaker evidence and is labeled +explicitly. The inventory separates independent behaviors so one successful +thread load cannot stand in for all thread or composer functionality. + +| Behavior before refactor | Previous owner in `block-email` | Current owner | Evidence and remaining limits | +| --- | --- | --- | --- | +| Load, retry, title and read marker | `Block.tsx` | Block plus `EmailBlockAdapter.tsx` | Browser loads; source review of load/error gate | +| Chronological messages and draft association | `EmailContext` query selector | `email-thread/core/thread-messages.ts`, thread source | Unit tests for reversed transport, drafts and nonmutation; browser thread order | +| Cached, pending, failed and changed thread identity | `EmailContext` | Thread source and `thread-snapshot.ts` | Unit tests for pending resource reads, retained snapshots and wrong identity | +| Notifications and draft-cache cleanup | `EmailContext` | Thread production entry and shared queries | Source review; no fresh live notification integration test | +| Draft precedence and local discard | `EmailContext` | Thread state | Unit tests for stale/missing/newer drafts and discarded drafts | +| Selection, expansion, hover and scroll ownership | Context and scroll state | Thread state | Unit tests for independent instances; browser navigation sequence below | +| Hidden middle messages and unread stops | `MessageList`, scroll helper | Thread message list, navigation and window helpers | Browser reveal/selection; unit stop and geometry tests | +| Paging and prepend geometry | Scroll helper | Thread source and scroll-to-message | Unit tests; source review of target/no-progress/identity/disposal termination; multi-page server integration not exercised | +| Deep links to hidden messages | Context and `Email` | Block location adapter and thread navigation | Browser target revealed, selected and expanded | +| Keyboard movement, expansion and collapse | `Email`, email hotkeys | Thread navigation plus block registration | Browser ArrowUp/Down, Enter and Escape | +| Block sender, Signal and Noise | `EmailContext` actions | Thread action adapter and chronological sender selector | Browser command requests match the original sender despite reversed transport | +| Read/unread and done/undone | Context actions | Thread action adapter | Browser `U`, `Shift+U`, `E`, `Shift+E` requests match | +| Undo triage, notification restoration and next-item selection | Context actions | Thread action adapter and host callbacks | Source review; full notification/soup integration not exercised | +| Header, details, compact snippet and card expansion | Message container/card/top bar | `email-message` views/components | Browser details, snippets, full card screenshots and expansion | +| HTML selection, signatures, quoted history and missing replyless data | Parser and body | Renderer core plus message controls | Node selection tests, browser quote reveal, full-app quoted fixture | +| Personal colors, links and calendar response backgrounds | Body and color helpers | Message policy plus renderer browser | Delayed-host browser tests and light/dark full-app comparisons | +| Newsletter CSS, fonts, margins and table fitting | Body and containment helpers | Renderer core/browser | Full-app computed style, dimensions and image comparisons; narrow/wide tables | +| Plaintext and Macro Markdown | Message body | Message view with existing Markdown renderer | Full-app comparisons; unit tests prevent hidden HTML resource work | +| Inline/CID/native images and resource lifetime | Body/image adapter | Message rendering adapter and renderer lifetime contract | Unit and Chromium adapter replacement, abort, cleanup and late-result tests; native runtime not exercised | +| HTML links and image-map areas | Body/parser | Resource policy and browser renderer | Node/browser URL, target and resource tests; mailto integration remains in app adapter | +| Attachment presentation and opening | Container/attachment pill | Message views and attachment action adapter | Browser click verifies original MIME routing with mocked document responses | +| Reply/reply-all/forward placement | Message container and reply actions | Thread views and primitives | Source review and reply-session tests; desktop/mobile saved reply checks | +| Stable reply editor identity | `EmailInput` | Thread email input view | Unit tests keep same-message editor and remount on changed target | +| Mobile drawer and reopen | Mobile reply components | Thread mobile view and compose host | Touch-browser drawer opens, closes and reopens saved content within viewport | +| To/Cc/Bcc, inbox choice and mentions | Form/conversion/input | Compose core and primitives | Recipient and secondary-inbox unit tests; mounted compose request comparison; account-switch/recipient-drag device flows not covered | +| Debounced drafts and quoted body round trips | Base input/compose/body helper | Compose primitives | Real local draft persistence across navigation; outgoing quote/forward tests | +| Immediate send, schedule and unschedule | Input/compose | Compose controllers and services | Unit race/failure tests; browser request checks use interception, not provider delivery | +| Undo-send recovery across composers | Global undo variables | Identity-keyed undo store/controller | Unit tests for draft/reply isolation, stale owners and bounded recovery; provider undo not exercised | +| Upload, remove and forward attachments | Base input/attachment helpers | Compose controllers/services | Source review and conversion tests; multipart upload/download integration not exercised | +| Signatures, watermark, rich editor and pasted uploads | Base input/editor/settings | Compose views and injected capabilities | Source review, signature fixtures and editor tests; subscription/from-switch/paste matrix not exercised | +| Standalone compose and launcher | Compose host | Compose production entry | Browser route, recipients, subject, body and saved draft request | +| Mailto and AI compose | App hosts | App hosts using compose entry/contracts | Existing parser tests and source review; no fresh AI-generated compose integration | + +## Regressions found and corrected + +These were real compatibility defects found during review, not intentional +consequences of feature isolation: + +| Trigger | Defect | Correction and regression coverage | +| --- | --- | --- | +| Solid constructs an email host before inserting it | Color preparation measured a detached tree, leaving blue links, unreadable calendar text and a light response banner | Prepare colors after connection, once per content generation; test delayed insertion and paused animation frames | +| HTML with extreme nesting | Recursive traversal/serialization could overflow | Iterative traversal and bounded nesting, retaining readable descendants; Node depth test | +| Collapsed renderer with contained HTML | Ancestor text clamp did not reach inside the shadow containment boundary | Apply the clamp inside the renderer; test line count, expansion and unchanged resources | +| Relative or protocol-relative image/link URLs, CID links | New policy silently removed previously usable URLs | Preserve relative/CID navigation and normalize protocol-relative URLs to HTTPS; Node and browser tests | +| Image map `` | Link destination was dropped | Preserve safe area destinations and apply safe link target/rel; browser test | +| One malformed declaration in a stylesheet | Entire unrelated newsletter stylesheet could be lost | Recover at declaration/rule boundaries; test hidden preheaders and valid text formatting | +| CSS variables and fallbacks | Valid color/layout declarations were removed or variable names/cascade changed | Preserve native custom-property semantics in the default reader; test inherited values, fallbacks, selectors and container queries | +| Nested/head/body color-scheme rules | Filtering exceeded the original top-level head-only rule | Restore the old filtering scope; Node and browser head/nested/body tests | +| Quoting or forwarding an email with dark CSS | Shared sanitization applied reader-only theme filtering to outgoing HTML | Keep display filtering separate; outgoing reply/forward tests preserve safe CSS | +| Supported CSS groups and image-set backgrounds | Layer/scope/namespace styling and valid backgrounds disappeared | Preserve supported groups and image-set under normal allow policy; actual browser styling/resource tests | +| Empty trailing stylesheet after line breaks | Trimming stopped too early, retaining excess blank space | Empty styles no longer stop trimming; Node and full-app case | +| Non-ASCII whitespace in a class name | Ordinary text could be mistaken for a signature and removed | Use HTML's ASCII class-token delimiters; Node and full-app case | +| Plaintext containing Markdown | The extraction changed existing rendered Markdown to literal punctuation | Restore the app's original fallback; full-app comparison and no-hidden-renderer tests | +| Backend returns newest messages first from different senders | Block/Signal/Noise targeted a later participant instead of the original sender | Select from the same chronological sequence as before; unit test and browser request comparison | +| Quoted authored document links and editor metadata | Shared sanitization removed the metadata required to reconstruct rich mentions | Preserve inert metadata in outgoing sanitization while keeping reader stripping; reply/forward and sanitizer regression tests | +| Duplicate MIME entries such as `text/plain` | Attachment lookup switched from the original last mapping to the first | Restore last-entry selection; mounted attachment click routes to the same block type | + +The optional `images.remote: 'block'` package policy deliberately rejects `var()` +declarations and image-set, including dynamically expanded strings that could +initiate loads. This is not the app's default policy. Its tests verify no remote +requests for the blocked cases; the normal allow policy preserves sender cascade +semantics. The browser layer still depends on inherited CSS, fonts and browser +behavior; only preparation is deterministic from explicit inputs. + +## Recorded browser comparisons + +Rendering covers personal announcements, Google Calendar invitations and accepted +responses, styled newsletters, GitHub notifications, wide tables, quoted messages, +plaintext Markdown, Macro Markdown, malformed CSS with variable fallback, empty +styles, class-token edge cases, head/nested/body theme rules and custom-property +selectors/container queries. Each case runs in light and dark mode. Wide tables +also run at a narrow viewport. Measurements include text, color, background, font, +font size, line height, display, dimensions and link destinations. Screenshots +capture the complete mounted message card after fonts load. + +The final serial run completed **30 before/after pairs (60 captures)**. All text, +computed style and dimension measurements match, and all card image dimensions +match. Fourteen pairs are pixel-identical; the other sixteen differ by 2–12 pixels +at border edges. There are no content-layout differences or page errors in that +run. Every capture asserts and records the actual `prefers-color-scheme` and +screen media settings. + +Two exploratory runs required investigation rather than acceptance: one hit a +`useSearchContext` provider error while source files were being edited; another +captured a baseline theme case whose colors did not reflect its requested dark +media setting. The provider error did not recur in the final stable run. The +theme case matched in isolated repeats, including light-to-dark transitions; its +original capture's cause was not established. The final run serializes shared +Chrome use and asserts actual media state to make that comparison reviewable. + +The navigation probe compares these eleven states in order: initial hidden middle; +first message expanded; hidden chip selected; chip revealed; second message +expanded; next message selected; previous message selected; selected message +collapsed; header details open; quote expanded; deep-linked middle message open. +The pointer stays outside the thread during keyboard assertions because hovered +messages intentionally affect the old and new navigation behavior. + +Draft checks save through the real local backend, navigate to another thread, +return, and verify restored content in both versions. A separate touch context +opens, closes and reopens the saved draft drawer. Standalone compose compares +serialized drafts, To/Cc/Bcc, bold HTML/Markdown, send and retry requests. A +synthetic send failure on desktop produces the same error feedback and retains the editor, +subject and recipients, with retry enabled in both versions. The quote metadata +comparison invokes actual app preparation modules in the browser; rich mention +reimport is verified with Lexical in unit tests, rather than a mounted reply click +flow. Encoded `data-html` remains excluded to prevent an editor import from +bypassing HTML scrubbing. Command checks exercise actual mounted +hotkeys/menus and compare intercepted payloads, not just helper return values. + +Local audit artifacts are under `/tmp/email-parity-render-verified` (including +`results.json`, `comparison.json` and both screenshots for each pair), +`/tmp/email-parity-navigation`, `/tmp/email-parity-compose-results.json`, +`/tmp/email-parity-compose-extended-results.json`, +`/tmp/email-parity-quoted-mention-results.json`, +`/tmp/email-thread-action-done-compare.json`, and +`/tmp/email-attachment-open-compare.json`. These temporary artifacts document this +run; the regression tests below are the durable automated checks. They contain +synthetic/local seed data and must not be replaced with unredacted production mail. + +## Repeatable automated checks + +The final code passes 67 renderer Node tests, 31 Chromium tests and 127 app email +tests. Package type checking and lint, the full frontend check (6,092 files), +feature ast-grep rules and whitespace checks also pass. All five QC roles—code, +simplicity, consistency, robustness and scope—reviewed the fixes with no remaining +findings. + +From `packages/email-renderer`, in the Nix shell: + +```sh +bun run test +bun run test:browser +bun run type-check +bun run lint +``` + +From `apps/web`: + +```sh +bun run test src/features/email +bun run check +``` + +The root recipe `just test-email-rendering` runs the Node and Chromium suites. +Fixtures and browser assertions live in `packages/email-renderer/tests`; parser +tests live alongside core. App tests live with the three email features. Boundary +tests enforce the core/browser and feature/block separation. Feature ast-grep +rules provide an additional architectural check. + +Passing these tests is evidence for the listed cases, not a proof that arbitrary +email HTML or every provider/device flow is equivalent. Native iOS/Tauri, +provider delivery, real attachment transport, live notifications and the other +explicitly marked integration paths still need their respective environments. +Future structural changes should preserve this inventory, compare the baseline +and proposed version in the same environment, and add a regression test for each +confirmed failure before claiming it is fixed. diff --git a/packages/email-renderer/README.md b/packages/email-renderer/README.md index 5de5ff894d1..c9aaa485a94 100644 --- a/packages/email-renderer/README.md +++ b/packages/email-renderer/README.md @@ -47,16 +47,21 @@ 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, animation frames, and resource -lifetimes it starts. Connect an empty host before mounting. In a synchronous -framework render, inserting it immediately after construction is also supported; -the first computed-style and layout pass runs in the next animation frame. +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` preserves content and resource -identity. `dispose` is idempotent, aborts work, disconnects observers, cancels -frames, removes listeners, and empties the shadow tree. Updating a disposed +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 @@ -70,7 +75,8 @@ 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; Markdown punctuation stays literal. + 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. @@ -79,14 +85,32 @@ There is no implicit fetch client or native-platform detection in this package. `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, unparsed/unsupported constructs, and color-scheme overrides, - including nested overrides. Malformed CSS fails closed without dropping text. + 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 image and - navigation URLs are excluded because they depend on the embedding app's origin. - `srcset`, SVG data images, and CSS image-set are excluded. `proxyUrl` rewrites + 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 @@ -99,9 +123,10 @@ There is no implicit fetch client or native-platform detection in this package. adapts personal/table-less email and preserves designed newsletters on a white background. Sender classification and Macro-specific rendering stay in the app. -Ordinary HTML/plaintext and Macro Markdown are separate paths. Macro Markdown -still uses the app's Lexical/Solid renderer for document mentions and other app -semantics. This package does not render a thread, message header, attachments +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 @@ -121,8 +146,14 @@ 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; diff --git a/packages/email-renderer/src/browser/colors.ts b/packages/email-renderer/src/browser/colors.ts index f581468dca0..cfd811577e5 100644 --- a/packages/email-renderer/src/browser/colors.ts +++ b/packages/email-renderer/src/browser/colors.ts @@ -113,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) { diff --git a/packages/email-renderer/src/browser/index.ts b/packages/email-renderer/src/browser/index.ts index 8d1ce849321..a261788d5b6 100644 --- a/packages/email-renderer/src/browser/index.ts +++ b/packages/email-renderer/src/browser/index.ts @@ -1,5 +1,5 @@ export type { ThemeColorParams } from './colors'; -export { processEmailColors, stripContentBackgrounds } from './colors'; +export { processEmailColors } from './colors'; export type { BrowserOptions, EmailBodyRenderer, diff --git a/packages/email-renderer/src/browser/renderer.ts b/packages/email-renderer/src/browser/renderer.ts index f1a4684c98d..6e3657a7b1f 100644 --- a/packages/email-renderer/src/browser/renderer.ts +++ b/packages/email-renderer/src/browser/renderer.ts @@ -26,7 +26,7 @@ export interface EmailBodyRenderer { dispose(): void; } -/** Owns one empty host's shadow tree. Call after connecting the host to the DOM. */ +/** Owns one empty host's shadow tree; visual preparation waits for attachment. */ export function mountEmailBody( host: HTMLElement, body: PreparedEmailBody, @@ -60,8 +60,10 @@ export function mountEmailBody( 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('a')) { - if (anchor.style.backgroundColor) { + 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 = ''; @@ -76,11 +78,18 @@ export function mountEmailBody( settings.prepareLinks?.(content); let expanded = settings.expanded !== false; - const measure = () => { - if (abort.signal.aborted) return; + 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.overflow = ''; content.style.overflowX = ''; + content.style.overflow = expanded ? '' : 'hidden'; if (!expanded) return; const fit = fitToWidthZoom({ containerWidth: host.clientWidth, @@ -92,25 +101,27 @@ export function mountEmailBody( }; 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' ); - measure(); + refresh(); }; applyExpanded(expanded); - const observer = new ResizeObserver(measure); + 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', measure, true); - lifetime.onDispose(() => shadow.removeEventListener('load', measure, true)); - const frame = requestAnimationFrame(() => { - if (abort.signal.aborted) return; - if (settings.adaptColors) processEmailColors(shadow, settings.theme); - measure(); - }); - lifetime.onDispose(() => cancelAnimationFrame(frame)); + 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'; diff --git a/packages/email-renderer/src/core/css.ts b/packages/email-renderer/src/core/css.ts index 034d81f1f16..e2eb1bb520c 100644 --- a/packages/email-renderer/src/core/css.ts +++ b/packages/email-renderer/src/core/css.ts @@ -9,27 +9,58 @@ import { } from 'css-tree'; import { type ImagePolicy, imageUrl } from './resource-policy'; -/** Parse CSS without CSSOM. Reject unparsed constructs and implicit loads. */ +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 + images: ImagePolicy, + options: CssOptions = {} ): string { try { const ast = parse(css, { context: inline ? 'declarationList' : 'stylesheet', - onParseError(error) { - throw error; - }, + 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'].includes(name) || - (node.prelude && - /prefers-color-scheme/i.test( + ![ + 'media', + 'supports', + 'layer', + 'scope', + 'namespace', + 'container', + 'page', + ].includes(name) || + (name === 'scope' && + node.prelude && + /:host|::slotted|:global/i.test( ident.decode(generate(node.prelude)) )) ) { @@ -39,25 +70,54 @@ export function prepareCss( } if ( node.type === 'Rule' && - /:host|::slotted|:global/i.test(ident.decode(generate(node.prelude))) + (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) - ) || ident.decode(node.property).startsWith('--'); + 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|var|url|(?:-webkit-)?image-set)$/i.test( - ident.decode(value.name) - ) + /^(?: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; @@ -79,5 +139,10 @@ export function prepareCss( } export function stripColorSchemeMediaQueries(css: string): string { - return prepareCss(css, false, { remote: 'allow' }); + return prepareCss( + css, + false, + { remote: 'allow' }, + { stripColorScheme: true } + ); } diff --git a/packages/email-renderer/src/core/html.test.ts b/packages/email-renderer/src/core/html.test.ts index d88f021149b..cf4dd377a9b 100644 --- a/packages/email-renderer/src/core/html.test.ts +++ b/packages/email-renderer/src/core/html.test.ts @@ -1,25 +1,62 @@ import { describe, expect, it } from 'vitest'; -import { - parseEmailContent, - prepareEmailBody, - sanitizeEmailHtml, - trimTrailingHtml, -} from './html'; +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(parseEmailContent(html)).toEqual({ - mainContent: '', - signature: null, - 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
', @@ -76,23 +113,52 @@ describe('prepareEmailBody in Node, without a DOM', () => { }); }); it('retains document styles and signatures according to explicit options', () => { - const result = parseEmailContent( - '

A

--
Me
' - ); - expect(result.mainContent).toBe('\n

A

'); - expect(result.signature).toBe('
Me
'); + 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) => { - expect(trimTrailingHtml(html)).not.toContain('
'); - expect(trimTrailingHtml(html)).toContain('A'); + const body = prepareEmailBody({ html }); + expect(body.html).not.toContain('
'); + expect(body.html).toContain('A'); } ); it('keeps trailing images and meaningful text', () => { - expect(trimTrailingHtml('

A

')).toContain('A
B

')).toContain('A
B'); + 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', () => { @@ -111,7 +177,8 @@ describe('content and resource policy', () => { }); 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'); @@ -150,14 +217,15 @@ describe('content and resource policy', () => { /onerror|alert\(| { + it('preserves relative and CID links but forbids data navigation', () => { const result = sanitizeEmailHtml( - 'mailwebdatarelative' + 'mailwebdatarelativepart' ); expect(result).toContain('href="mailto:a@example.com"'); expect(result).toContain('href="https://example.com"'); expect(result).not.toContain('href="data:'); - expect(result).not.toContain('href="/app'); + 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( @@ -192,14 +260,84 @@ describe('content and resource policy', () => { expect(result).toContain('src="data:image/png;base64,AAAA"'); expect(result).not.toContain('svg+xml'); }); - it('strips nested dark mode rules, host selectors and unsafe CSS escapes', () => { - const result = sanitizeEmailHtml( - '' - ); - expect(result).not.toMatch(/prefers-color-scheme|:host|javascript/); + 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( '' diff --git a/packages/email-renderer/src/core/html.ts b/packages/email-renderer/src/core/html.ts index 339cfbdb5e4..0c7f6c1bb67 100644 --- a/packages/email-renderer/src/core/html.ts +++ b/packages/email-renderer/src/core/html.ts @@ -3,7 +3,6 @@ import { defaultTreeAdapter, html as htmlConstants, parse, - parseFragment, serialize, serializeOuter, } from 'parse5'; @@ -20,14 +19,23 @@ const ACTIVE = new Set( ); 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[] = []; - for (const child of root.childNodes) { + const pending = [...root.childNodes].reverse(); + while (pending.length) { + const child = pending.pop()!; if (isElement(child)) { - result.push(child, ...elements(child)); + result.push(child); + for (let index = child.childNodes.length - 1; index >= 0; index--) + pending.push(child.childNodes[index]); } } return result; @@ -41,10 +49,10 @@ function remove(node: Element) { function hasClass(node: Element, name: string) { return node.attrs .find((attr) => attr.name === 'class') - ?.value.split(/\s+/) + ?.value.split(/[\t\n\f\r ]+/) .includes(name); } -function scrub(root: Parent, images: ImagePolicy) { +function scrub(root: Parent, images: ImagePolicy, options: ScrubOptions = {}) { for (const node of elements(root)) { if ( ACTIVE.has(node.tagName) || @@ -70,12 +78,17 @@ function scrub(root: Parent, images: ImagePolicy) { '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-') + (name.startsWith('data-') && !options.preserveDataAttributes) ) return []; if (name === 'href') { - const value = node.tagName === 'a' ? linkUrl(attr.value) : undefined; + const value = ['a', 'area'].includes(node.tagName) + ? linkUrl(attr.value) + : undefined; return value ? [{ ...attr, value }] : []; } if (name === 'src' || name === 'background') { @@ -89,7 +102,7 @@ function scrub(root: Parent, images: ImagePolicy) { return value ? [{ ...attr, value }] : []; } if (name === 'style') { - const value = prepareCss(attr.value, true, images); + const value = prepareCss(attr.value, true, images, options); return value ? [{ ...attr, value }] : []; } return [attr]; @@ -105,16 +118,53 @@ function scrub(root: Parent, images: ImagePolicy) { node.childNodes = [ { nodeName: '#text', - value: prepareCss(css, false, images), + value: prepareCss(css, false, images, { + stripColorScheme: + options.stripColorScheme && node.parentNode?.nodeName === 'head', + }), parentNode: node, }, ]; } } } -function documentParts(html: string, images: ImagePolicy) { + +// 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); + scrub(document, images, options); + boundNesting(document); const nodes = elements(document); let body = nodes.find((node) => node.tagName === 'body'); if (!body) { @@ -139,7 +189,7 @@ function trim(root: Parent) { if (last.nodeName === '#text') { if ((last as DefaultTreeAdapterMap['textNode']).value.trim()) return; } else if (isElement(last)) { - if (last.tagName === 'img' || last.tagName === 'style') return; + if (last.tagName === 'img') return; if (last.tagName !== 'br') { trim(last); if (last.childNodes.length) return; @@ -149,29 +199,29 @@ function trim(root: Parent) { } } -export function trimTrailingHtml(html: string): string { - const fragment = parseFragment(html); - trim(fragment); - return serialize(fragment); -} - export function sanitizeEmailHtml( html: string, images: ImagePolicy = IMAGES_ALLOWED ): string { - const { document } = documentParts(html, images); + // 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')!); } -export interface ContentOptions { +interface ContentOptions { removeSignature?: boolean; removeTrailingBrs?: boolean; images?: ImagePolicy; } -export function parseEmailContent(html: string, options: ContentOptions = {}) { +function parseEmailContent(html: string, options: ContentOptions = {}) { const { body, styles } = documentParts( html, - options.images ?? IMAGES_ALLOWED + options.images ?? IMAGES_ALLOWED, + { stripColorScheme: true } ); const nodes = elements(body); const hasTable = nodes.some((node) => node.tagName === 'table'); @@ -238,12 +288,14 @@ export function prepareEmailBody( const replyless = input.replylessHtml || (quote ? styles + serialize(body) : input.html); const full = !!(options.showQuotedContent || options.showFullContent); - const parsed = parseEmailContent(full ? input.html : replyless, { - removeSignature: !options.showQuotedContent, - removeTrailingBrs: !options.showQuotedContent, - images, - }); const shortened = parseEmailContent(replyless, { images }); + const parsed = full + ? parseEmailContent(input.html, { + removeSignature: !options.showQuotedContent, + removeTrailingBrs: !options.showQuotedContent, + images, + }) + : shortened; return { html: parsed.mainContent, kind: 'html', diff --git a/packages/email-renderer/src/core/index.ts b/packages/email-renderer/src/core/index.ts index cf51a0ae148..4a01b0ede38 100644 --- a/packages/email-renderer/src/core/index.ts +++ b/packages/email-renderer/src/core/index.ts @@ -1,14 +1,8 @@ export { stripColorSchemeMediaQueries } from './css'; export type { BodyOptions, - ContentOptions, EmailBodyInput, PreparedEmailBody, } from './html'; -export { - parseEmailContent, - prepareEmailBody, - sanitizeEmailHtml, - trimTrailingHtml, -} 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 index 83f4993060c..64159978ead 100644 --- a/packages/email-renderer/src/core/resource-policy.ts +++ b/packages/email-renderer/src/core/resource-policy.ts @@ -7,13 +7,16 @@ export interface ImagePolicy { } function cleanUrl(value: string): string { - return Array.from(value) + const url = Array.from(value) .filter((char) => char.charCodeAt(0) >= 0x20 && char.charCodeAt(0) !== 0x7f) .join('') .trim(); + return url.startsWith('//') ? `https:${url}` : url; } -/** Relative images depend on the embedding app's origin, so are excluded. */ +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 @@ -26,7 +29,9 @@ export function imageUrl( ) ) return url; - if (policy.remote === 'block' || !/^https?:\/\//i.test(url)) return; + 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; @@ -35,5 +40,6 @@ export function imageUrl( export function linkUrl(value: string): string | undefined { const url = cleanUrl(value); - if (/^(?:https?:\/\/|mailto:|tel:|sms:|#)/i.test(url)) return url; + if (/^(?:https?:\/\/|mailto:|tel:|sms:|cid:)/i.test(url) || !hasScheme(url)) + return url; } 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/rendering.pw.ts b/packages/email-renderer/tests/rendering.pw.ts index 01c094b580e..706ea6105e5 100644 --- a/packages/email-renderer/tests/rendering.pw.ts +++ b/packages/email-renderer/tests/rendering.pw.ts @@ -36,6 +36,278 @@ test.beforeEach(async ({ page }) => { await page.goto('/'); }); +test('preserves CSS recovery and native variable cascade and fallbacks', async ({ + page, +}) => { + await page.evaluate(() => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + host.id = 'css-compat'; + host.style.setProperty('--gap', '9px'); + document.body.append(host); + mountEmailBody( + host, + prepareEmailBody( + { + html: '
Hidden preview

Inherited green

Fallback red

Image map footer

', + }, + { images: { remote: 'allow' } } + ), + { theme: themes.light, adaptColors: false, normalizeFonts: false } + ); + }); + const host = page.locator('#css-compat'); + 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: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=', + 'base64' + ), + }) + ); + await page.evaluate(() => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + host.id = 'url-compat'; + document.body.append(host); + mountEmailBody( + host, + prepareEmailBody({ + html: 'Open', + }), + { theme: themes.light, adaptColors: false, normalizeFonts: false } + ); + }); + const host = page.locator('#url-compat'); + 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, +}) => { + await page.evaluate(() => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + host.id = 'grouped-css'; + document.body.append(host); + mountEmailBody( + host, + prepareEmailBody({ + html: '

Styled inside scope

Outside scope

', + }), + { theme: themes.light, adaptColors: false, normalizeFonts: false } + ); + }); + const host = page.locator('#grouped-css'); + 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' }); + await page.evaluate(() => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + host.id = 'theme-rule-scope'; + document.body.append(host); + mountEmailBody( + host, + prepareEmailBody({ + html: '

Head

Nested

Body

', + }), + { theme: themes.dark, adaptColors: false, normalizeFonts: false } + ); + }); + const host = page.locator('#theme-rule-scope'); + 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, +}) => { + await page.evaluate(() => { + const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; + const host = document.createElement('div'); + host.id = 'variable-names'; + document.body.append(host); + mountEmailBody( + host, + prepareEmailBody({ + html: '

Variable styled text

', + }), + { theme: themes.light, adaptColors: false, normalizeFonts: false } + ); + }); + const text = page + .locator('#variable-names') + .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: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=', + 'base64' + ), + }); + }); + 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, }) => { @@ -145,9 +417,62 @@ test('resizes wide content, removes stale scaling, hides collapsed images', asyn 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, }) => { 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 0000000000000000000000000000000000000000..dad8104c16aa2aa10792dc30bd91151c8d6981f3 GIT binary patch literal 23410 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 d>zhXyFdU*(8WIFvY8SXg*+vY*tkupa7S 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 aSi%_uUt8pOtugOl$w?`Hs+9N~^xps&)S-L; literal 0 HcmV?d00001 diff --git a/packages/email-renderer/viewer/main.ts b/packages/email-renderer/viewer/main.ts index 1bd17cdf8be..e1e8c1fad7c 100644 --- a/packages/email-renderer/viewer/main.ts +++ b/packages/email-renderer/viewer/main.ts @@ -31,6 +31,8 @@ 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', { @@ -66,8 +68,8 @@ function renderFixture() { }); const options = { theme, - adaptColors: !prepared.hasTable, - normalizeFonts: false, + adaptColors: fixture.adaptColors ?? !prepared.hasTable, + normalizeFonts: fixture.normalizeFonts ?? false, expanded: expandedInput.checked, }; if (renderer) renderer.update(prepared, options); From 54c34e3e7b4a56cf67dfc0c5dcd6913c001962b2 Mon Sep 17 00:00:00 2001 From: synoet Date: Sun, 6 Sep 2026 20:04:59 +0000 Subject: [PATCH 05/21] refactor(web): split email compose and thread responsibilities --- .../email-compose/core/email-recipient.ts | 1 + .../primitives/attachment-persistence.test.ts | 115 ++++ .../primitives/attachment-persistence.ts | 88 +++ .../primitives/compose-view-state.ts | 19 +- .../primitives/email-composer.ts | 189 ++----- .../primitives/email-form-state.ts | 13 +- .../primitives/email-send-schedule.ts | 95 ++++ .../primitives/reply-composer-focus.test.ts | 91 +++ .../primitives/reply-composer-focus.ts | 86 +++ .../email-compose/primitives/reply-input.ts | 443 +++------------ .../primitives/reply-recipient-fields.test.ts | 68 +++ .../primitives/reply-recipient-fields.ts | 115 ++++ .../email-compose/views/compose-layout.tsx | 2 +- .../email-compose/views/compose-toolbar.tsx | 25 +- .../email-compose/views/reply-envelope.tsx | 329 +++++++++++ .../email-compose/views/reply-input.tsx | 530 ++---------------- .../primitives/email-thread-state.ts | 137 +---- .../email-thread/primitives/thread-drafts.ts | 66 +++ .../primitives/thread-navigation.ts | 78 --- .../primitives/thread-recipients.ts | 79 +++ .../primitives/thread-reply-area.test.ts | 107 ++++ .../primitives/thread-reply-area.ts | 102 ++++ .../email-thread/thread-action-adapter.tsx | 407 +------------- .../thread-completion-adapter.tsx | 330 +++++++++++ .../email-thread/thread-read-adapter.ts | 98 ++++ .../src/features/email-thread/views/email.tsx | 21 +- docs/EMAIL_FEATURE_ARCHITECTURE.md | 38 ++ docs/EMAIL_REFACTOR_VERIFICATION.md | 89 +++ 28 files changed, 2129 insertions(+), 1632 deletions(-) create mode 100644 apps/web/src/features/email-compose/primitives/attachment-persistence.test.ts create mode 100644 apps/web/src/features/email-compose/primitives/attachment-persistence.ts create mode 100644 apps/web/src/features/email-compose/primitives/email-send-schedule.ts create mode 100644 apps/web/src/features/email-compose/primitives/reply-composer-focus.test.ts create mode 100644 apps/web/src/features/email-compose/primitives/reply-composer-focus.ts create mode 100644 apps/web/src/features/email-compose/primitives/reply-recipient-fields.test.ts create mode 100644 apps/web/src/features/email-compose/primitives/reply-recipient-fields.ts create mode 100644 apps/web/src/features/email-compose/views/reply-envelope.tsx create mode 100644 apps/web/src/features/email-thread/primitives/thread-drafts.ts create mode 100644 apps/web/src/features/email-thread/primitives/thread-recipients.ts create mode 100644 apps/web/src/features/email-thread/primitives/thread-reply-area.test.ts create mode 100644 apps/web/src/features/email-thread/primitives/thread-reply-area.ts create mode 100644 apps/web/src/features/email-thread/thread-completion-adapter.tsx create mode 100644 apps/web/src/features/email-thread/thread-read-adapter.ts diff --git a/apps/web/src/features/email-compose/core/email-recipient.ts b/apps/web/src/features/email-compose/core/email-recipient.ts index 6094276fe3c..7fb1f1102de 100644 --- a/apps/web/src/features/email-compose/core/email-recipient.ts +++ b/apps/web/src/features/email-compose/core/email-recipient.ts @@ -26,6 +26,7 @@ export type EmailRecipient = }; export type RecipientFieldId = 'to' | 'cc' | 'bcc'; +export type EmailFormRecipients = Record; export function getRecipientDisplayName(item: EmailRecipient): string { if (item.kind === 'custom') return item.data.email; 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..322980f4da5 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/attachment-persistence.test.ts @@ -0,0 +1,115 @@ +import { createRoot } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { UploadEmailAttachments } from '../context/compose-services'; +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', + linkId: () => 'secondary-inbox', + }); + return { form, services, persistence }; + }); +} + +describe('draft attachment persistence', () => { + it('waits for earlier content uploads even after their attachment IDs are assigned', async () => { + let finish!: () => void; + const uploadFinished = new Promise((resolve) => { + finish = resolve; + }); + 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].linkId).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', + linkId: 'secondary-inbox', + }); + expect(state.services.removeForwardedAttachment).toHaveBeenCalledWith({ + draftID: 'draft', + attachmentID: 'forwarded', + linkId: '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..105bc38683c --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/attachment-persistence.ts @@ -0,0 +1,88 @@ +import type { Accessor } from 'solid-js'; +import type { EmailComposeServices } from '../context/compose-services'; +import { createComposeOperation } from './compose-operation'; +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: Accessor; + draftId: Accessor; + linkId: Accessor; + services: Pick< + EmailComposeServices, + 'uploadAttachments' | 'removeAttachment' | 'removeForwardedAttachment' + >; +}) { + const upload = createComposeOperation(options.services.uploadAttachments); + const remove = createComposeOperation(options.services.removeAttachment); + const removeForwarded = createComposeOperation( + options.services.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>(); + + return { + uploading: upload.pending, + async upload(draftId: string) { + const attachments = options + .attachments() + .list() + .filter( + ( + attachment + ): attachment is Extract => + attachment.type === 'local' && !attachment.attachmentID + ); + let run: Promise | undefined; + if (attachments.length) { + run = upload.run({ + draftID: draftId, + attachments: attachments.map((attachment) => attachment.file), + linkId: options.linkId(), + onAttachmentAdded: (file, id) => + options.attachments().assignAttachmentID(file, id), + onAttachmentUploadFailed: (file) => + options.attachments().clearAttachmentID(file), + }); + const settled = run.then( + () => undefined, + () => undefined + ); + inFlight.add(settled); + settled.then(() => inFlight.delete(settled)); + } + 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' ? removeForwarded : remove; + operation.start({ + draftID, + attachmentID: attachment.attachmentID, + linkId: options.linkId(), + }); + }, + }; +} diff --git a/apps/web/src/features/email-compose/primitives/compose-view-state.ts b/apps/web/src/features/email-compose/primitives/compose-view-state.ts index 74644f1ae2b..f4bbda0b240 100644 --- a/apps/web/src/features/email-compose/primitives/compose-view-state.ts +++ b/apps/web/src/features/email-compose/primitives/compose-view-state.ts @@ -1,15 +1,15 @@ +import type { EmailFormRecipients } from '../core/email-recipient'; + +export type { + EmailFormRecipients, + RecipientFieldId, +} from '../core/email-recipient'; + import type { LexicalEditor } from 'lexical'; import type { Accessor, JSX } from 'solid-js'; import type { ComposeBodyActions } from '../context/editor-capabilities'; import type { EmailRecipient } from '../core/email-recipient'; import type { DraftFormAttachment } from './email-form-state'; -export type EmailFormRecipients = { - to: EmailRecipient[]; - cc: EmailRecipient[]; - bcc: EmailRecipient[]; -}; - -export type RecipientFieldId = 'to' | 'cc' | 'bcc'; export type ComposeValidationError = { type: 'no_recipient' | 'no_message' | 'no_subject' | 'no_link'; @@ -21,7 +21,6 @@ export interface ComposeContextValue { isMobile: Accessor; scheduleEnabled: boolean; attachmentFailure(message: string, options?: { subtext?: string }): void; - prepareSignatureLinks?: (root: ShadowRoot) => void; onUpgrade?: () => void; viewerLoading?: Accessor; // Form state (read) @@ -47,7 +46,6 @@ export interface ComposeContextValue { // Actions onSend: () => void; - onSaveDraft?: () => void | Promise; onDelete?: () => void; onSendTimeChange?: (date: Date | null) => void; @@ -87,9 +85,6 @@ export interface ComposeContextValue { onSelectFromLink?: (linkId: 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. diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts index e8978295e06..2c598ba2536 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -7,7 +7,7 @@ import { import { debounce } from '@solid-primitives/scheduled'; import * as EmailValidator from 'email-validator'; import type { LexicalEditor } from 'lexical'; -import { createEffect, createMemo, createSignal, on } from 'solid-js'; +import { createMemo, createSignal } from 'solid-js'; import { unwrap } from 'solid-js/store'; import type { EmailContact } from '../../email-message/core/email-message'; import type { @@ -35,6 +35,8 @@ import { prepareEmailBody, } from '../primitives/prepare-email-body'; import { endUndoSend } from '../primitives/undo-send-claim'; +import { createAttachmentPersistence } from './attachment-persistence'; +import { createEmailSendSchedule } from './email-send-schedule'; import { createEmailUndoStore } from './undo-store'; const DRAFT_DEBOUNCE_MS = 500; @@ -61,11 +63,7 @@ export type EmailComposeInput = { export function createEmailComposer(props: EmailComposeInput) { const services = props.services; - const [scheduling, setScheduling] = createSignal(false); const hasPaidAccess = services.hasPaidAccess; - const uploadAttachmentMutation = createComposeOperation( - services.uploadAttachments - ); const saveDraftMutation = createComposeOperation(services.saveDraft); const deleteDraftMutation = createComposeOperation(services.deleteDraft); const emailContext = props.session; @@ -93,8 +91,8 @@ export function createEmailComposer(props: EmailComposeInput) { const primaryLinkId = services.accounts.primaryId; const link = createMemo(() => { - const data = { links: services.accounts.inboxes() }; - if (!data || data.links.length === 0) return undefined; + const inboxes = services.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 draftLinkId = props.draftID @@ -103,7 +101,7 @@ export function createEmailComposer(props: EmailComposeInput) { .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]; + return inboxes.find((inbox) => inbox.id === targetId) ?? inboxes[0]; }); const toHeaderLinkId = services.accounts.headerId; @@ -146,6 +144,13 @@ export function createEmailComposer(props: EmailComposeInput) { : undefined ); + const attachmentPersistence = createAttachmentPersistence({ + services, + attachments: () => form.attachments, + draftId: currentDraftID, + linkId: headerLinkId, + }); + // Restore form state from undo-send snapshot if available const restoredSnapshot = props.draftID ? composeUndo.take(props.draftID) @@ -209,12 +214,6 @@ export function createEmailComposer(props: EmailComposeInput) { }; } - // 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.pending()) { return; @@ -251,35 +250,7 @@ export function createEmailComposer(props: EmailComposeInput) { 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.run({ - 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; + await attachmentPersistence.upload(draftId); setCurrentDraftID(draftId); return draftId; @@ -301,13 +272,6 @@ export function createEmailComposer(props: EmailComposeInput) { // --- Attachment handling --- - const removeAttachmentMutation = createComposeOperation( - services.removeAttachment - ); - const removeForwardedAttachmentMutation = createComposeOperation( - services.removeForwardedAttachment - ); - const handleAddAttachments = (attachments: DraftFormAttachment[]) => { for (const attachment of attachments) { form.attachments.add(attachment); @@ -317,30 +281,7 @@ export function createEmailComposer(props: EmailComposeInput) { 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.start({ - draftID: savedDraftID, - attachmentID: attachment.attachmentID, - linkId: headerLinkId(), - }); - } else { - removeAttachmentMutation.start({ - draftID: savedDraftID, - attachmentID: attachment.attachmentID, - linkId: headerLinkId(), - }); - } + attachmentPersistence.remove(attachment); }; // --- Content change --- @@ -560,89 +501,26 @@ export function createEmailComposer(props: EmailComposeInput) { // --- Schedule --- - const unscheduleMessageMutation = createComposeOperation( - services.unschedule, - { - onSuccess: (_data, vars) => { - services.feedback.success('Email unscheduled'); - services.invalidatePreview(vars.draftID); - }, - onError: () => { - services.feedback.failure('Failed to unschedule email'); - }, - } - ); - - const handleSendTimeChange = async (date: Date | null) => { - if (scheduling()) return; - setScheduling(true); - try { - const previous = form.sendTime(); - const currentDraft = currentDraftID(); - if (!date && previous && currentDraft) { - try { - await unscheduleMessageMutation.run({ - draftID: currentDraft, - linkId: headerLinkId(), - }); - form.setSendTime(null); - setDraftDirty(true); - } catch { - /* The operation reports the failure; keep the confirmed send time. */ - } - return; - } - if (!date) { - form.setSendTime(null); - setDraftDirty(true); - return; - } - try { - const draftID = currentDraft ?? (await executeSaveDraft()); - if (!draftID) throw new Error('Draft required'); - await services.schedule( - { draftID, send_time: date.toISOString() }, - headerLinkId() - ); - form.setSendTime(date); - setDraftDirty(true); - } catch (error) { - services.reportError(error); - services.feedback.failure('Failed to schedule message'); - return; - } - const threadID = saveDraftMutation.result()?.draft.thread_db_id; - if (threadID) { - try { - await services.archive({ id: threadID, value: true }, headerLinkId()); - } catch (error) { - services.reportError(error); - services.feedback.failure( - 'Email scheduled, but unable to mark thread done' - ); - } - } - } finally { - setScheduling(false); - } - }; - - // Unschedule when all recipients are removed const totalRecipientCount = () => { - const r = form.recipients(); - return r.to.length + r.cc.length + r.bcc.length; + const recipients = form.recipients(); + return recipients.to.length + recipients.cc.length + recipients.bcc.length; }; - createEffect( - on( - totalRecipientCount, - (count) => { - if (count === 0 && form.sendTime()) { - handleSendTimeChange(null); - } - }, - { defer: true } - ) - ); + const schedule = createEmailSendSchedule({ + services, + draftId: currentDraftID, + saveDraft: executeSaveDraft, + threadId: () => saveDraftMutation.result()?.draft.thread_db_id, + linkId: headerLinkId, + sendTime: form.sendTime, + setSendTime: (date) => { + form.setSendTime(date); + setDraftDirty(true); + }, + recipientCount: totalRecipientCount, + onUnscheduled: services.invalidatePreview, + }); + const scheduling = schedule.pending; + const handleSendTimeChange = schedule.change; // --- Reset / delete --- @@ -742,7 +620,6 @@ export function createEmailComposer(props: EmailComposeInput) { isMobile: services.isMobile, scheduleEnabled: services.scheduleEnabled, attachmentFailure: services.feedback.failure, - prepareSignatureLinks: services.prepareSignatureLinks, onUpgrade: services.onUpgrade, viewerLoading: services.viewerLoading, // Form state (read) diff --git a/apps/web/src/features/email-compose/primitives/email-form-state.ts b/apps/web/src/features/email-compose/primitives/email-form-state.ts index 60f9526776e..b5e3d1c7b01 100644 --- a/apps/web/src/features/email-compose/primitives/email-form-state.ts +++ b/apps/web/src/features/email-compose/primitives/email-form-state.ts @@ -1,3 +1,10 @@ +import type { EmailFormRecipients } from '../core/email-recipient'; + +export type { + EmailFormRecipients, + RecipientFieldId, +} 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 type { LexicalEditor } from 'lexical'; @@ -14,12 +21,6 @@ import type { ReplyType } from '../core/reply-type'; import { getSubjectText } from '../core/subject-text'; import { TOGGLE_APPEND_EMAIL_THREAD_COMMAND } from './email-editor-commands'; -export type EmailFormRecipients = { - to: EmailRecipient[]; - cc: EmailRecipient[]; - bcc: EmailRecipient[]; -}; - export type DraftFormAttachment = | { type: 'local'; 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..27d819763c2 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/email-send-schedule.ts @@ -0,0 +1,95 @@ +import { type Accessor, createEffect, createSignal, on } from 'solid-js'; +import type { EmailComposeServices } from '../context/compose-services'; +import { createComposeOperation } from './compose-operation'; + +type ScheduleServices = Pick< + EmailComposeServices, + 'schedule' | 'unschedule' | 'archive' | 'feedback' | 'reportError' +>; + +export function createEmailSendSchedule(options: { + services: ScheduleServices; + draftId: Accessor; + saveDraft: () => Promise; + threadId: Accessor; + linkId: Accessor; + sendTime: Accessor; + setSendTime: (date: Date | null) => void; + recipientCount: Accessor; + onUnscheduled?: (draftId: string) => void; +}) { + const { services } = options; + const [pending, setPending] = createSignal(false); + const unschedule = createComposeOperation(services.unschedule, { + onSuccess: (_data, vars) => { + services.feedback.success('Email unscheduled'); + options.onUnscheduled?.(vars.draftID); + }, + onError: () => services.feedback.failure('Failed to unschedule email'), + }); + + const change = async (date: Date | null) => { + if (pending()) return; + setPending(true); + try { + const previous = options.sendTime(); + const currentDraft = options.draftId(); + if (!date && previous && currentDraft) { + try { + await unschedule.run({ + draftID: currentDraft, + linkId: options.linkId(), + }); + options.setSendTime(null); + } catch { + // The operation reports failure; retain the confirmed send time. + } + return; + } + if (!date) { + options.setSendTime(null); + return; + } + try { + const draftID = currentDraft ?? (await options.saveDraft()); + if (!draftID) throw new Error('Draft required'); + await services.schedule( + { draftID, send_time: date.toISOString() }, + options.linkId() + ); + options.setSendTime(date); + } catch (error) { + services.reportError(error); + services.feedback.failure('Failed to schedule message'); + return; + } + const threadID = options.threadId(); + if (threadID) { + try { + await services.archive( + { id: threadID, value: true }, + options.linkId() + ); + } catch (error) { + services.reportError(error); + services.feedback.failure( + 'Email scheduled, but unable to mark thread done' + ); + } + } + } 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/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-input.ts b/apps/web/src/features/email-compose/primitives/reply-input.ts index 7be3335ac53..7e933cff0ef 100644 --- a/apps/web/src/features/email-compose/primitives/reply-input.ts +++ b/apps/web/src/features/email-compose/primitives/reply-input.ts @@ -2,7 +2,6 @@ import { MACRO_EMAIL_SIGNATURE, MAX_ATTACHMENTS_BYTES_SIZE, } from '@app/features/email-compose/core/constants'; -import type { EmailRecipient } from '@app/features/email-compose/core/email-recipient'; import type { EmailMessage } from '@app/features/email-message/core/email-message'; import { isPersonalMessage } from '@app/features/email-message/core/is-personal-message'; import type { @@ -11,8 +10,11 @@ import type { } from '../context/compose-services'; import type { EmailReplySession } from '../context/email-form-dependencies'; import type { EmailDraft } from '../core/email-draft'; -import { getRecipientDisplayName } from '../core/email-recipient'; import { createComposeOperation } from '../primitives/compose-operation'; +import { createAttachmentPersistence } from './attachment-persistence'; +import { createEmailSendSchedule } from './email-send-schedule'; +import { createReplyComposerFocus } from './reply-composer-focus'; +import { createReplyRecipientFields } from './reply-recipient-fields'; import { createEmailUndoStore } from './undo-store'; type EmailDraftId = string | null; @@ -106,7 +108,7 @@ export type ReplyInputProps = { onClose: () => void; }; }; -type ReplyEditorOptions = { +export type ReplyEditorOptions = { namespace: string; onChange?: (markdown: string) => void; onUserMention?: (mention: UserMentionRecord) => void; @@ -164,7 +166,6 @@ export function createReplyInput( const sourceEntityId = props.sourceEntityId; const undoKey = `${sourceEntityId}:${props.replyingTo()?.db_id ?? props.draft?.replying_to_id ?? props.draft?.db_id ?? 'new'}`; const services = props.services; - const [scheduling, setScheduling] = createSignal(false); const userEmail = services.viewerEmail; const toHeaderLinkId = services.accounts.headerId; @@ -189,10 +190,7 @@ export function createReplyInput( const sendingLink = createMemo(() => services.accounts.inboxes().find((l) => l.id === activeLinkId()) ); - const signature = () => - services.accounts.inboxes().find((inbox) => inbox.id === activeLinkId()) - ?.settings.signature ?? undefined; - const emailSignaturesFlag = () => ({ enabled: services.signaturesEnabled() }); + const signature = () => sendingLink()?.settings.signature ?? undefined; // Whether this reply includes the signature. Defaults on, reset per reply, // and dismissable via the preview ✕. const [includeSignature, setIncludeSignature] = createSignal(true); @@ -201,7 +199,7 @@ export function createReplyInput( // 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 && + services.signaturesEnabled() && props.replyingTo() && includeSignature() && sendingLink()?.settings.signature_on_replies_forwards @@ -219,18 +217,20 @@ export function createReplyInput( 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); + const recipients = createReplyRecipientFields({ + values: () => form().recipients(), + setValues: (field, values) => form().setRecipients(field, values), + onChange: scheduleDraftSave, + container: dom.container, + }); + 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); @@ -445,9 +445,13 @@ export function createReplyInput( }, }); - const uploadAttachmentMutation = createComposeOperation( - services.uploadAttachments - ); + const attachmentPersistence = createAttachmentPersistence({ + services, + attachments: () => form().attachments, + draftId: savedDraftId, + linkId: headerLinkId, + }); + const addForwardedAttachmentsMutation = createComposeOperation( services.addForwardedAttachments ); @@ -461,52 +465,6 @@ export function createReplyInput( } } - // 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 (!dom.container()?.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 - dom.footer()?.scrollIntoView({ block: 'nearest' }); - }, 100); - }; - // Attach side-effect handlers on mount; they replay against current state onMount(() => { form().setOnDirty(() => { @@ -517,12 +475,9 @@ export function createReplyInput( setComposerExpanded(false); if (rt === 'forward') { setQuoteCollapsed(true); - focusForwardRecipients(); + focus.forward(); } else if (rt === 'reply' || rt === 'reply-all') { - setTimeout(() => { - editor()?.focus(); - dom.footer()?.scrollIntoView({ block: 'nearest' }); - }, 100); + focus.reply(); } }); }); @@ -574,12 +529,6 @@ export function createReplyInput( }; } - // 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.pending() || pendingDeletion || pendingSend) { return; @@ -629,40 +578,7 @@ export function createReplyInput( 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.run({ - 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; + await attachmentPersistence.upload(draftId); // Sync forwarded attachments const forwardedAttachments = form() @@ -783,7 +699,7 @@ export function createReplyInput( } else if (requestReplyType === 'forward') { // setReplyType is skipped when the type is unchanged, so land the // cursor in the To field explicitly - focusForwardRecipients(); + focus.forward(); } // Forwards focus the To field; focusing the editor would steal it back if (requestReplyType !== 'forward') { @@ -792,47 +708,6 @@ export function createReplyInput( 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) => { @@ -844,34 +719,11 @@ export function createReplyInput( 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 (!dom.container()?.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 = services.hasPaidAccess; const sendEmail = async (markDone = false) => { if (scheduling()) return; - if (sendMutation.pending() || uploadAttachmentMutation.pending()) return; + if (sendMutation.pending() || attachmentPersistence.uploading()) return; const to = form().recipients().to.map(convertEmailRecipientToContactInfo); const cc = form().recipients().cc.map(convertEmailRecipientToContactInfo); @@ -1121,10 +973,7 @@ export function createReplyInput( } const ed = editor(); if (!ed) return; - requestAnimationFrame(() => { - ed.focus(); - form().setShouldFocusInput(false); - }); + focus.editor(() => form().setShouldFocusInput(false)); }); const handleAddAttachments = (files: File[]) => { @@ -1164,124 +1013,27 @@ export function createReplyInput( scheduleDraftSave(); }; - const removeAttachmentMutation = createComposeOperation( - services.removeAttachment - ); - const removeForwardedAttachmentMutation = createComposeOperation( - services.removeForwardedAttachment - ); - - 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 handleRemoveAttachment = attachmentPersistence.remove; - const currentDraftID = savedDraftId(); - - if (!currentDraftID || !attachment.attachmentID) return; - - if (attachment.type === 'forwarded') { - removeForwardedAttachmentMutation.start({ - draftID: currentDraftID, - attachmentID: attachment.attachmentID, - linkId: headerLinkId(), - }); - } else { - removeAttachmentMutation.start({ - draftID: currentDraftID, - attachmentID: attachment.attachmentID, - linkId: headerLinkId(), - }); - } - }; - - const unscheduleMessageMutation = createComposeOperation( - services.unschedule, - { - onSuccess: () => { - services.feedback.success('Email unscheduled'); - }, - onError: () => { - services.feedback.failure('Failed to unschedule email'); - }, - } - ); - - const handleSendTimeChange = async (date: Date | null) => { - if (scheduling()) return; - setScheduling(true); - try { - const previous = form().sendTime(); - const currentDraft = savedDraftId(); - if (!date && previous && currentDraft) { - try { - await unscheduleMessageMutation.run({ - draftID: currentDraft, - linkId: headerLinkId(), - }); - form().setSendTime(null); - } catch { - /* The operation reports the failure; keep the confirmed send time. */ - } - return; - } - if (!date) { - form().setSendTime(null); - return; - } - try { - const draftID = currentDraft ?? (await executeSaveDraft()); - if (!draftID) throw new Error('Draft required'); - await services.schedule( - { draftID, send_time: date.toISOString() }, - headerLinkId() - ); - form().setSendTime(date); - } catch (error) { - services.reportError(error); - services.feedback.failure('Failed to schedule message'); - return; - } - const threadID = ctx.thread()?.db_id; - if (threadID) { - try { - await services.archive({ id: threadID, value: true }, headerLinkId()); - } catch (error) { - services.reportError(error); - services.feedback.failure( - 'Email scheduled, but unable to mark thread done' - ); - } - } - } finally { - setScheduling(false); - } - }; - - // 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 schedule = createEmailSendSchedule({ + services, + draftId: savedDraftId, + saveDraft: executeSaveDraft, + threadId: () => ctx.thread()?.db_id, + linkId: headerLinkId, + sendTime: () => form().sendTime(), + setSendTime: (date) => form().setSendTime(date), + recipientCount: () => { + const recipients = form().recipients(); + return ( + recipients.to.length + recipients.cc.length + recipients.bcc.length + ); + }, + }); + const scheduling = schedule.pending; + const handleSendTimeChange = schedule.change; const hasBodyText = () => bodyMacro().trim().length > 0; - const isMobileDrawer = () => props.mobileDrawer !== undefined; - const composePortalScope = () => (isMobileDrawer() ? 'local' : undefined); const sendActionHidden = () => services.isTouch() && !hasBodyText() && @@ -1289,7 +1041,7 @@ export function createReplyInput( effectiveReplyType() !== 'forward'; const sendActionDisabled = () => scheduling() || - uploadAttachmentMutation.pending() || + attachmentPersistence.uploading() || sendMutation.pending() || !!form().sendTime(); const scheduleSendDisabled = () => @@ -1297,36 +1049,6 @@ export function createReplyInput( (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 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; @@ -1352,63 +1074,60 @@ export function createReplyInput( }); }; + const handleEditorDrop = ( + files: FileSystemFileEntry[], + directories: FileSystemDirectoryEntry[], + event: DragEvent | undefined, + onUploaded: () => void + ) => { + const currentEditor = editor(); + if (!currentEditor || !event) return; + services.uploadEditorFiles({ + editor: currentEditor, + sourceId: sourceEntityId, + files, + directories, + dropEvent: event, + onUploaded: (ids) => { + onUploaded(); + ids.forEach(services.makePublic); + scheduleDraftSave(); + }, + }); + }; + return { editorOptions, - ctx, form, - sourceEntityId, - services, activeLinkId, activeInboxEmail, - signature, + replyType: effectiveReplyType, + signatureHtml: replySignatureHtml, setIncludeSignature, setScrollContainer, composerExpanded, setComposerExpanded, quoteCollapsed, setQuoteCollapsed, - showExpandedRecipients, - setShowExpandedRecipients, - isDragging, - setIsDragging, - setToRef, - ccRef, - setCcRef, - bccRef, - setBccRef, - showCc, - setShowCc, - showBcc, - setShowBcc, - recipientDragState, savedDraftId, initialHtml, handleEditorConnect, - sendMutation, - uploadAttachmentMutation, + isSending: sendMutation.pending, + isUploading: attachmentPersistence.uploading, + recipients, collectDraft, scheduleDraftSave, persistDraftOnSenderSwitch, - handleChipDragStart, - handleChipDragEnd, - handleRecipientDrop, - withDraftSave, hasPaidAccess, sendEmail, deleteDraftAndReset, handleAddAttachments, handleRemoveAttachment, + handleEditorDrop, handleSendTimeChange, - isMobileDrawer, - composePortalScope, sendActionHidden, sendActionDisabled, scheduleSendDisabled, - scrollAreaSignatureHtml, - footerSignatureHtml, - replyingToSummary, - mobileDrawerCcBccOpen, - toggleMobileDrawerCcBcc, 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..517b6f5e246 --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/reply-recipient-fields.test.ts @@ -0,0 +1,68 @@ +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, + 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 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..3bb564131da --- /dev/null +++ b/apps/web/src/features/email-compose/primitives/reply-recipient-fields.ts @@ -0,0 +1,115 @@ +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; +}) { + 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 (!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 = 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 as Node | null; + if (!target) return; + const combobox = document.querySelector('div[data-popper-positioner]'); + if ( + !options.container()?.contains(target) && + !combobox?.contains(target) + ) { + 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 { + showExpandedRecipients, + setShowExpandedRecipients, + toRef, + setToRef, + ccRef, + setCcRef, + bccRef, + setBccRef, + showCc, + setShowCc, + showBcc, + setShowBcc, + recipientDragState, + handleChipDragStart, + handleChipDragEnd, + handleRecipientDrop, + mobileDrawerCcBccOpen, + toggleMobileDrawerCcBcc, + setRecipients(field: RecipientFieldId, values: EmailRecipient[]) { + options.setValues(field, values); + options.onChange(); + }, + }; +} diff --git a/apps/web/src/features/email-compose/views/compose-layout.tsx b/apps/web/src/features/email-compose/views/compose-layout.tsx index 42a2f06e11f..962b51e05bf 100644 --- a/apps/web/src/features/email-compose/views/compose-layout.tsx +++ b/apps/web/src/features/email-compose/views/compose-layout.tsx @@ -281,7 +281,7 @@ export function ComposeLayout(props: { ); }} /> - {props.toolbar ?? ctx.toolbar?.()} + {props.toolbar} ); diff --git a/apps/web/src/features/email-compose/views/compose-toolbar.tsx b/apps/web/src/features/email-compose/views/compose-toolbar.tsx index 1dbebce265a..ea4d5d98522 100644 --- a/apps/web/src/features/email-compose/views/compose-toolbar.tsx +++ b/apps/web/src/features/email-compose/views/compose-toolbar.tsx @@ -127,18 +127,6 @@ export function EmailComposeToolbar(props: {
- - -
- - - + ; + values: Accessor; + options: Accessor; + inboxes: Accessor; + activeLinkId: 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; + }) => ( + + 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 index 907112283c0..225ef8a0bad 100644 --- a/apps/web/src/features/email-compose/views/reply-input.tsx +++ b/apps/web/src/features/email-compose/views/reply-input.tsx @@ -1,11 +1,8 @@ -import type { EmailRecipient } from '@app/features/email-compose/core/email-recipient'; 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 type { UserMentionRecord } from '@core/component/LexicalMarkdown/utils/mentionsUtils'; -import { RecipientSelector } from '@core/component/RecipientSelector'; import { fileFolderDrop } from '@core/directive/fileFolderDrop'; import { fileSelector } from '@core/directive/fileSelector'; import { registerHotkey, useHotkeyDOMScope } from '@core/hotkey/hotkeys'; @@ -13,8 +10,6 @@ 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 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'; @@ -22,40 +17,18 @@ 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 { - type Accessor, - createSignal, - For, - Match, - onMount, - Show, - Switch, -} from 'solid-js'; +import { createSignal, For, onMount, Show } from 'solid-js'; import { EmailDateSelector } from '../components/email-date-selector'; -import { FromInboxSelector } from '../components/from-inbox-selector'; import { MacroSignatureButton } from '../components/macro-signature-button'; import { SignaturePreview } from '../components/signature-preview'; import { registerToggleAppendedThread } from '../primitives/prepare-email-body'; import { getOrInitEmailFormContext } from './email-form-context'; +import { ReplyEnvelope } from './reply-envelope'; false && fileFolderDrop; false && fileSelector; -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 -) { +function createConfiguredEmailMarkdownEditor(options: ReplyEditorOptions) { const editor = buildConfig('markdown') .namespace(options.namespace) .withMentions({ @@ -89,12 +62,15 @@ function createConfiguredEmailMarkdownEditor( return editor; } -import { RecipientDropRow } from '../components/recipient-drop-row'; import { createReplyInput, + type ReplyEditorOptions, type ReplyInputProps, } from '../primitives/reply-input'; export function ReplyInputView(props: ReplyInputProps) { + const services = props.services; + const ctx = props.session; + const [isDragging, setIsDragging] = createSignal(); let composeContainerRef: HTMLDivElement | undefined; let bottomBarRef: HTMLDivElement | undefined; const [editor, setEditor] = createSignal(); @@ -105,10 +81,7 @@ export function ReplyInputView(props: ReplyInputProps) { getOrInitEmailFormContext ); const { - ctx, form, - sourceEntityId, - services, activeLinkId, activeInboxEmail, setIncludeSignature, @@ -117,50 +90,32 @@ export function ReplyInputView(props: ReplyInputProps) { setComposerExpanded, quoteCollapsed, setQuoteCollapsed, - showExpandedRecipients, - setShowExpandedRecipients, - isDragging, - setIsDragging, - setToRef, - ccRef, - setCcRef, - bccRef, - setBccRef, - showCc, - setShowCc, - showBcc, - setShowBcc, - recipientDragState, savedDraftId, initialHtml, handleEditorConnect, - sendMutation, - uploadAttachmentMutation, + isSending, + isUploading, collectDraft, scheduleDraftSave, persistDraftOnSenderSwitch, - handleChipDragStart, - handleChipDragEnd, - handleRecipientDrop, - withDraftSave, hasPaidAccess, sendEmail, deleteDraftAndReset, handleAddAttachments, handleRemoveAttachment, handleSendTimeChange, - isMobileDrawer, - composePortalScope, sendActionHidden, sendActionDisabled, scheduleSendDisabled, - scrollAreaSignatureHtml, - footerSignatureHtml, - replyingToSummary, - mobileDrawerCcBccOpen, - toggleMobileDrawerCcBcc, toggleQuotedText, } = state; + const isMobileDrawer = () => props.mobileDrawer !== undefined; + const composePortalScope = () => + isMobileDrawer() ? ('local' as const) : undefined; + const scrollAreaSignatureHtml = () => + isMobileDrawer() ? state.signatureHtml() : undefined; + const footerSignatureHtml = () => + isMobileDrawer() ? undefined : state.signatureHtml(); const editorConfig = createConfiguredEmailMarkdownEditor(state.editorOptions); const markdownHandle = editorConfig.buildHandle(); setEditor(markdownHandle.lexical); @@ -275,44 +230,22 @@ export function ReplyInputView(props: ReplyInputProps) { > {(attachment) => ( - - - {(attachment) => ( - handleRemoveAttachment(attachment())} - /> - )} - - - {(attachment) => ( - handleRemoveAttachment(attachment())} - /> - )} - - - {(attachment) => ( - handleRemoveAttachment(attachment())} - /> - )} - - + handleRemoveAttachment(attachment)} + /> )} @@ -378,367 +311,37 @@ export function ReplyInputView(props: ReplyInputProps) { /> 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={ - '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' - } - 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={ - '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' - } - 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={ - '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' - } - 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:" - /> -
-
- + form().recipients()} + options={props.session.recipientOptions} + inboxes={services.accounts.inboxes} + activeLinkId={activeLinkId} + senderEmail={activeInboxEmail} + onSenderChange={persistDraftOnSenderSwitch} + subject={() => form().subject()} + onSubjectChange={(subject) => { + form().setSubject(subject); + scheduleDraftSave(); + }} + showSubject={!!(props.isEditingExisting || props.newMessage)} + mobile={isMobileDrawer} + portalScope={composePortalScope} + replyType={state.replyType} + />
setIsDragging(valid), onDragEnd: () => setIsDragging(false), - onDrop: (fileEntries, folderEntries, e) => { - const editor_ = editor(); - if (!editor_ || !e) return; - services.uploadEditorFiles({ - editor: editor_, - sourceId: sourceEntityId, - files: fileEntries, - directories: folderEntries, - dropEvent: e, - onUploaded: (ids) => { - setIsDragging(false); - ids.forEach(services.makePublic); - scheduleDraftSave(); - }, - }); + onDrop: (files, directories, event) => { + state.handleEditorDrop(files, directories, event, () => + setIsDragging(false) + ); }, }} > @@ -804,7 +396,7 @@ export function ReplyInputView(props: ReplyInputProps) { quoteCollapsed() && 'quote-collapsed', isDragging() && 'blur' )} - disabled={sendMutation.pending()} + disabled={isSending()} initialValue={initialHtml() ? undefined : props.preloadedBody} placeholder={ isMobileDrawer() @@ -942,12 +534,8 @@ export function ReplyInputView(props: ReplyInputProps) { />
diff --git a/apps/web/src/features/email-compose/views/compose-subject.tsx b/apps/web/src/features/email-compose/views/compose-subject.tsx index 3dcaf6016d4..579fbccdc92 100644 --- a/apps/web/src/features/email-compose/views/compose-subject.tsx +++ b/apps/web/src/features/email-compose/views/compose-subject.tsx @@ -1,6 +1,6 @@ import { cn } from '@ui'; import { createSignal, Show } from 'solid-js'; -import { useCompose } from './compose-context'; +import { useCompose } from '../context/compose-context'; function autosize(el: HTMLTextAreaElement) { el.style.height = 'auto'; diff --git a/apps/web/src/features/email-compose/views/compose-toolbar.tsx b/apps/web/src/features/email-compose/views/compose-toolbar.tsx index ea4d5d98522..79935a9971b 100644 --- a/apps/web/src/features/email-compose/views/compose-toolbar.tsx +++ b/apps/web/src/features/email-compose/views/compose-toolbar.tsx @@ -17,7 +17,7 @@ import { Button, SendButton, Tooltip } from '@ui'; import { FORMAT_TEXT_COMMAND, type LexicalEditor } from 'lexical'; import { createSignal, Show } from 'solid-js'; import { EmailDateSelector } from '../components/email-date-selector'; -import { useCompose } from './compose-context'; +import { useCompose } from '../context/compose-context'; export function EmailComposeToolbar(props: { editor?: () => LexicalEditor | undefined; diff --git a/apps/web/src/features/email-compose/views/email-compose.tsx b/apps/web/src/features/email-compose/views/email-compose.tsx index e42a605e5fd..756c5372de9 100644 --- a/apps/web/src/features/email-compose/views/email-compose.tsx +++ b/apps/web/src/features/email-compose/views/email-compose.tsx @@ -11,24 +11,24 @@ import { Surface } from '@ui'; import { createSignal, Show } from 'solid-js'; import { SignaturePreview } from '../components/signature-preview'; -import type { EmailComposeServices } from '../context/compose-services'; +import type { EmailComposeEnvironment } from '../context/compose-capabilities'; +import { ComposeProvider } from '../context/compose-context'; import type { ComposeContextValue } from '../primitives/compose-view-state'; import { createEmailComposer, - type EmailComposeInput, + type EmailComposerOptions, } from '../primitives/email-composer'; import { ComposeLayout } from '../views/compose-layout'; import { EmailComposeToolbar } from '../views/compose-toolbar'; -import { ComposeProvider } from './compose-context'; export type EmailComposeViewProps = Pick< - EmailComposeInput, + EmailComposerOptions, | 'host' | 'draft' - | 'draftID' + | 'draftId' | 'recipientOptions' | 'onRecipientsChange' | 'initialTo' -> & { services: EmailComposeServices }; +> & { services: EmailComposeEnvironment }; export function EmailComposeView(props: EmailComposeViewProps) { const services = props.services; const state = createEmailComposer({ @@ -43,7 +43,7 @@ export function EmailComposeView(props: EmailComposeViewProps) { recipientName: services.recipientName, host: props.host, draft: props.draft, - draftID: props.draftID, + draftId: props.draftId, recipientOptions: props.recipientOptions, onRecipientsChange: props.onRecipientsChange, initialTo: props.initialTo, @@ -51,7 +51,7 @@ export function EmailComposeView(props: EmailComposeViewProps) { const { editor, previewName, - hasLinkError, + hasInboxError, draftDirty, deleteDraftAndReset, signature, @@ -138,7 +138,7 @@ export function EmailComposeView(props: EmailComposeViewProps) { > } - notice={hasLinkError() ? : undefined} + 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 index ab0a1d4e9ac..4c25e5a4ab6 100644 --- a/apps/web/src/features/email-compose/views/reply-envelope.tsx +++ b/apps/web/src/features/email-compose/views/reply-envelope.tsx @@ -6,7 +6,7 @@ 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-services'; +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'; @@ -18,7 +18,7 @@ type ReplyEnvelopeProps = { values: Accessor; options: Accessor; inboxes: Accessor; - activeLinkId: Accessor; + activeInboxId: Accessor; senderEmail: Accessor; onSenderChange: (id: string) => void; subject: Accessor; @@ -133,7 +133,7 @@ export function ReplyEnvelope(props: ReplyEnvelopeProps) { pill class="min-w-0" links={props.inboxes()} - activeLinkId={props.activeLinkId()} + activeInboxId={props.activeInboxId()} onSelect={props.onSenderChange} portalScope={props.portalScope()} /> @@ -301,7 +301,7 @@ export function ReplyEnvelope(props: ReplyEnvelopeProps) { compact class="min-w-0 truncate text-ink-muted" links={props.inboxes()} - activeLinkId={props.activeLinkId()} + activeInboxId={props.activeInboxId()} onSelect={props.onSenderChange} portalScope={props.portalScope()} /> diff --git a/apps/web/src/features/email-compose/views/reply-input.tsx b/apps/web/src/features/email-compose/views/reply-input.tsx index b2d90983018..82c6cc209a6 100644 --- a/apps/web/src/features/email-compose/views/reply-input.tsx +++ b/apps/web/src/features/email-compose/views/reply-input.tsx @@ -3,6 +3,7 @@ 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 type { UserMentionRecord } from '@core/component/LexicalMarkdown/utils/mentionsUtils'; import { fileFolderDrop } from '@core/directive/fileFolderDrop'; import { fileSelector } from '@core/directive/fileSelector'; import { registerHotkey, useHotkeyDOMScope } from '@core/hotkey/hotkeys'; @@ -21,8 +22,9 @@ import { type Accessor, 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 { EmailComposeEnvironment } from '../context/compose-capabilities'; +import { getOrInitEmailFormContext } from '../context/email-form-context'; import { registerToggleAppendedThread } from '../primitives/prepare-email-body'; -import { getOrInitEmailFormContext } from './email-form-context'; import { ReplyEnvelope } from './reply-envelope'; false && fileFolderDrop; @@ -63,16 +65,14 @@ function createConfiguredEmailMarkdownEditor(options: ReplyEditorOptions) { } import { - createReplyInput, - type ReplyInputProps, -} from '../primitives/reply-input'; + createReplyComposer, + type ReplyComposerOptions, +} from '../primitives/reply-composer'; type ReplyEditorOptions = { namespace: string; onChange?: (markdown: string) => void; - onUserMention?: ( - mention: import('@core/component/LexicalMarkdown/utils/mentionsUtils').UserMentionRecord - ) => void; + onUserMention?: (mention: UserMentionRecord) => void; onDocumentMention?: (item: { id: string }) => void; onPasteFilesAndDirs?: ( files: FileSystemFileEntry[], @@ -81,7 +81,7 @@ type ReplyEditorOptions = { scrollContainer?: Accessor; }; type ReplyInputViewProps = Omit< - ReplyInputProps, + ReplyComposerOptions, | 'drafts' | 'attachmentStorage' | 'delivery' @@ -92,7 +92,7 @@ type ReplyInputViewProps = Omit< | 'recordMention' | 'focusAfterReplyRequest' > & { - services: import('../context/compose-services').EmailComposeServices; + services: EmailComposeEnvironment; markdownDomRef?: (ref: HTMLDivElement) => void | HTMLDivElement; unframed?: boolean; mobileDrawer?: { onClose: () => void }; @@ -104,7 +104,7 @@ export function ReplyInputView(props: ReplyInputViewProps) { let composeContainerRef: HTMLDivElement | undefined; let bottomBarRef: HTMLDivElement | undefined; const [editor, setEditor] = createSignal(); - const state = createReplyInput( + const state = createReplyComposer( { drafts: services.drafts, attachmentStorage: services.attachmentStorage, @@ -135,7 +135,7 @@ export function ReplyInputView(props: ReplyInputViewProps) { ); const { form, - activeLinkId, + activeInboxId, activeInboxEmail, setIncludeSignature, setScrollContainer, @@ -387,7 +387,7 @@ export function ReplyInputView(props: ReplyInputViewProps) { values={() => form().recipients()} options={props.session.recipientOptions} inboxes={services.accounts.inboxes} - activeLinkId={activeLinkId} + activeInboxId={activeInboxId} senderEmail={activeInboxEmail} onSenderChange={persistDraftOnSenderSwitch} subject={() => form().subject()} 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 index 2bfe0173c56..72e120db223 100644 --- a/apps/web/src/features/email-message/primitives/email-message-body.ts +++ b/apps/web/src/features/email-message/primitives/email-message-body.ts @@ -16,7 +16,7 @@ export interface EmailMessageBodyProps { isPersonal: boolean; isBodyExpanded: Accessor; setExpandedMessageBody: (id: string) => void; - setFocusedMessageId: (messageID: string | undefined) => void; + setFocusedMessageId: (messageId: string | undefined) => void; showFullContent?: boolean; isFocused: boolean; } diff --git a/apps/web/src/features/email-thread/context/email-thread-state-context.tsx b/apps/web/src/features/email-thread/context/email-thread-state-context.tsx new file mode 100644 index 00000000000..32ca538ce3f --- /dev/null +++ b/apps/web/src/features/email-thread/context/email-thread-state-context.tsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'solid-js'; +import type { EmailThreadState } from '../primitives/email-thread-state'; + +const EmailThreadStateContext = createContext(); +export const EmailThreadStateProvider = EmailThreadStateContext.Provider; +export function useEmailThreadState() { + const ctx = useContext(EmailThreadStateContext); + if (!ctx) + throw new Error( + 'useEmailThreadState must be used within an EmailThreadStateProvider' + ); + return ctx; +} diff --git a/apps/web/src/features/email-thread/views/thread-environment.tsx b/apps/web/src/features/email-thread/context/thread-environment.tsx similarity index 80% rename from apps/web/src/features/email-thread/views/thread-environment.tsx rename to apps/web/src/features/email-thread/context/thread-environment.tsx index 53b372e8b07..6d0d4b807fa 100644 --- a/apps/web/src/features/email-thread/views/thread-environment.tsx +++ b/apps/web/src/features/email-thread/context/thread-environment.tsx @@ -1,18 +1,18 @@ import { createContext, type JSX, useContext } from 'solid-js'; import type { + EmailComposeEnvironment, EmailComposeHost, - EmailComposeServices, -} from '../../email-compose/context/compose-services'; +} from '../../email-compose/context/compose-capabilities'; import type { EmailAttachment, EmailMessage, } from '../../email-message/core/email-message'; -import type { EmailThreadDependencies } from '../context/email-thread-dependencies'; +import type { EmailThreadDependencies } from './email-thread-dependencies'; export interface ThreadViewEnvironment { dependencies: EmailThreadDependencies; copySubject?: (subject: string) => void; - compose: EmailComposeServices; + compose: EmailComposeEnvironment; composeHost?: EmailComposeHost; rendering: { renderAvatar?: (message: EmailMessage) => JSX.Element; diff --git a/apps/web/src/features/email-thread/email-thread.tsx b/apps/web/src/features/email-thread/email-thread.tsx index 932e8fc8209..b9a769a7017 100644 --- a/apps/web/src/features/email-thread/email-thread.tsx +++ b/apps/web/src/features/email-thread/email-thread.tsx @@ -7,8 +7,8 @@ import { createEffectOnEntityTypeNotification } from '@notifications'; import { clearSavedDraftThreadCache } from '@queries/email/draft-cache'; import { useThreadQuery } from '@queries/email/thread'; import { createEffect, createMemo, onCleanup } from 'solid-js'; +import { createEmailComposeEnvironment } from '../email-compose/compose-adapter'; import { createEmailComposeHost } from '../email-compose/compose-host-adapter'; -import { createEmailComposeServices } from '../email-compose/compose-service-adapter'; import { convertContactInfoToEmailRecipient } from '../email-compose/core/recipient-conversion'; import { createEmailAttachmentOpener } from '../email-message/attachment-action-adapter'; import type { EmailMessage } from '../email-message/core/email-message'; @@ -36,7 +36,7 @@ export function EmailThread(props: EmailThreadProps) { const contacts = useContacts(); const viewerEmail = useEmail(); const user = useUserContext(); - const compose = createEmailComposeServices(); + const compose = createEmailComposeEnvironment(); const dependencies: EmailThreadDependencies = { source, viewerEmail, diff --git a/apps/web/src/features/email-thread/primitives/email-thread-state.test.ts b/apps/web/src/features/email-thread/primitives/email-thread-state.test.ts index 06104d34daf..ee6ca2555e5 100644 --- a/apps/web/src/features/email-thread/primitives/email-thread-state.test.ts +++ b/apps/web/src/features/email-thread/primitives/email-thread-state.test.ts @@ -68,9 +68,9 @@ describe('thread state with an injected source', () => { const second = createEmailThreadState(dependencies(source)); first.messages.setFocused('one'); first.setIsScrollingToMessage(true); - expect(first.messages.focusedID()).toBe('one'); + expect(first.messages.focusedId()).toBe('one'); expect(first.isScrollingToMessage()).toBe(true); - expect(second.messages.focusedID()).toBeUndefined(); + expect(second.messages.focusedId()).toBeUndefined(); expect(second.isScrollingToMessage()).toBe(false); first.query.fetchNextPage(); expect(source.fetchOlder).toHaveBeenCalledOnce(); diff --git a/apps/web/src/features/email-thread/primitives/email-thread-state.ts b/apps/web/src/features/email-thread/primitives/email-thread-state.ts index 23ca9044794..4336628fa30 100644 --- a/apps/web/src/features/email-thread/primitives/email-thread-state.ts +++ b/apps/web/src/features/email-thread/primitives/email-thread-state.ts @@ -28,18 +28,18 @@ export type EmailThreadState = { onRecipientsChange: (items: EmailRecipient[]) => void; drafts: { - getDraftForMessage: (messageDbID: string) => EmailMessage | undefined; - deleteDraftForMessage: (messageDbID: string) => void; + getDraftForMessage: (messageDbId: string) => EmailMessage | 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; + targetMessageId: Accessor; + setTargetMessageId: (id: string | undefined) => void; + focusedId: Accessor; + setFocused: (messageId: string | undefined) => void; hiddenChipFocused: Accessor; setHiddenChipFocused: (focused: boolean) => void; hovered: Accessor; @@ -150,8 +150,8 @@ export function createEmailThreadState( >(undefined); /** Selecting a message clears the hidden-chip stop explicitly (no createEffect). */ - const setFocused = (messageID: string | undefined) => { - if (messageID) { + const setFocused = (messageId: string | undefined) => { + if (messageId) { setHiddenChipFocused(false); const list = messagesListRef(); const button = list ? hiddenMessagesControl(list) : undefined; @@ -160,7 +160,7 @@ export function createEmailThreadState( host.focusContainer?.(); } } - setFocusedMessageId(messageID); + setFocusedMessageId(messageId); }; const isContainerFilled = () => { @@ -202,8 +202,8 @@ export function createEmailThreadState( }); }; - const onExpandMessageBody = (messageID: string, expanded: boolean) => { - setExpandedMessageBodyIds(messageID, expanded); + const onExpandMessageBody = (messageId: string, expanded: boolean) => { + setExpandedMessageBodyIds(messageId, expanded); }; return { @@ -224,14 +224,14 @@ export function createEmailThreadState( }, drafts, messages: { - focusedID: focusedMessageId, + focusedId: focusedMessageId, setFocused, hiddenChipFocused, setHiddenChipFocused, hovered: hoveredStop, setHovered: setHoveredStop, - targetMessageID: targetMessageId, - setTargetMessageID: setTargetMessageId, + targetMessageId: targetMessageId, + setTargetMessageId: setTargetMessageId, list: createMemo(() => selected()?.filtered ?? []), unfiltered: createMemo(() => selected()?.messages ?? []), // Google's CATEGORY_PERSONAL classification is inconsistent across diff --git a/apps/web/src/features/email-thread/primitives/thread-drafts.ts b/apps/web/src/features/email-thread/primitives/thread-drafts.ts index 0b52968922b..ddb82b35e9e 100644 --- a/apps/web/src/features/email-thread/primitives/thread-drafts.ts +++ b/apps/web/src/features/email-thread/primitives/thread-drafts.ts @@ -49,13 +49,13 @@ export function createThreadDrafts( Record >({}); - const deleteDraftForMessage = (messageID: string) => { - setDeletedDraftIds(messageID, true); + const deleteDraftForMessage = (messageId: string) => { + setDeletedDraftIds(messageId, true); }; - const getDraftForMessage = (messageID: string) => { - if (deletedDraftIds[messageID]) return undefined; - return serverDrafts()?.map[messageID]; + 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 diff --git a/apps/web/src/features/email-thread/primitives/thread-navigation.test.ts b/apps/web/src/features/email-thread/primitives/thread-navigation.test.ts index 732009bb768..71bb84419f4 100644 --- a/apps/web/src/features/email-thread/primitives/thread-navigation.test.ts +++ b/apps/web/src/features/email-thread/primitives/thread-navigation.test.ts @@ -161,7 +161,7 @@ it.each(['next', 'previous'] as const)( : handlers?.navigateToPreviousMessage(); expect(moved).toBe(true); expect(document.activeElement).toBe(state.container); - expect(state.context.messages.focusedID()).toBe('middle'); + expect(state.context.messages.focusedId()).toBe('middle'); expect(handlers?.activate()).toBe(true); expect(state.context.messages.expandedBodyIds.middle).toBe(true); expect(state.context.replyRequest.messageId()).toBeUndefined(); @@ -188,7 +188,7 @@ it('retains Enter activation of the focused hidden-message button', () => { state.context.messages.setHiddenChipFocused(true); expect(state.handlers()?.activate()).toBe(true); expect(state.navigation.showMiddleMessages()).toBe(true); - expect(state.context.messages.focusedID()).toBe('second'); + expect(state.context.messages.focusedId()).toBe('second'); expect(state.context.replyRequest.messageId()).toBeUndefined(); } finally { state.dispose(); diff --git a/apps/web/src/features/email-thread/primitives/thread-navigation.ts b/apps/web/src/features/email-thread/primitives/thread-navigation.ts index c463ea1ec6e..ee4ec2f4182 100644 --- a/apps/web/src/features/email-thread/primitives/thread-navigation.ts +++ b/apps/web/src/features/email-thread/primitives/thread-navigation.ts @@ -191,9 +191,9 @@ export function createThreadNavigation( context.messages.setFocused(messageId); } - if (context.messages.targetMessageID() === messageId) { + if (context.messages.targetMessageId() === messageId) { setTimeout(() => { - context.messages.setTargetMessageID(undefined); + context.messages.setTargetMessageId(undefined); }, TARGET_MESSAGE_HIGHLIGHT_MS); } @@ -206,7 +206,7 @@ export function createThreadNavigation( if (!canRunInitialEmailScroll()) return false; if (!untrack(context.messagesListRef)) return false; - const targetMessageId_ = context.messages.targetMessageID(); + const targetMessageId_ = context.messages.targetMessageId(); if (targetMessageId_ && typeof targetMessageId_ !== 'string') return true; if (typeof targetMessageId_ === 'string') { void revealTargetMessage(targetMessageId_); @@ -260,8 +260,8 @@ export function createThreadNavigation( const showMiddleMessages = createMemo(() => { if (userOpenedMiddle()) return true; const messages = context.messages.list(); - const focus = context.messages.focusedID(); - const target = context.messages.targetMessageID(); + 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; @@ -376,7 +376,7 @@ export function createThreadNavigation( if (anchor === 'title' || anchor === 'composer') { return { kind: anchor } as const; } - const focusedId = context.messages.focusedID(); + const focusedId = context.messages.focusedId(); if (!focusedId) return undefined; const index = messages.findIndex( (message) => message.db_id === focusedId @@ -415,7 +415,7 @@ export function createThreadNavigation( 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 selectedId = context.messages.focusedId(); const selectedIndex = selectedId ? messageIds.indexOf(selectedId) : -1; const cursor = hover ?? @@ -437,7 +437,7 @@ export function createThreadNavigation( const messages = context.messages.list(); if (messages.length === 0) return; - const focusedId = context.messages.focusedID(); + const focusedId = context.messages.focusedId(); const focusedMessage = focusedId ? messages.find((message) => message.db_id === focusedId) : undefined; @@ -526,7 +526,7 @@ export function createThreadNavigation( // button activate itself instead of opening a reply on the thread. if (document.activeElement instanceof HTMLButtonElement) return false; - const focusedId = context.messages.focusedID(); + const focusedId = context.messages.focusedId(); const target = getHotkeyTarget(); if (focusedId && target?.message.db_id === focusedId) { @@ -573,7 +573,7 @@ export function createThreadNavigation( return true; } - const focusedId = context.messages.focusedID(); + const focusedId = context.messages.focusedId(); if (!focusedId) { if (keyboardSelecting()) { clearSelection(); diff --git a/apps/web/src/features/email-thread/views/bottom-reply-buttons.tsx b/apps/web/src/features/email-thread/views/bottom-reply-buttons.tsx index f896e02b5bb..229442ee5f2 100644 --- a/apps/web/src/features/email-thread/views/bottom-reply-buttons.tsx +++ b/apps/web/src/features/email-thread/views/bottom-reply-buttons.tsx @@ -10,9 +10,9 @@ import CheckBoldIcon from '@phosphor-icons/core/bold/check-bold.svg?component-so import { createCallback } from '@solid-primitives/rootless'; import { Button, cn } from '@ui'; import { type Component, Show } from 'solid-js'; +import { useEmailThreadState } from '../context/email-thread-state-context'; +import { useEmailThreadEnvironment } from '../context/thread-environment'; import { openEmailReplyComposerForMessage } from '../primitives/reply-actions'; -import { useEmailContext } from './email-thread-context'; -import { useEmailThreadEnvironment } from './thread-environment'; function ReplyActionButton(props: { icon: Component<{ class?: string }>; @@ -45,7 +45,7 @@ function ReplyActionButton(props: { } export function BottomReplyButtons(props: { lastMessage: EmailMessage }) { - const ctx = useEmailContext(); + const ctx = useEmailThreadState(); const environment = useEmailThreadEnvironment(); const currentUserEmail = environment.dependencies.viewerEmail; diff --git a/apps/web/src/features/email-thread/views/email-participants.tsx b/apps/web/src/features/email-thread/views/email-participants.tsx index c0518c892c4..867d3cb8576 100644 --- a/apps/web/src/features/email-thread/views/email-participants.tsx +++ b/apps/web/src/features/email-thread/views/email-participants.tsx @@ -2,8 +2,8 @@ import { EmailUserTooltip } from '@app/features/email-message/components/email-u import { UserIcon, type UserIconProps } from '@core/component/UserIcon'; import { emailToMacroId } from '@core/user/macroId'; import { createMemo, createSignal, For, Show } from 'solid-js'; -import { useEmailContext } from './email-thread-context'; -import { useEmailThreadEnvironment } from './thread-environment'; +import { useEmailThreadState } from '../context/email-thread-state-context'; +import { useEmailThreadEnvironment } from '../context/thread-environment'; interface Participant { email: string; @@ -14,7 +14,7 @@ interface Participant { const DEFAULT_VISIBLE_COUNT = 5; export function EmailParticipants() { - const context = useEmailContext(); + const context = useEmailThreadState(); const currentUserEmail = useEmailThreadEnvironment().dependencies.viewerEmail; const [expanded, setExpanded] = createSignal(false); diff --git a/apps/web/src/features/email-thread/views/email-thread-context.tsx b/apps/web/src/features/email-thread/views/email-thread-context.tsx deleted file mode 100644 index 2771a054871..00000000000 --- a/apps/web/src/features/email-thread/views/email-thread-context.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { createContext, useContext } from 'solid-js'; -import type { EmailThreadState } from '../primitives/email-thread-state'; - -const EmailContext = createContext(); -export const EmailProvider = EmailContext.Provider; -export function useEmailContext() { - const ctx = useContext(EmailContext); - if (!ctx) - throw new Error('useEmailContext must be used within an EmailProvider'); - return ctx; -} diff --git a/apps/web/src/features/email-thread/views/email-thread-surface.test.tsx b/apps/web/src/features/email-thread/views/email-thread-surface.test.tsx index c2314cf1850..d8c30c5d459 100644 --- a/apps/web/src/features/email-thread/views/email-thread-surface.test.tsx +++ b/apps/web/src/features/email-thread/views/email-thread-surface.test.tsx @@ -1,14 +1,14 @@ import { render } from '@solidjs/testing-library'; import { createContext, useContext } from 'solid-js'; import { describe, expect, it, vi } from 'vitest'; -import { composeServices } from '../../email-compose/tests/services'; +import { composeEnvironment } from '../../email-compose/tests/capabilities'; import { useEmailRendering } from '../../email-message/context/email-rendering-context'; +import { useEmailThreadState } from '../context/email-thread-state-context'; import { dependencies, message, thread } from '../tests/fixtures'; -import { useEmailContext } from './email-thread-context'; import { EmailThreadSurface } from './email-thread-surface'; // Replace only the large UI subtree; exercise real providers, state, and host frame ordering. -vi.mock('./email', () => ({ +vi.mock('./email-thread', () => ({ EmailThreadView: (props: { header?: unknown }) => <>{props.header}, })); const HostContext = createContext(); @@ -24,7 +24,7 @@ describe('thread composition ownership', () => { async refresh() {}, }); const Probe = () => { - const state = useEmailContext(); + const state = useEmailThreadState(); useEmailRendering(); return (

@@ -38,7 +38,7 @@ describe('thread composition ownership', () => { threadId={() => 'thread'} environment={{ dependencies: deps, - compose: composeServices(), + compose: composeEnvironment(), rendering: {}, }} emailRendering={{ @@ -67,6 +67,6 @@ describe('thread composition ownership', () => { }); it('fails clearly when a required capability provider is omitted', () => { expect(() => useEmailRendering()).toThrow('EmailRenderingProvider'); - expect(() => useEmailContext()).toThrow(); + expect(() => useEmailThreadState()).toThrow(); }); }); diff --git a/apps/web/src/features/email-thread/views/email-thread-surface.tsx b/apps/web/src/features/email-thread/views/email-thread-surface.tsx index 5e54ac74a0a..d9c4f6115f5 100644 --- a/apps/web/src/features/email-thread/views/email-thread-surface.tsx +++ b/apps/web/src/features/email-thread/views/email-thread-surface.tsx @@ -3,13 +3,13 @@ import { type EmailRenderingDependencies, EmailRenderingProvider, } from '../../email-message/context/email-rendering-context'; -import { createEmailThreadState } from '../primitives/email-thread-state'; -import { EmailThreadView, type EmailThreadViewProps } from './email'; -import { EmailProvider } from './email-thread-context'; +import { EmailThreadStateProvider } from '../context/email-thread-state-context'; import { ThreadEnvironmentProvider, type ThreadViewEnvironment, -} from './thread-environment'; +} from '../context/thread-environment'; +import { createEmailThreadState } from '../primitives/email-thread-state'; +import { EmailThreadView, type EmailThreadViewProps } from './email-thread'; export interface EmailThreadSurfaceProps extends EmailThreadViewProps { environment: ThreadViewEnvironment; @@ -27,9 +27,9 @@ export function EmailThreadSurface(props: EmailThreadSurfaceProps) { return ( - + {props.frame ? props.frame(content) : content()} - + ); diff --git a/apps/web/src/features/email-thread/views/email.tsx b/apps/web/src/features/email-thread/views/email-thread.tsx similarity index 95% rename from apps/web/src/features/email-thread/views/email.tsx rename to apps/web/src/features/email-thread/views/email-thread.tsx index 139c10c94f4..cabda7a047b 100644 --- a/apps/web/src/features/email-thread/views/email.tsx +++ b/apps/web/src/features/email-thread/views/email-thread.tsx @@ -1,16 +1,16 @@ +import { EmailFormContextProvider } from '@app/features/email-compose/context/email-form-context'; import { EmailComposeView } from '@app/features/email-compose/views/email-compose'; -import { EmailFormContextProvider } from '@app/features/email-compose/views/email-form-context'; import { CustomScrollbar } from '@core/component/CustomScrollbar'; import type { JSX } from 'solid-js'; import { type Accessor, Match, Show, Switch } from 'solid-js'; import type { EmailThreadHost } from '../context/email-thread-dependencies'; +import { useEmailThreadState } from '../context/email-thread-state-context'; +import { useEmailThreadEnvironment } from '../context/thread-environment'; import { createThreadNavigation } from '../primitives/thread-navigation'; import { createThreadReplyArea } from '../primitives/thread-reply-area'; import { BottomReplyButtons } from './bottom-reply-buttons'; -import { useEmailContext } from './email-thread-context'; import { MessageList } from './message-list'; import { MobileEmailComposeDrawer } from './mobile-email-compose-drawer'; -import { useEmailThreadEnvironment } from './thread-environment'; export type EmailThreadViewProps = { title: string; threadId: Accessor; @@ -19,7 +19,7 @@ export type EmailThreadViewProps = { actions?: JSX.Element; }; export function EmailThreadView(props: EmailThreadViewProps) { - const context = useEmailContext(); + const context = useEmailThreadState(); const environment = useEmailThreadEnvironment(); const deps = environment.dependencies; const isTouchDevice = deps.isTouch; @@ -80,7 +80,7 @@ export function EmailThreadView(props: EmailThreadViewProps) { inboxes: environment.compose.accounts.inboxes, }} formOptions={{ - getMessageByID: (id) => + getMessageById: (id) => context.messages.unfiltered().find((m) => m.db_id === id), getDraftForMessageReply: context.drafts.getDraftForMessage, onRecipientsChange: context.onRecipientsChange, diff --git a/apps/web/src/features/email-thread/views/message-container.tsx b/apps/web/src/features/email-thread/views/message-container.tsx index a58b1625451..957fe65a9b7 100644 --- a/apps/web/src/features/email-thread/views/message-container.tsx +++ b/apps/web/src/features/email-thread/views/message-container.tsx @@ -3,15 +3,15 @@ import { FloatingInputLoader } from '@core/component/FloatingInputLoader'; import { createMemo, createSignal, Match, Show, Switch } from 'solid-js'; import { isPersonalMessage } from '../../email-message/core/is-personal-message'; import { EmailMessageView } from '../../email-message/views/email-message'; +import { useEmailThreadState } from '../context/email-thread-state-context'; +import { useEmailThreadEnvironment } from '../context/thread-environment'; import { openEmailReplyComposerForMessage } from '../primitives/reply-actions'; import { revealMessageAfterLayout, scrollFocusedCardIntoView, } from '../primitives/scroll-to-message'; import { BottomReplyButtons } from './bottom-reply-buttons'; -import { EmailInput } from './email-input'; -import { useEmailContext } from './email-thread-context'; -import { useEmailThreadEnvironment } from './thread-environment'; +import { ThreadReplyInput } from './thread-reply-input'; interface MessageContainerProps { message: EmailMessage; @@ -26,7 +26,7 @@ interface MessageContainerProps { export function MessageContainer(props: MessageContainerProps) { const { dependencies: deps, rendering } = useEmailThreadEnvironment(); const isTouchDevice = deps.isTouch; - const context = useEmailContext(); + const context = useEmailThreadState(); const draftChild = createMemo(() => { if (!props.message.db_id) return undefined; const draft = context.drafts.getDraftForMessage(props.message.db_id); @@ -148,7 +148,7 @@ export function MessageContainer(props: MessageContainerProps) {

- props.message} setShowReply={setShowReply} draft={draftChild()} diff --git a/apps/web/src/features/email-thread/views/message-list.tsx b/apps/web/src/features/email-thread/views/message-list.tsx index 981cfa31268..8018e341158 100644 --- a/apps/web/src/features/email-thread/views/message-list.tsx +++ b/apps/web/src/features/email-thread/views/message-list.tsx @@ -9,6 +9,8 @@ import { Show, } from 'solid-js'; import { EmailThreadTitle } from '../components/email-thread-title'; +import { useEmailThreadState } from '../context/email-thread-state-context'; +import { useEmailThreadEnvironment } from '../context/thread-environment'; import { fetchOlderMessages, isTruncatedMiddleMessage, @@ -18,9 +20,7 @@ import { truncatedMiddleCount, } from '../primitives/scroll-to-message'; import { EmailParticipants } from './email-participants'; -import { useEmailContext } from './email-thread-context'; import { MessageContainer } from './message-container'; -import { useEmailThreadEnvironment } from './thread-environment'; interface MessageListProps { initialLoadComplete: boolean; @@ -41,12 +41,12 @@ interface MessageListProps { export function MessageList(props: MessageListProps) { const getIsScrollingToMessage = () => context.isScrollingToMessage(); - const context = useEmailContext(); + const context = useEmailThreadState(); const environment = useEmailThreadEnvironment(); // One selected message at a time, whether it was reached by click, tab, or // arrow keys — they all write the same focused id. const isSelectedSelector = createSelector( - context.messages.focusedID, + context.messages.focusedId, (a, b) => !!a && !!b && a === b ); const hiddenCount = createMemo(() => @@ -180,20 +180,20 @@ export function MessageList(props: MessageListProps) { // draft remains visible even after newer messages arrive (matches // Gmail/Superhuman). Desktop only: mobile edits drafts in a drawer. const hasDraft = createMemo(() => { - const messageID = message().db_id; - if (!messageID || environment.dependencies.isTouch()) + const messageId = message().db_id; + if (!messageId || environment.dependencies.isTouch()) return false; - return !!context.drafts.getDraftForMessage(messageID); + return !!context.drafts.getDraftForMessage(messageId); }); const isExpanded = createMemo(() => { - const messageID = message().db_id; - if (!messageID) return false; + const messageId = message().db_id; + if (!messageId) return false; return threadMessageIsExpanded({ chronologicalIndex: chronologicalIndex(), listLength: context.messages.list().length, expansionOverride: - context.messages.expandedBodyIds[messageID], + context.messages.expandedBodyIds[messageId], isUnread: isUnreadMessage(message()), hasDraft: hasDraft(), }); diff --git a/apps/web/src/features/email-thread/views/mobile-email-compose-drawer.tsx b/apps/web/src/features/email-thread/views/mobile-email-compose-drawer.tsx index d63e39c4af8..5ea2e81c72c 100644 --- a/apps/web/src/features/email-thread/views/mobile-email-compose-drawer.tsx +++ b/apps/web/src/features/email-thread/views/mobile-email-compose-drawer.tsx @@ -4,13 +4,13 @@ import { } from '@components/app/mobile/MobileDrawer'; import { FloatingInputLoader } from '@core/component/FloatingInputLoader'; import { createMemo, Show } from 'solid-js'; -import { EmailInput } from './email-input'; -import { useEmailContext } from './email-thread-context'; +import { useEmailThreadState } from '../context/email-thread-state-context'; +import { ThreadReplyInput } from './thread-reply-input'; export function MobileEmailComposeDrawer(props: { markdownDomRef?: (ref: HTMLDivElement) => void | HTMLDivElement; }) { - const context = useEmailContext(); + const context = useEmailThreadState(); const replyInfo = createMemo(() => { const messageId = context.mobileReplyComposer.messageId(); @@ -67,7 +67,7 @@ export function MobileEmailComposeDrawer(props: { class="overflow-y-auto scrollbar-hidden h-full pb-1" onFocusIn={(e) => scrollToFocusedInput(e)} > - info().replyingTo} draft={info().draft} setShowReply={(value) => { diff --git a/apps/web/src/features/email-thread/views/email-input.test.tsx b/apps/web/src/features/email-thread/views/thread-reply-input.test.tsx similarity index 84% rename from apps/web/src/features/email-thread/views/email-input.test.tsx rename to apps/web/src/features/email-thread/views/thread-reply-input.test.tsx index 823426d92e2..b538e6406e7 100644 --- a/apps/web/src/features/email-thread/views/email-input.test.tsx +++ b/apps/web/src/features/email-thread/views/thread-reply-input.test.tsx @@ -2,12 +2,12 @@ import { render } from '@solidjs/testing-library'; import { createSignal, onCleanup } from 'solid-js'; import { beforeEach, expect, it, vi } from 'vitest'; import type { EmailReplySession } from '../../email-compose/context/email-form-dependencies'; -import { composeServices } from '../../email-compose/tests/services'; +import { composeEnvironment } from '../../email-compose/tests/capabilities'; +import { EmailThreadStateProvider } from '../context/email-thread-state-context'; +import { ThreadEnvironmentProvider } from '../context/thread-environment'; import { createEmailThreadState } from '../primitives/email-thread-state'; import { dependencies, message, thread } from '../tests/fixtures'; -import { EmailInput } from './email-input'; -import { EmailProvider } from './email-thread-context'; -import { ThreadEnvironmentProvider } from './thread-environment'; +import { ThreadReplyInput } from './thread-reply-input'; vi.mock('@ui', () => ({ Layer: (props: { children: unknown }) => props.children, @@ -60,13 +60,13 @@ it('preserves an engaged composer through a same-message update but resets it fo - - - + + + ); }); @@ -100,18 +100,18 @@ it('returns focus to the owning thread when split panes contain the same message - +
- parent} /> + parent} />
- + ); }; diff --git a/apps/web/src/features/email-thread/views/email-input.tsx b/apps/web/src/features/email-thread/views/thread-reply-input.tsx similarity index 92% rename from apps/web/src/features/email-thread/views/email-input.tsx rename to apps/web/src/features/email-thread/views/thread-reply-input.tsx index 5c6b852ef97..e0e8e12c46a 100644 --- a/apps/web/src/features/email-thread/views/email-input.tsx +++ b/apps/web/src/features/email-thread/views/thread-reply-input.tsx @@ -11,11 +11,11 @@ import { Show, } from 'solid-js'; import { isPersonalMessage } from '../../email-message/core/is-personal-message'; +import { useEmailThreadState } from '../context/email-thread-state-context'; +import { useEmailThreadEnvironment } from '../context/thread-environment'; import { revealMessageAfterLayout } from '../primitives/scroll-to-message'; -import { useEmailContext } from './email-thread-context'; -import { useEmailThreadEnvironment } from './thread-environment'; -interface EmailInputProps { +interface ThreadReplyInputProps { replyingTo: Accessor; draft?: EmailMessage; setShowReply?: Setter; @@ -27,16 +27,16 @@ interface EmailInputProps { } /** A reply target owns one editor/form lifetime; changing targets must reset the draft latch. */ -export function EmailInput(props: EmailInputProps) { +export function ThreadReplyInput(props: ThreadReplyInputProps) { return ( - {(_identity) => } + {(_identity) => } ); } -function EmailInputSession(props: EmailInputProps) { - const ctx = useEmailContext(); +function ThreadReplyInputSession(props: ThreadReplyInputProps) { + const ctx = useEmailThreadState(); const environment = useEmailThreadEnvironment(); // The seed identity of this composer: which version of which draft it @@ -120,7 +120,7 @@ function EmailInputSession(props: EmailInputProps) { const id = target === 'last' ? ctx.messages.list().at(-1)?.db_id - : ctx.messages.focusedID(); + : ctx.messages.focusedId(); if (!id) return false; ctx.messages.setFocused(id); const message = ctx 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 7659d7fbafb..8c1dcc73dd2 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 @@ -1,19 +1,17 @@ import { SignaturePreview } from '@app/features/email-compose/components/signature-preview'; -import { - ComposeLayout, - EmailComposeToolbar, -} from '@app/features/email-compose/compose-layout'; +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 { - type ComposeContextValue, - ComposeProvider, - type ComposeValidationError, -} from '@app/features/email-compose/views/compose-context'; +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 { useChatContext } from '@core/component/AI/context'; import type { AssistantMessagePart } from '@core/component/AI/types'; diff --git a/docs/AGENT_GUIDE/surfaces.md b/docs/AGENT_GUIDE/surfaces.md index 049c4d85ff6..eb5a4b20896 100644 --- a/docs/AGENT_GUIDE/surfaces.md +++ b/docs/AGENT_GUIDE/surfaces.md @@ -39,6 +39,14 @@ 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. A failed schedule or unschedule keeps the last confirmed time. If scheduling succeeds but marking the thread done fails, the email remains diff --git a/docs/EMAIL_FEATURE_ARCHITECTURE.md b/docs/EMAIL_FEATURE_ARCHITECTURE.md index 2ae0bad538a..3c650469985 100644 --- a/docs/EMAIL_FEATURE_ARCHITECTURE.md +++ b/docs/EMAIL_FEATURE_ARCHITECTURE.md @@ -52,22 +52,24 @@ may import a block package, `@core/block`, or block-related signal modules. resolved presentation values and event callbacks. A thread supplies selection, expansion, reply actions, and a footer through those inputs. - `email-thread/email-thread.tsx` constructs the existing shared thread query, - source adapter, viewer/contact capabilities, thread commands, composer services, + 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 dependencies. It imports no production entry point for a nested message or composer. -- `email-compose/email-compose.tsx` supplies real compose services and split-host +- `email-compose/email-compose.tsx` supplies the production compose environment and split-host callbacks to `views/email-compose.tsx`. Inline replies use - `views/reply-input.tsx` with explicitly supplied services and a reply session. + `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 live in `context/`. Prepared view-state types live in -`primitives/`, and contexts holding a mounted screen's state live in `views/`. +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. @@ -91,15 +93,32 @@ test exercises this ordering. | `EmailAttachmentStorage` | Upload, forward, and remove draft attachments | | `EmailDelivery` | Send, undo, schedule, unschedule, and archive operations | | `EmailComposeFeedback` | User notices and error reporting | -| `EmailComposeServices` | Composition of those contracts with account, editor, and presentation capabilities for the complete surface | +| `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 | +| `EmailComposeEnvironment` | Production composition groups; consumed by views, never by a reusable controller | +| `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` decodes -wire values and guards Solid resource reads. `email-compose/queries/inbox-source.ts` +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 `EmailComposeEnvironment`, `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 @@ -162,6 +181,8 @@ quote is idempotent across editor remounts. DOM listeners use the installed ```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] @@ -172,6 +193,7 @@ flowchart TD 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 @@ -202,6 +224,61 @@ single source of thread dependencies. 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. 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 diff --git a/docs/FRONTEND_FEATURE_ARCHITECTURE.md b/docs/FRONTEND_FEATURE_ARCHITECTURE.md index 57b3d16c74e..854ff145477 100644 --- a/docs/FRONTEND_FEATURE_ARCHITECTURE.md +++ b/docs/FRONTEND_FEATURE_ARCHITECTURE.md @@ -158,7 +158,9 @@ The current [feed-query.ts](../apps/web/src/features/activity/queries/feed-query 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. +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). @@ -171,7 +173,12 @@ 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. Queries and primitives must not import rendering code. +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 @@ -265,8 +272,10 @@ that wiring, and the component when they already have resolved values. ### `context/`: the feature's capability contract -Define one feature context record containing the ambient capabilities the feature -uses consistently across surfaces. Keep the contracts and provider/consumer free +Define the ambient capabilities each consumer needs. A production entry point may +group them into an environment for views to wire, but reusable controllers receive +only their named contracts. Do not pass a complete screen environment 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. @@ -468,7 +477,7 @@ current-user hook, or display resolvers. | Layer | Test shape | Useful assertions | | --- | --- | --- | | Core | Ordinary Vitest tests with domain inputs | Grouping, descriptions, calendar boundaries, unknown cases | -| Decode/select | Wire fixtures passed to pure adapters | Domain output, missing versus empty, unsupported variants, malformed property payloads | +| 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 | diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md index 71ac2652f41..b5b40cade4c 100644 --- a/docs/STYLE_GUIDE.md +++ b/docs/STYLE_GUIDE.md @@ -269,7 +269,12 @@ TypeScript · `[ui]` UI / UX conventions context display types only. Keep data availability in source adapters and presentation decisions in primitives; avoid interfaces with no independent behavior to protect. - Screen primitives expose view-state unions; small helpers can return accessors. + 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 diff --git a/rules/ast-grep/ts-feature-data-no-ui.yml b/rules/ast-grep/ts-feature-data-no-ui.yml index 67347834bd7..68f8e2f3b60 100644 --- a/rules/ast-grep/ts-feature-data-no-ui.yml +++ b/rules/ast-grep/ts-feature-data-no-ui.yml @@ -5,11 +5,11 @@ 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/** diff --git a/rules/ast-grep/tsx-feature-data-no-ui.yml b/rules/ast-grep/tsx-feature-data-no-ui.yml index 04549c86829..1d981058ec2 100644 --- a/rules/ast-grep/tsx-feature-data-no-ui.yml +++ b/rules/ast-grep/tsx-feature-data-no-ui.yml @@ -5,11 +5,11 @@ 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/** From 86e8ef7a29e8d38d3ab18aadf9cdbed261c484d9 Mon Sep 17 00:00:00 2001 From: synoet Date: Tue, 8 Sep 2026 23:54:49 +0000 Subject: [PATCH 17/21] fix(nix): use native Darwin hash for merged dependencies Use the hash reported by the macOS CI build of the merged lockfile. Linux verification already passes with its current hash. --- nix-support/node_modules-hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix-support/node_modules-hashes.json b/nix-support/node_modules-hashes.json index ed3206c4302..d87e2abdba9 100644 --- a/nix-support/node_modules-hashes.json +++ b/nix-support/node_modules-hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { - "aarch64-darwin": "sha256-gUObjBBCrU/BB7+Rl4dDYlpo7mAdF9AASMlKAYAv/d4=", + "aarch64-darwin": "sha256-7ZvwLUFReCp0oAJEtXXYQn7EGnEFsmb3MGJNmKxOKtY=", "x86_64-linux": "sha256-N4Tnz5s9gMuoIig8nNQ1h1sjlitPR2+OX4xNjzgpWZU=" } } From 5fdee4603d0cc7f761c2ef2e3892caffe0525153 Mon Sep 17 00:00:00 2001 From: synoet Date: Wed, 9 Sep 2026 02:34:42 +0000 Subject: [PATCH 18/21] cleanup --- .../features/email-compose/compose-adapter.ts | 4 +- .../context/compose-capabilities.ts | 2 +- .../context/email-form-context.tsx | 12 +- ...m-dependencies.ts => email-form-inputs.ts} | 2 +- .../core/plain-text-to-html.test.ts | 105 ++--- .../features/email-compose/email-compose.tsx | 8 +- .../appended-reply-round-trip.test.ts | 97 ++--- .../primitives/attachment-persistence.test.ts | 6 +- .../primitives/compose-persistence.test.ts | 263 ++++++------ .../primitives/email-composer.test.ts | 244 ++++------- .../primitives/email-form-state.ts | 8 +- .../primitives/prepare-email-body.test.ts | 24 +- .../primitives/reply-composer.ts | 2 +- .../primitives/send-schedule.test.ts | 386 +++++++----------- .../email-compose/tests/capabilities.ts | 10 +- .../features/email-compose/tests/composer.ts | 33 ++ .../features/email-compose/tests/editor.ts | 30 ++ .../email-compose/views/email-compose.tsx | 55 +-- .../email-compose/views/reply-input.tsx | 61 +-- .../context/email-rendering-context.tsx | 6 +- .../features/email-message/email-message.tsx | 6 +- .../email-message/image-adapter.test.ts | 5 +- .../primitives/email-message-body.test.ts | 24 +- .../primitives/email-message-body.ts | 12 +- .../email-message/rendering-adapter.ts | 4 +- .../views/email-message-body.tsx | 4 +- ...ependencies.ts => email-thread-context.ts} | 2 +- .../context/email-thread-view-context.tsx | 30 ++ .../context/thread-environment.tsx | 30 -- .../email-thread/core/thread-stops.test.ts | 9 - .../features/email-thread/email-thread.tsx | 20 +- .../primitives/email-thread-state.test.ts | 24 +- .../primitives/email-thread-state.ts | 29 +- .../primitives/scroll-to-message.test.ts | 276 +++++-------- .../primitives/thread-navigation.test.ts | 71 +--- .../primitives/thread-navigation.ts | 12 +- .../primitives/thread-snapshot.ts | 2 +- .../queries/thread-source.test.ts | 18 +- .../email-thread/queries/thread-source.ts | 2 +- .../features/email-thread/tests/fixtures.ts | 17 +- .../email-thread/thread-action-adapter.tsx | 2 +- .../thread-completion-adapter.tsx | 2 +- .../email-thread/thread-read-adapter.ts | 2 +- .../views/bottom-reply-buttons.tsx | 14 +- .../email-thread/views/email-participants.tsx | 4 +- .../views/email-thread-surface.test.tsx | 26 +- .../views/email-thread-surface.tsx | 23 +- .../email-thread/views/email-thread.tsx | 31 +- .../email-thread/views/message-container.tsx | 14 +- .../email-thread/views/message-list.tsx | 19 +- .../views/thread-reply-input.test.tsx | 107 +++-- .../email-thread/views/thread-reply-input.tsx | 8 +- .../src/lib/queries/email/attachment.test.tsx | 51 +-- apps/web/src/lib/queries/email/link.test.tsx | 41 +- .../src/lib/queries/email/tests/mutation.tsx | 30 ++ .../queries/email/write-completion.test.tsx | 43 +- docs/EMAIL_FEATURE_ARCHITECTURE.md | 19 +- docs/FRONTEND_FEATURE_ARCHITECTURE.md | 37 +- .../email-renderer/src/core/colors.test.ts | 93 ++--- packages/email-renderer/src/core/html.test.ts | 4 +- packages/email-renderer/tests/rendering.pw.ts | 172 +++----- packages/email-renderer/tests/snapshots.pw.ts | 32 ++ 62 files changed, 1130 insertions(+), 1599 deletions(-) rename apps/web/src/features/email-compose/context/{email-form-dependencies.ts => email-form-inputs.ts} (95%) create mode 100644 apps/web/src/features/email-compose/tests/composer.ts create mode 100644 apps/web/src/features/email-compose/tests/editor.ts rename apps/web/src/features/email-thread/context/{email-thread-dependencies.ts => email-thread-context.ts} (98%) create mode 100644 apps/web/src/features/email-thread/context/email-thread-view-context.tsx delete mode 100644 apps/web/src/features/email-thread/context/thread-environment.tsx create mode 100644 apps/web/src/lib/queries/email/tests/mutation.tsx create mode 100644 packages/email-renderer/tests/snapshots.pw.ts diff --git a/apps/web/src/features/email-compose/compose-adapter.ts b/apps/web/src/features/email-compose/compose-adapter.ts index a323a9e57dd..6de65f443cc 100644 --- a/apps/web/src/features/email-compose/compose-adapter.ts +++ b/apps/web/src/features/email-compose/compose-adapter.ts @@ -54,7 +54,7 @@ import type { ApiThread } from '@service-email/generated/schemas'; import type { InfiniteData } from '@tanstack/solid-query'; import type { ComposeNoticeOptions, - EmailComposeEnvironment, + EmailComposeContext, } from './context/compose-capabilities'; import { readDroppedEmailFiles } from './editor-adapter'; import { makeAttachmentPublic } from './make-attachment-public'; @@ -62,7 +62,7 @@ import { createEmailInboxSource } from './queries/inbox-source'; import { restoreDraftBodyAfterUndo, runUndoSend } from './undo-send'; /** Construct under the composing surface's Solid owner to scope request progress. */ -export function createEmailComposeEnvironment(): EmailComposeEnvironment { +export function createEmailComposeContext(): EmailComposeContext { const accounts = useEmailLinksQuery(); const headerId = useNonPrimaryEmailLinkIdHeader(); const user = useUserContext(); diff --git a/apps/web/src/features/email-compose/context/compose-capabilities.ts b/apps/web/src/features/email-compose/context/compose-capabilities.ts index 2e4886cdfec..c7dea922129 100644 --- a/apps/web/src/features/email-compose/context/compose-capabilities.ts +++ b/apps/web/src/features/email-compose/context/compose-capabilities.ts @@ -139,7 +139,7 @@ export interface EmailEditorFiles { } /** Production composition groups capabilities for views to wire into their consumers. */ -export interface EmailComposeEnvironment { +export interface EmailComposeContext { drafts: EmailDraftStorage; attachmentStorage: EmailAttachmentStorage; delivery: EmailDelivery; diff --git a/apps/web/src/features/email-compose/context/email-form-context.tsx b/apps/web/src/features/email-compose/context/email-form-context.tsx index 99fd392eb2a..84a031008d4 100644 --- a/apps/web/src/features/email-compose/context/email-form-context.tsx +++ b/apps/web/src/features/email-compose/context/email-form-context.tsx @@ -7,7 +7,7 @@ import type { EmailFormContextValue, FormAccessKey, } from '../primitives/email-form-types'; -import type { EmailFormDependencies } from './email-form-dependencies'; +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 @@ -24,7 +24,7 @@ const EmailFormRegistryCtx = createContext(); export function EmailFormContextProvider( props: ParentProps<{ - dependencies: EmailFormDependencies; + context: EmailFormContextInputs; formOptions: EmailFormStateOptions; }> ) { @@ -32,16 +32,12 @@ export function EmailFormContextProvider( const getOrInit: RegistryApi['getOrInit'] = (key) => { if (!key) { - return createEmailFormState(props.dependencies); + return createEmailFormState(props.context); } const stringifiedKey = stringifyKey(key); let existing = map.get(stringifiedKey); if (!existing) { - existing = createEmailFormState( - props.dependencies, - key, - props.formOptions - ); + existing = createEmailFormState(props.context, key, props.formOptions); map.set(stringifiedKey, existing); } return existing; diff --git a/apps/web/src/features/email-compose/context/email-form-dependencies.ts b/apps/web/src/features/email-compose/context/email-form-inputs.ts similarity index 95% rename from apps/web/src/features/email-compose/context/email-form-dependencies.ts rename to apps/web/src/features/email-compose/context/email-form-inputs.ts index f3054708253..bcad84d245c 100644 --- a/apps/web/src/features/email-compose/context/email-form-dependencies.ts +++ b/apps/web/src/features/email-compose/context/email-form-inputs.ts @@ -2,7 +2,7 @@ import type { Accessor } from 'solid-js'; import type { EmailRecipient } from '../core/email-recipient'; import type { ReplyType } from '../core/reply-type'; -export interface EmailFormDependencies { +export interface EmailFormContextInputs { viewerEmail: Accessor; inboxes: Accessor<{ id: string; email_address: string }[]>; } 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 index f809f2286fa..1c33232fc81 100644 --- 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 @@ -1,77 +1,40 @@ import { describe, expect, it } from 'vitest'; import { plainTextToHtml } from './plain-text-to-html'; -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')}
` +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/email-compose/email-compose.tsx b/apps/web/src/features/email-compose/email-compose.tsx index bc338279bef..da37f232072 100644 --- a/apps/web/src/features/email-compose/email-compose.tsx +++ b/apps/web/src/features/email-compose/email-compose.tsx @@ -1,11 +1,11 @@ import type { ComponentProps } from 'solid-js'; -import { createEmailComposeEnvironment } from './compose-adapter'; +import { createEmailComposeContext } from './compose-adapter'; import { createEmailComposeHost } from './compose-host-adapter'; import { EmailComposeView } from './views/email-compose'; export function EmailCompose( - props: Omit, 'services' | 'host'> + props: Omit, 'context' | 'host'> ) { - const services = createEmailComposeEnvironment(); + const composeContext = createEmailComposeContext(); const host = createEmailComposeHost(); - return ; + return ; } 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 index e0c90a01f8e..dc528e2aadc 100644 --- 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 @@ -1,72 +1,44 @@ // @vitest-environment jsdom -import type { EmailMessage } from '@app/features/email-message/core/email-message'; + import { $generateNodesFromDOM } from '@lexical/html'; -import { - $isClassedBlockNode, - SupportedNodeTypes, -} from '@macro-inc/lexical-core'; -import { - $createParagraphNode, - $createTextNode, - $getRoot, - createEditor, - type LexicalNode, -} from 'lexical'; +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'; -function decodeBodyHtml(encoded: string) { - const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); - return decodeURIComponent(escape(atob(base64))); -} - -const replyingTo = { +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: [], -} as unknown as EmailMessage; +}); function makeEditor() { - const editor = createEditor({ - nodes: SupportedNodeTypes, - onError: (e) => { - throw e; - }, - }); + const editor = createEmailEditor('my reply'); 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 $collectMacroQuotes() { + return $nodesOfType(ClassedBlockNode).filter((node) => + node.exportJSON().classes.includes('macro_quote') + ); } 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', @@ -83,39 +55,15 @@ function hideQuote(editor: ReturnType) { } 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 and showing a saved quote again keeps one removable quote', () => { + 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 = decodeBodyHtml(prepared!.bodyHtml); + const html = decodeBase64Utf8(prepared!.bodyHtml); // Reload path: setEditorStateFromHtml -> $generateNodesFromDOM. const editorB = makeEditor(); @@ -127,6 +75,10 @@ describe('appended reply draft round trip', () => { 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', @@ -138,6 +90,7 @@ describe('appended reply draft round trip', () => { 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 index c714669b1f6..fd139c7ce7c 100644 --- a/apps/web/src/features/email-compose/primitives/attachment-persistence.test.ts +++ b/apps/web/src/features/email-compose/primitives/attachment-persistence.test.ts @@ -33,10 +33,8 @@ function setup( describe('draft attachment persistence', () => { it('waits for earlier content uploads even after their attachment IDs are assigned', async () => { - let finish!: () => void; - const uploadFinished = new Promise((resolve) => { - finish = resolve; - }); + const { promise: uploadFinished, resolve: finish } = + Promise.withResolvers(); const file = new File(['content'], 'notes.txt'); const state = setup(async (input) => { input.onAttachmentAdded?.(file, 'attachment'); diff --git a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts index 63f35386f55..611698ba880 100644 --- a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts +++ b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts @@ -1,61 +1,9 @@ -import { SupportedNodeTypes } from '@macro-inc/lexical-core'; -import { - $createParagraphNode, - $createTextNode, - $getRoot, - createEditor, -} from 'lexical'; -import { createRoot } from 'solid-js'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -import type { - EmailComposeEnvironment, - EmailComposeHost, - PersistedEmailIdentity, -} from '../context/compose-capabilities'; +import type { PersistedEmailIdentity } from '../context/compose-capabilities'; import { decodeBase64Utf8 } from '../core/decode-base64'; -import { composeEnvironment } from '../tests/capabilities'; -import { createEmailComposer } from './email-composer'; +import { createComposeContext } from '../tests/capabilities'; +import { mountEmailComposer } from '../tests/composer'; -function mount(services: EmailComposeEnvironment, host?: EmailComposeHost) { - const root = createRoot((dispose) => ({ - dispose, - state: createEmailComposer({ - ...services, - host, - initialTo: ['maya@example.com'], - }), - })); - const editor = createEditor({ - nodes: SupportedNodeTypes, - onError: (error) => { - throw error; - }, - }); - root.state.context.captureEditor(editor); - root.state.context.onContentChange(''); - return { - ...root, - edit(text: string) { - editor.update( - () => { - $getRoot() - .clear() - .append($createParagraphNode().append($createTextNode(text))); - }, - { discrete: true } - ); - root.state.context.setSubject('Launch review'); - root.state.context.onContentChange(text); - }, - }; -} -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} const response: PersistedEmailIdentity = { draftId: 'saved-id', threadId: 'thread', @@ -65,65 +13,69 @@ beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it('flushes the latest pending body and envelope exactly once on disposal', async () => { - const services = composeEnvironment(); - const root = mount(services); - root.edit('Last-second edit'); + const composeContext = createComposeContext(); + const root = mountEmailComposer(composeContext); + root.edit('Last-second edit', 'Launch review'); root.dispose(); await vi.advanceTimersByTimeAsync(1000); - expect(services.drafts.saveDraft).toHaveBeenCalledOnce(); - const { draft } = vi.mocked(services.drafts.saveDraft).mock.calls[0][0]; + 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: 'maya@example.com' }), + expect.objectContaining({ email: 'colleague@example.com' }), ]); }); it('does not save an untouched composer or repeat a settled autosave on disposal', async () => { - const services = composeEnvironment(); - const untouched = mount(services); + const composeContext = createComposeContext(); + const untouched = mountEmailComposer(composeContext); untouched.dispose(); await vi.advanceTimersByTimeAsync(1000); - expect(services.drafts.saveDraft).not.toHaveBeenCalled(); - const edited = mount(services); + expect(composeContext.drafts.saveDraft).not.toHaveBeenCalled(); + const edited = mountEmailComposer(composeContext); edited.edit('Saved'); await vi.advanceTimersByTimeAsync(600); edited.dispose(); await vi.advanceTimersByTimeAsync(1000); - expect(services.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); }); it('serializes a disposal flush behind the first save and reuses its returned ID', async () => { - const pending = deferred(); - const services = composeEnvironment(); - vi.mocked(services.drafts.saveDraft).mockReturnValueOnce(pending.promise); - const root = mount(services); + 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(services.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); pending.resolve(response); await vi.advanceTimersByTimeAsync(1); - expect(services.drafts.saveDraft).toHaveBeenCalledTimes(2); - const { draft } = vi.mocked(services.drafts.saveDraft).mock.calls[1][0]; + 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 services = composeEnvironment(); - const root = mount(services); + const composeContext = createComposeContext(); + const root = mountEmailComposer(composeContext); root.edit('Discard me'); await root.state.deleteDraftAndReset(); root.dispose(); await vi.advanceTimersByTimeAsync(1000); - expect(services.drafts.saveDraft).not.toHaveBeenCalled(); - expect(services.drafts.deleteDraft).not.toHaveBeenCalled(); + 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 = deferred(); - const services = composeEnvironment(); - vi.mocked(services.drafts.saveDraft).mockReturnValueOnce(pending.promise); - const root = mount(services); + 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'); @@ -132,53 +84,56 @@ it('discard waits for an in-flight first save and deletes its returned draft', a pending.resolve(response); await discard; await vi.advanceTimersByTimeAsync(1000); - expect(services.drafts.saveDraft).toHaveBeenCalledOnce(); - expect(services.drafts.deleteDraft).toHaveBeenCalledWith( + 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 services = composeEnvironment(); - const root = mount(services); + const composeContext = createComposeContext(); + const root = mountEmailComposer(composeContext); root.edit('Saved'); await vi.advanceTimersByTimeAsync(600); - vi.mocked(services.drafts.deleteDraft).mockRejectedValueOnce( + 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(services.drafts.saveDraft).toHaveBeenCalledTimes(2); + 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 = deferred(); - const services = composeEnvironment(); - vi.mocked(services.drafts.saveDraft).mockReturnValueOnce(pending.promise); - const root = mount(services); + 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(services.drafts.saveDraft).toHaveBeenCalledOnce(); - expect(services.delivery.sendMessage).not.toHaveBeenCalled(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.delivery.sendMessage).not.toHaveBeenCalled(); pending.resolve(response); await vi.advanceTimersByTimeAsync(1); - expect(services.delivery.sendMessage).toHaveBeenCalledOnce(); + expect(composeContext.delivery.sendMessage).toHaveBeenCalledOnce(); expect( - vi.mocked(services.delivery.sendMessage).mock.calls[0][0].message.db_id + vi.mocked(composeContext.delivery.sendMessage).mock.calls[0][0].message + .db_id ).toBe('saved-id'); root.dispose(); await vi.advanceTimersByTimeAsync(1000); - expect(services.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); }); it('resumes autosave after a failed send', async () => { - const services = composeEnvironment(); - vi.mocked(services.delivery.sendMessage).mockRejectedValueOnce( + const composeContext = createComposeContext(); + vi.mocked(composeContext.delivery.sendMessage).mockRejectedValueOnce( new Error('offline') ); - const root = mount(services); + const root = mountEmailComposer(composeContext); root.edit('Send this'); root.state.context.onSend(); await vi.advanceTimersByTimeAsync(1); @@ -186,45 +141,49 @@ it('resumes autosave after a failed send', async () => { root.edit('Retry with this'); root.dispose(); await vi.advanceTimersByTimeAsync(1000); - expect(services.drafts.saveDraft).toHaveBeenCalledTimes(2); - expect(services.notices.feedback.failure).toHaveBeenCalledExactlyOnceWith( - 'Failed to send email' - ); + 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 services = composeEnvironment(); + const composeContext = createComposeContext(); const error = new Error('Navigation failed'); - const root = mount(services, { - showThread: () => { - throw error; + const root = mountEmailComposer(composeContext, { + host: { + showThread: () => { + throw error; + }, }, }); root.edit('Send this'); root.state.context.onSend(); await vi.advanceTimersByTimeAsync(1); - expect(services.notices.reportError).toHaveBeenCalledWith(error); - expect(services.notices.feedback.failure).not.toHaveBeenCalled(); + expect(composeContext.notices.reportError).toHaveBeenCalledWith(error); + expect(composeContext.notices.feedback.failure).not.toHaveBeenCalled(); root.state.context.onSend(); root.dispose(); await vi.advanceTimersByTimeAsync(1000); - expect(services.delivery.sendMessage).toHaveBeenCalledOnce(); - expect(services.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.delivery.sendMessage).toHaveBeenCalledOnce(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); }); it('keeps completion independent when two composers share delivery capabilities', async () => { - const services = composeEnvironment(); - const pending = deferred(); - vi.mocked(services.delivery.sendMessage).mockReturnValueOnce(pending.promise); - const first = mount(services); - const second = mount(services); + 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(services.delivery.sendMessage).toHaveBeenCalledTimes(2); + expect(composeContext.delivery.sendMessage).toHaveBeenCalledTimes(2); expect(first.state.context.isSending()).toBe(true); expect(second.state.context.isSending()).toBe(false); pending.resolve(response); @@ -234,12 +193,12 @@ it('keeps completion independent when two composers share delivery capabilities' second.dispose(); }); it('waits for an existing attachment upload before flushing newer body edits', async () => { - const pending = deferred(); - const services = composeEnvironment(); - vi.mocked(services.attachmentStorage.uploadAttachments).mockReturnValueOnce( - pending.promise - ); - const root = mount(services); + 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([ { @@ -250,46 +209,50 @@ it('waits for an existing attachment upload before flushing newer body edits', a await vi.advanceTimersByTimeAsync(600); root.edit('Final attachment note'); root.dispose(); - expect(services.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); pending.resolve(); await vi.advanceTimersByTimeAsync(1); - expect(services.drafts.saveDraft).toHaveBeenCalledTimes(2); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(2); expect( - vi.mocked(services.drafts.saveDraft).mock.calls[1][0].draft.db_id + 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 = deferred(); - const services = composeEnvironment(); - const root = mount(services); + const pending = Promise.withResolvers(); + const composeContext = createComposeContext(); + const root = mountEmailComposer(composeContext); root.edit('Saved'); await vi.advanceTimersByTimeAsync(600); - vi.mocked(services.drafts.deleteDraft).mockReturnValueOnce(pending.promise); + 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(services.drafts.saveDraft).toHaveBeenCalledOnce(); - expect(services.delivery.schedule).not.toHaveBeenCalled(); + 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 = deferred(); - const services = composeEnvironment(); - const root = mount(services); - vi.mocked(services.delivery.sendMessage).mockReturnValueOnce(pending.promise); + 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(services.drafts.saveDraft).toHaveBeenCalledOnce(); - expect(services.delivery.schedule).not.toHaveBeenCalled(); + 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); @@ -297,17 +260,19 @@ it('rejects sender and scheduling changes after send dispatch', async () => { }); it('uses the captured inbox for attachment upload when a sender switch queues behind a save', async () => { - const pending = deferred(); - const services = composeEnvironment(); - services.accounts = { - ...services.accounts, + 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(services.drafts.saveDraft).mockReturnValueOnce(pending.promise); - const root = mount(services); + 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') }, @@ -317,11 +282,11 @@ it('uses the captured inbox for attachment upload when a sender switch queues be pending.resolve(response); await vi.advanceTimersByTimeAsync(1); expect( - vi.mocked(services.attachmentStorage.uploadAttachments).mock.calls[0][0] - .inboxId + vi.mocked(composeContext.attachmentStorage.uploadAttachments).mock + .calls[0][0].inboxId ).toBe('inbox'); - expect(vi.mocked(services.drafts.saveDraft).mock.calls[1][0].inboxId).toBe( - 'other' - ); + expect( + vi.mocked(composeContext.drafts.saveDraft).mock.calls[1][0].inboxId + ).toBe('other'); root.dispose(); }); diff --git a/apps/web/src/features/email-compose/primitives/email-composer.test.ts b/apps/web/src/features/email-compose/primitives/email-composer.test.ts index fc32da6b74c..9bd191c8f25 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.test.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.test.ts @@ -1,58 +1,26 @@ -import { SupportedNodeTypes } from '@macro-inc/lexical-core'; -import { - $createParagraphNode, - $createTextNode, - $getRoot, - createEditor, -} from 'lexical'; -import { createRoot } from 'solid-js'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { decodeBase64Utf8 } from '../core/decode-base64'; -import { composeEnvironment } from '../tests/capabilities'; -import { createEmailComposer } from './email-composer'; +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', () => { - afterEach(() => vi.useRealTimers()); it('saves a composed draft after debounce and preserves the recipients and HTML', async () => { - vi.useFakeTimers(); - const services = composeEnvironment(); - const showDraft = vi.fn(); - const root = createRoot((dispose) => ({ - dispose, - state: createEmailComposer({ - ...services, - host: { showDraft }, - initialTo: ['colleague@example.com'], - }), - })); + const context = createComposeContext(); + const root = mountEmailComposer(context); try { - const editor = createEditor({ - nodes: SupportedNodeTypes, - namespace: 'email-controller-test', - onError: (error) => { - throw error; - }, - }); - editor.update( - () => { - $getRoot().append( - $createParagraphNode().append($createTextNode('Keep this draft')) - ); - }, - { discrete: true } - ); - root.state.context.captureEditor(editor); - root.state.context.setSubject('Architecture'); - root.state.context.onContentChange('Keep this draft'); + root.edit('Keep this draft', 'Architecture'); await vi.advanceTimersByTimeAsync(600); - expect(services.drafts.saveDraft).toHaveBeenCalled(); - const input = vi.mocked(services.drafts.saveDraft).mock.calls.at(-1)![0]; - expect(input.draft.to).toEqual([ + 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(input.draft.subject).toBe('Architecture'); - expect(decodeBase64Utf8(input.draft.body_html ?? '')).toContain( + expect(draft.subject).toBe('Architecture'); + expect(decodeBase64Utf8(draft.body_html ?? '')).toContain( 'Keep this draft' ); expect(root.state.context.hasDraft()).toBe(true); @@ -60,128 +28,82 @@ describe('standalone compose controller', () => { root.dispose(); } }); - it('does not send an empty draft', async () => { - const services = composeEnvironment(); - const root = createRoot((dispose) => ({ - dispose, - state: createEmailComposer({ ...services }), - })); + + 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 Promise.resolve(); - expect(services.delivery.sendMessage).not.toHaveBeenCalled(); + 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 send time, and keeps a confirmed schedule when archive fails', async () => { - vi.useFakeTimers(); - const services = composeEnvironment(); - const root = createRoot((dispose) => ({ - dispose, - state: createEmailComposer({ - ...services, - initialTo: ['colleague@example.com'], - }), - })); - try { - const editor = createEditor({ - nodes: SupportedNodeTypes, - onError: (error) => { - throw error; - }, - }); - editor.update( - () => { - $getRoot().append( - $createParagraphNode().append($createTextNode('Schedule this reply')) - ); - }, - { discrete: true } - ); - root.state.context.captureEditor(editor); - root.state.context.setSubject('Schedule review'); - root.state.context.onContentChange('Schedule this reply'); - const requested = new Date('2026-10-01T12:00:00Z'); - vi.mocked(services.delivery.schedule).mockRejectedValueOnce( - new Error('offline') - ); - await root.state.context.onSendTimeChange?.(requested); - expect(root.state.context.sendTime()).toBeFalsy(); - expect(services.notices.feedback.failure).toHaveBeenCalledWith( - 'Failed to schedule message' - ); - vi.mocked(services.delivery.archive).mockRejectedValueOnce( - new Error('archive offline') - ); - await root.state.context.onSendTimeChange?.(requested); - expect(root.state.context.sendTime()).toEqual(requested); - expect(services.notices.feedback.failure).toHaveBeenCalledWith( - 'Email scheduled, but unable to mark thread done' - ); - } finally { - root.dispose(); - vi.useRealTimers(); - } -}); - -it('blocks immediate send and overlapping changes while a scheduling request is pending', async () => { - let finish!: () => void; - const services = composeEnvironment({ - delivery: { - schedule: vi.fn( - () => - new Promise((resolve) => { - finish = resolve; - }) - ), - }, + 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(); + } }); - const root = createRoot((dispose) => ({ - dispose, - state: createEmailComposer({ - ...services, - initialTo: ['colleague@example.com'], - }), - })); - try { - const editor = createEditor({ - nodes: SupportedNodeTypes, - onError: (error) => { - throw error; - }, + + it('blocks immediate send and overlapping changes while a scheduling request is pending', async () => { + const pending = Promise.withResolvers(); + const context = createComposeContext({ + delivery: { schedule: vi.fn(() => pending.promise) }, }); - editor.update( - () => { - $getRoot().append( - $createParagraphNode().append($createTextNode('Schedule this reply')) - ); - }, - { discrete: true } - ); - root.state.context.captureEditor(editor); - root.state.context.setSubject('Schedule review'); - root.state.context.onContentChange('Schedule this reply'); - const request = root.state.context.onSendTimeChange?.( - new Date('2026-10-01T12:00:00Z') - ); - await vi.waitFor(() => - expect(services.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(services.delivery.sendMessage).not.toHaveBeenCalled(); - expect(services.delivery.schedule).toHaveBeenCalledOnce(); - finish(); - await request; - expect(root.state.context.disabled()).toBe(false); - expect(root.state.draftDirty()).toBe(true); - } finally { - root.dispose(); - } + 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-form-state.ts b/apps/web/src/features/email-compose/primitives/email-form-state.ts index e5f55b3a49e..784003cdf89 100644 --- a/apps/web/src/features/email-compose/primitives/email-form-state.ts +++ b/apps/web/src/features/email-compose/primitives/email-form-state.ts @@ -9,7 +9,7 @@ import type { EmailRecipient } from '@app/features/email-compose/core/email-reci 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 type { EmailFormDependencies } from '../context/email-form-dependencies'; +import type { EmailFormContextInputs } from '../context/email-form-inputs'; import { decodeBase64Utf8 } from '../core/decode-base64'; import { convertContactInfoToEmailRecipient, @@ -79,14 +79,14 @@ const EMPTY_FORM_STATE: EmailFormState = { * @returns A state object for the email form. */ export function createEmailFormState( - dependencies: EmailFormDependencies, + context: EmailFormContextInputs, purpose?: | { type: 'replying_to'; messageId: string } | { type: 'draft'; messageId: string }, options?: EmailFormStateOptions ) { - const userEmail = dependencies.viewerEmail; + const userEmail = context.viewerEmail; let replyingTo: EmailMessage | undefined; @@ -114,7 +114,7 @@ export function createEmailFormState( const inboxEmail = () => { const inboxId = selectedInboxId() ?? (draft ?? replyingTo)?.link_id; const ownerEmail = inboxId - ? dependencies.inboxes().find((l) => l.id === inboxId)?.email_address + ? context.inboxes().find((l) => l.id === inboxId)?.email_address : undefined; return ownerEmail ?? userEmail() ?? ''; }; 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 index 4dca4b35c4c..556d19b55b6 100644 --- 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 @@ -1,26 +1,24 @@ // @vitest-environment jsdom -import type { EmailMessage } from '@app/features/email-message/core/email-message'; + 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'; -function decodeBodyHtml(encoded: string) { - const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); - return decodeURIComponent(escape(atob(base64))); -} - -const replyingTo = { +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: [], -} as unknown as EmailMessage; +}); describe('prepareEmailBodyFromHtml', () => { it.each(['reply', 'forward'] as const)( @@ -35,7 +33,7 @@ describe('prepareEmailBodyFromHtml', () => { }, }); const dom = new DOMParser().parseFromString( - decodeBodyHtml(prepared.bodyHtml), + decodeBase64Utf8(prepared.bodyHtml), 'text/html' ); const editor = createEditor({ @@ -72,7 +70,7 @@ describe('prepareEmailBodyFromHtml', () => { '

Original message

', }, }); - const decoded = decodeBodyHtml(prepared.bodyHtml); + 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"'); @@ -80,7 +78,7 @@ describe('prepareEmailBodyFromHtml', () => { ); it('does not add a quote block without appendReply (undo-send restore)', () => { const prepared = prepareEmailBodyFromHtml('

hi there

'); - const decoded = decodeBodyHtml(prepared.bodyHtml); + const decoded = decodeBase64Utf8(prepared.bodyHtml); expect(decoded).toContain('hi there'); expect(decoded).not.toContain('macro_quote'); }); @@ -90,7 +88,7 @@ describe('prepareEmailBodyFromHtml', () => { replyType: 'reply', replyingTo, }); - const decoded = decodeBodyHtml(prepared.bodyHtml); + const decoded = decodeBase64Utf8(prepared.bodyHtml); const body = new DOMParser().parseFromString(decoded, 'text/html').body; const quotes = body.querySelectorAll('.macro_quote'); expect(quotes).toHaveLength(1); @@ -103,7 +101,7 @@ describe('prepareEmailBodyFromHtml', () => { '

hi there

already quoted
', { replyType: 'reply', replyingTo } ); - const decoded = decodeBodyHtml(prepared.bodyHtml); + const decoded = decodeBase64Utf8(prepared.bodyHtml); const body = new DOMParser().parseFromString(decoded, 'text/html').body; const quotes = body.querySelectorAll('.macro_quote'); expect(quotes).toHaveLength(1); diff --git a/apps/web/src/features/email-compose/primitives/reply-composer.ts b/apps/web/src/features/email-compose/primitives/reply-composer.ts index 1b290b7d4e5..009a86d1ab0 100644 --- a/apps/web/src/features/email-compose/primitives/reply-composer.ts +++ b/apps/web/src/features/email-compose/primitives/reply-composer.ts @@ -12,7 +12,7 @@ import type { EmailUndoHandle, PersistedEmailIdentity, } from '../context/compose-capabilities'; -import type { EmailReplySession } from '../context/email-form-dependencies'; +import type { EmailReplySession } from '../context/email-form-inputs'; import type { EmailDraft } from '../core/email-draft'; import { createAttachmentPersistence } from './attachment-persistence'; import { createDraftAutosave } from './draft-autosave'; diff --git a/apps/web/src/features/email-compose/primitives/send-schedule.test.ts b/apps/web/src/features/email-compose/primitives/send-schedule.test.ts index b0d3b59c736..d428394ed23 100644 --- a/apps/web/src/features/email-compose/primitives/send-schedule.test.ts +++ b/apps/web/src/features/email-compose/primitives/send-schedule.test.ts @@ -1,63 +1,40 @@ -import { SupportedNodeTypes } from '@macro-inc/lexical-core'; -import { - $createParagraphNode, - $createTextNode, - $getRoot, - createEditor, -} from 'lexical'; import { createRoot, createSignal } from 'solid-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { message } from '../../email-message/tests/messages'; import type { - EmailComposeEnvironment, + EmailComposeContext, PersistedEmailIdentity, } from '../context/compose-capabilities'; import { decodeBase64Utf8 } from '../core/decode-base64'; -import { composeEnvironment } from '../tests/capabilities'; -import { createEmailComposer } from './email-composer'; +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 emailEditor() { - const editor = createEditor({ - nodes: SupportedNodeTypes, - onError: (error) => { - throw error; - }, - }); - const edit = (text: string) => - editor.update( - () => { - $getRoot() - .clear() - .append($createParagraphNode().append($createTextNode(text))); - }, - { discrete: true } - ); - edit('Ready to send'); - return { editor, edit }; -} - function replyComposer( - services: EmailComposeEnvironment, + composeContext: EmailComposeContext, replyingTo = () => message('parent'), callbacks: Pick = {} ) { return createRoot((dispose) => { - const { editor, edit } = emailEditor(); + const editor = createEmailEditor('Ready to send'); const parent = replyingTo(); const form = createEmailFormState( - { viewerEmail: services.viewerEmail, inboxes: services.accounts.inboxes }, + { + viewerEmail: composeContext.viewerEmail, + inboxes: composeContext.accounts.inboxes, + }, { type: 'replying_to', messageId: parent.db_id }, { getMessageById: () => parent, getDraftForMessageReply: () => undefined } ); const state = createReplyComposer( { ...callbacks, - ...services, + ...composeContext, focusAfterReplyRequest: () => true, sourceEntityId: 'thread', replyingTo, @@ -84,7 +61,7 @@ function replyComposer( ...state, dispose, edit(text: string) { - edit(text); + setEmailEditorText(editor, text); state.onContentChange(text); }, }; @@ -93,50 +70,39 @@ function replyComposer( function composer( kind: 'standalone' | 'reply', - services: EmailComposeEnvironment + composeContext: EmailComposeContext ) { if (kind === 'reply') { - const state = replyComposer(services); + const state = replyComposer(composeContext); return { dispose: state.dispose, send: () => state.sendEmail(), schedule: state.handleSendTimeChange, }; } - return createRoot((dispose) => { - const { editor } = emailEditor(); - const { context } = createEmailComposer({ - ...services, - initialTo: ['colleague@example.com'], - }); - context.captureEditor(editor); - context.setSubject('Review'); - context.onContentChange('Ready to send'); - return { - dispose, - send: context.onSend, - schedule: context.onSendTimeChange!, - }; - }); + 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('undoes mark-done while the post-send refresh is still pending', async () => { - const services = composeEnvironment(); - let finish!: () => void; - const refresh = new Promise((resolve) => { - finish = resolve; - }); + 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(services.delivery.undoSend).mockImplementation( + vi.mocked(composeContext.delivery.undoSend).mockImplementation( async ({ onUndone }) => { await onUndone(); } ); - const state = replyComposer(services, undefined, { + const state = replyComposer(composeContext, undefined, { sideEffectOnSend: () => refresh, onMarkDone, }); @@ -144,7 +110,7 @@ describe('send and schedule ordering', () => { const send = state.sendEmail(true); await vi.advanceTimersByTimeAsync(0); const sentNotice = vi - .mocked(services.notices.feedback.success) + .mocked(composeContext.notices.feedback.success) .mock.calls.find(([text]) => text === 'Email sent'); expect(onMarkDone).toHaveBeenCalledOnce(); sentNotice?.[1]?.actions?.[0].onClick(); @@ -155,7 +121,7 @@ describe('send and schedule ordering', () => { await vi.advanceTimersByTimeAsync(600); expect( decodeBase64Utf8( - vi.mocked(services.drafts.saveDraft).mock.lastCall?.[0].draft + vi.mocked(composeContext.drafts.saveDraft).mock.lastCall?.[0].draft .body_html ?? '' ) ).toContain('Restored reply edited before refresh'); @@ -169,39 +135,38 @@ describe('send and schedule ordering', () => { }); it('does not add a scheduling notice after persistence already failed', async () => { - const services = composeEnvironment(); + const composeContext = createComposeContext(); const failure = new Error('Draft save failed'); - vi.mocked(services.drafts.saveDraft).mockImplementationOnce(async () => { - services.notices.feedback.failure('Failed to save draft'); - throw failure; - }); - const state = replyComposer(services); + 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(services.delivery.schedule).not.toHaveBeenCalled(); - expect(services.notices.feedback.failure).toHaveBeenCalledExactlyOnceWith( - 'Failed to save draft' - ); - expect(services.notices.reportError).toHaveBeenCalledWith(failure); + 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 services = composeEnvironment(); - let reject!: (error: Error) => void; - vi.mocked(services.delivery.sendMessage).mockReturnValueOnce( - new Promise((_, fail) => { - reject = fail; - }) - ); - const first = replyComposer(services); + 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(services); + const newer = replyComposer(composeContext); try { newer.form().setSubject('New subject'); newer.edit('Newer reply'); @@ -216,10 +181,10 @@ describe('send and schedule ordering', () => { } }); it('completes reply mark-done when the post-send refresh fails', async () => { - const services = composeEnvironment(); + const composeContext = createComposeContext(); const failure = new Error('Refresh failed'); const onMarkDone = vi.fn(); - const state = replyComposer(services, undefined, { + const state = replyComposer(composeContext, undefined, { sideEffectOnSend: async () => { throw failure; }, @@ -227,10 +192,10 @@ describe('send and schedule ordering', () => { }); try { await state.sendEmail(true); - expect(services.delivery.sendMessage).toHaveBeenCalledOnce(); + expect(composeContext.delivery.sendMessage).toHaveBeenCalledOnce(); expect(onMarkDone).toHaveBeenCalledOnce(); - expect(services.notices.reportError).toHaveBeenCalledWith(failure); - expect(services.notices.feedback.failure).not.toHaveBeenCalled(); + expect(composeContext.notices.reportError).toHaveBeenCalledWith(failure); + expect(composeContext.notices.feedback.failure).not.toHaveBeenCalled(); expect(state.isSending()).toBe(false); } finally { state.dispose(); @@ -238,18 +203,18 @@ describe('send and schedule ordering', () => { }); it('restores a failed reply after optimistic reset without marking it done', async () => { - const services = composeEnvironment(); - vi.mocked(services.delivery.sendMessage).mockRejectedValueOnce( + const composeContext = createComposeContext(); + vi.mocked(composeContext.delivery.sendMessage).mockRejectedValueOnce( new Error('Offline') ); const onMarkDone = vi.fn(); - const state = replyComposer(services, undefined, { onMarkDone }); + const state = replyComposer(composeContext, undefined, { onMarkDone }); try { state.edit('Keep my reply'); await state.sendEmail(true); - expect(services.notices.feedback.failure).toHaveBeenCalledExactlyOnceWith( - 'Failed to send email' - ); + 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( @@ -264,24 +229,22 @@ describe('send and schedule ordering', () => { afterEach(() => vi.useRealTimers()); it('serializes the last reply edit on disposal and reuses the ID from its first save', async () => { - let finish!: (value: PersistedEmailIdentity) => void; - const services = composeEnvironment(); - vi.mocked(services.drafts.saveDraft).mockReturnValueOnce( - new Promise((resolve) => { - finish = resolve; - }) - ); - const state = replyComposer(services); + 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(services.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); finish({ draftId: 'saved-reply', threadId: 'thread', inboxId: 'inbox' }); await vi.advanceTimersByTimeAsync(0); - expect(services.drafts.saveDraft).toHaveBeenCalledTimes(2); - const latest = vi.mocked(services.drafts.saveDraft).mock.calls[1][0].draft; + 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'); @@ -289,30 +252,29 @@ describe('send and schedule ordering', () => { it('flushes an unmounted editor only to its original reply target', async () => { const [target, setTarget] = createSignal(message('original')); - const services = composeEnvironment(); - const state = replyComposer(services, target); + 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(services.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); expect( - vi.mocked(services.drafts.saveDraft).mock.calls[0][0].draft + 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 () => { - let finish!: () => void; - const services = composeEnvironment(); - vi.mocked(services.attachmentStorage.uploadAttachments).mockReturnValueOnce( - new Promise((resolve) => { - finish = resolve; - }) - ); - const state = replyComposer(services); + 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')]); @@ -322,10 +284,10 @@ describe('send and schedule ordering', () => { ); await vi.advanceTimersByTimeAsync(0); state.form().setSelectedInbox('inbox'); - expect(services.delivery.schedule).not.toHaveBeenCalled(); + expect(composeContext.delivery.schedule).not.toHaveBeenCalled(); finish(); await scheduling; - expect(services.delivery.schedule).toHaveBeenCalledExactlyOnceWith( + expect(composeContext.delivery.schedule).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ draftId: 'draft' }), 'secondary' ); @@ -335,27 +297,25 @@ describe('send and schedule ordering', () => { }); it('discards an in-flight first reply save after an upload failure without leaving a draft', async () => { - let fail!: (error: Error) => void; - const services = composeEnvironment(); - vi.mocked(services.attachmentStorage.uploadAttachments).mockReturnValueOnce( - new Promise((_resolve, reject) => { - fail = reject; - }) - ); - const state = replyComposer(services); + 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(services.drafts.deleteDraft).not.toHaveBeenCalled(); + expect(composeContext.drafts.deleteDraft).not.toHaveBeenCalled(); fail(new Error('Upload failed')); await discarded; await vi.advanceTimersByTimeAsync(1000); - expect(services.drafts.deleteDraft).toHaveBeenCalledExactlyOnceWith( + expect(composeContext.drafts.deleteDraft).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ draftId: 'draft' }) ); - expect(services.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); expect(state.savedDraftId()).toBeUndefined(); } finally { state.dispose(); @@ -365,20 +325,21 @@ describe('send and schedule ordering', () => { it.each(['send', 'discard'] as const)( 'blocks scheduling and inbox changes during a pending reply %s', async (operation) => { - let finish!: () => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const services = composeEnvironment(); + const { promise: pending, resolve: finish } = + Promise.withResolvers(); + const composeContext = createComposeContext(); if (operation === 'send') - vi.mocked(services.delivery.sendMessage).mockImplementationOnce( + vi.mocked(composeContext.delivery.sendMessage).mockImplementationOnce( async () => { await pending; return { draftId: 'sent', threadId: 'thread', inboxId: 'inbox' }; } ); - else vi.mocked(services.drafts.deleteDraft).mockReturnValueOnce(pending); - const state = replyComposer(services); + else + vi.mocked(composeContext.drafts.deleteDraft).mockReturnValueOnce( + pending + ); + const state = replyComposer(composeContext); try { state.edit('Ready'); await vi.advanceTimersByTimeAsync(500); @@ -387,17 +348,18 @@ describe('send and schedule ordering', () => { ? state.sendEmail() : state.deleteDraftAndReset(); await vi.advanceTimersByTimeAsync(0); - const saves = vi.mocked(services.drafts.saveDraft).mock.calls.length; + 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(services.delivery.schedule).not.toHaveBeenCalled(); - expect(services.drafts.saveDraft).toHaveBeenCalledTimes(saves); + 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(services.drafts.saveDraft).toHaveBeenCalledTimes(saves); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledTimes(saves); } finally { finish(); state.dispose(); @@ -406,14 +368,11 @@ describe('send and schedule ordering', () => { ); it('does not attach a forwarded file removed while the first draft save is pending', async () => { - let finish!: (value: PersistedEmailIdentity) => void; - const services = composeEnvironment(); - vi.mocked(services.drafts.saveDraft).mockReturnValueOnce( - new Promise((resolve) => { - finish = resolve; - }) - ); - const state = replyComposer(services); + 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, @@ -429,32 +388,35 @@ describe('send and schedule ordering', () => { finish({ draftId: 'draft', threadId: 'thread', inboxId: 'inbox' }); await vi.advanceTimersByTimeAsync(0); expect( - services.attachmentStorage.addForwardedAttachments + composeContext.attachmentStorage.addForwardedAttachments ).not.toHaveBeenCalled(); } finally { state.dispose(); } }); - it('does not submit a second reply while its first send is still saving', async () => { - let finish!: (value: PersistedEmailIdentity) => void; - const services = composeEnvironment(); - vi.mocked(services.drafts.saveDraft).mockReturnValueOnce( - new Promise((resolve) => { - finish = resolve; - }) - ); - const state = replyComposer(services); + 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(services.drafts.saveDraft).toHaveBeenCalledOnce(); + 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(services.delivery.sendMessage).toHaveBeenCalledOnce(); - expect(services.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.delivery.sendMessage).toHaveBeenCalledOnce(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); } finally { state.dispose(); } @@ -463,21 +425,23 @@ describe('send and schedule ordering', () => { it.each([false, true])( 'restores a cross-inbox reply and its envelope after undo (remount: %s)', async (remount) => { - const services = composeEnvironment(); + const composeContext = createComposeContext(); const persisted = { draftId: 'cross-inbox-draft', threadId: 'secondary-thread', inboxId: 'secondary', }; - vi.mocked(services.drafts.saveDraft).mockResolvedValue(persisted); - vi.mocked(services.delivery.sendMessage).mockResolvedValue(persisted); - vi.mocked(services.delivery.undoSend).mockImplementation( + 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(services, target); + let state = replyComposer(composeContext, target); try { state.form().setSelectedInbox('secondary'); state.form().setSubject('Custom reply subject'); @@ -494,16 +458,16 @@ describe('send and schedule ordering', () => { ]); await state.sendEmail(); await vi.advanceTimersByTimeAsync(0); - expect(services.delivery.sendMessage).toHaveBeenCalledWith( + expect(composeContext.delivery.sendMessage).toHaveBeenCalledWith( expect.objectContaining({ inboxId: 'secondary' }) ); const notice = vi - .mocked(services.notices.feedback.success) + .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(services.drafts.restoreDraft).toHaveBeenCalledWith( + expect(composeContext.drafts.restoreDraft).toHaveBeenCalledWith( expect.objectContaining({ inboxId: 'secondary', threadId: 'secondary-thread', @@ -512,12 +476,12 @@ describe('send and schedule ordering', () => { }), }) ); - if (remount) state = replyComposer(services, target); + 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(services.drafts.saveDraft).toHaveBeenLastCalledWith( + expect(composeContext.drafts.saveDraft).toHaveBeenLastCalledWith( expect.objectContaining({ inboxId: 'secondary', previousThreadId: 'secondary-thread', @@ -529,7 +493,7 @@ describe('send and schedule ordering', () => { }) ); await state.deleteDraftAndReset(); - expect(services.drafts.deleteDraft).toHaveBeenLastCalledWith( + expect(composeContext.drafts.deleteDraft).toHaveBeenLastCalledWith( expect.objectContaining({ threadId: 'secondary-thread', inboxId: 'secondary', @@ -542,19 +506,16 @@ describe('send and schedule ordering', () => { ); it('reconciles each previous persisted thread when a reply moves between inboxes', async () => { - const services = composeEnvironment(); - let finish!: (value: PersistedEmailIdentity) => void; - vi.mocked(services.drafts.saveDraft) + 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( - new Promise((resolve) => { - finish = resolve; - }) - ) + .mockReturnValueOnce(saving) .mockResolvedValueOnce({ draftId: 'draft-b', threadId: 'thread-b', @@ -565,7 +526,7 @@ describe('send and schedule ordering', () => { threadId: 'thread-c', inboxId: 'c', }); - const state = replyComposer(services); + const state = replyComposer(composeContext); try { state.edit('Moving between inboxes'); await vi.advanceTimersByTimeAsync(500); @@ -574,7 +535,7 @@ describe('send and schedule ordering', () => { finish({ draftId: 'draft-a', threadId: 'thread-a', inboxId: 'inbox' }); await vi.advanceTimersByTimeAsync(0); const inputs = vi - .mocked(services.drafts.saveDraft) + .mocked(composeContext.drafts.saveDraft) .mock.calls.map(([input]) => input); expect(inputs.map((input) => input.previousThreadId)).toEqual([ undefined, @@ -587,7 +548,7 @@ describe('send and schedule ordering', () => { 'draft-b', ]); await state.handleSendTimeChange(new Date('2026-10-01T12:00:00Z')); - expect(services.delivery.archive).toHaveBeenLastCalledWith( + expect(composeContext.delivery.archive).toHaveBeenLastCalledWith( { threadId: 'thread-c', value: true }, 'c' ); @@ -596,61 +557,26 @@ describe('send and schedule ordering', () => { } }); - it('waits for a saved reply and dispatches with its returned draft ID', async () => { - let finishSaving!: (value: PersistedEmailIdentity) => void; - const services = composeEnvironment(); - vi.mocked(services.drafts.saveDraft).mockReturnValueOnce( - new Promise((resolve) => { - finishSaving = resolve; - }) - ); - const state = composer('reply', services); - try { - state.send(); - await vi.advanceTimersByTimeAsync(0); - expect(services.drafts.saveDraft).toHaveBeenCalledOnce(); - expect(services.delivery.sendMessage).not.toHaveBeenCalled(); - finishSaving({ - draftId: 'saved-reply', - threadId: 'thread', - inboxId: 'inbox', - }); - await vi.advanceTimersByTimeAsync(0); - expect(services.delivery.sendMessage).toHaveBeenCalledOnce(); - expect( - vi.mocked(services.delivery.sendMessage).mock.calls[0][0].message.db_id - ).toBe('saved-reply'); - } finally { - state.dispose(); - } - }); - it.each(['standalone', 'reply'] as const)( '%s does not dispatch when scheduling starts during the pending draft save', async (kind) => { - let finishSaving!: (value: PersistedEmailIdentity) => void; - let finishScheduling!: () => void; - const services = composeEnvironment({ + const { promise: saving, resolve: finishSaving } = + Promise.withResolvers(); + const { promise: scheduled, resolve: finishScheduling } = + Promise.withResolvers(); + const composeContext = createComposeContext({ delivery: { - schedule: vi.fn( - () => - new Promise((resolve) => { - finishScheduling = resolve; - }) - ), + schedule: vi.fn(() => scheduled), }, }); - vi.mocked(services.drafts.saveDraft).mockImplementationOnce( - () => - new Promise((resolve) => { - finishSaving = resolve; - }) + vi.mocked(composeContext.drafts.saveDraft).mockImplementationOnce( + () => saving ); - const state = composer(kind, services); + const state = composer(kind, composeContext); try { state.send(); await vi.advanceTimersByTimeAsync(0); - expect(services.drafts.saveDraft).toHaveBeenCalledOnce(); + expect(composeContext.drafts.saveDraft).toHaveBeenCalledOnce(); const scheduling = state.schedule(new Date('2026-10-01T12:00:00Z')); await vi.advanceTimersByTimeAsync(0); finishSaving({ @@ -659,8 +585,8 @@ describe('send and schedule ordering', () => { inboxId: 'inbox', }); await vi.advanceTimersByTimeAsync(0); - expect(services.delivery.sendMessage).not.toHaveBeenCalled(); - expect(services.delivery.schedule).toHaveBeenCalledOnce(); + expect(composeContext.delivery.sendMessage).not.toHaveBeenCalled(); + expect(composeContext.delivery.schedule).toHaveBeenCalledOnce(); finishScheduling(); await scheduling; } finally { diff --git a/apps/web/src/features/email-compose/tests/capabilities.ts b/apps/web/src/features/email-compose/tests/capabilities.ts index 290a1443493..c74f981e7f5 100644 --- a/apps/web/src/features/email-compose/tests/capabilities.ts +++ b/apps/web/src/features/email-compose/tests/capabilities.ts @@ -1,12 +1,12 @@ import { vi } from 'vitest'; -import type { EmailComposeEnvironment } from '../context/compose-capabilities'; +import type { EmailComposeContext } from '../context/compose-capabilities'; /** Fake capabilities: no production modules or app providers are needed by a controller. */ -export function composeEnvironment( +export function createComposeContext( overrides: { - drafts?: Partial; - delivery?: Partial; + drafts?: Partial; + delivery?: Partial; } = {} -): EmailComposeEnvironment { +): EmailComposeContext { return { recipientName: (id) => id, recordMention: vi.fn(), 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..151d407fc0f --- /dev/null +++ b/apps/web/src/features/email-compose/tests/composer.ts @@ -0,0 +1,33 @@ +import { createRoot } from 'solid-js'; +import type { EmailComposeContext } from '../context/compose-capabilities'; +import { + createEmailComposer, + type EmailComposerOptions, +} 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, + options: Pick = {} +) { + const root = createRoot((dispose) => ({ + dispose, + state: createEmailComposer({ + ...context, + initialTo: ['colleague@example.com'], + ...options, + }), + })); + 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/email-compose/views/email-compose.tsx b/apps/web/src/features/email-compose/views/email-compose.tsx index 756c5372de9..c2d955835c6 100644 --- a/apps/web/src/features/email-compose/views/email-compose.tsx +++ b/apps/web/src/features/email-compose/views/email-compose.tsx @@ -11,7 +11,7 @@ import { Surface } from '@ui'; import { createSignal, Show } from 'solid-js'; import { SignaturePreview } from '../components/signature-preview'; -import type { EmailComposeEnvironment } from '../context/compose-capabilities'; +import type { EmailComposeContext } from '../context/compose-capabilities'; import { ComposeProvider } from '../context/compose-context'; import type { ComposeContextValue } from '../primitives/compose-view-state'; import { @@ -28,19 +28,19 @@ export type EmailComposeViewProps = Pick< | 'recipientOptions' | 'onRecipientsChange' | 'initialTo' -> & { services: EmailComposeEnvironment }; +> & { context: EmailComposeContext }; export function EmailComposeView(props: EmailComposeViewProps) { - const services = props.services; + const composeContext = props.context; const state = createEmailComposer({ - drafts: services.drafts, - attachmentStorage: services.attachmentStorage, - delivery: services.delivery, - notices: services.notices, - accounts: services.accounts, - viewerEmail: services.viewerEmail, - hasPaidAccess: services.hasPaidAccess, - recipients: services.recipients, - recipientName: services.recipientName, + 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, @@ -63,33 +63,34 @@ export function EmailComposeView(props: EmailComposeViewProps) { bodyActions: { focusSibling: props.host?.focusSibling, recipientAdded: (email) => - services.notices.feedback.success(`${email} added to CC`), - readDroppedFiles: services.editorFiles.readDroppedFiles, + composeContext.notices.feedback.success(`${email} added to CC`), + readDroppedFiles: composeContext.editorFiles.readDroppedFiles, pasteFiles: (editor, files, directories) => - services.editorFiles.uploadEditorFiles({ + composeContext.editorFiles.uploadEditorFiles({ editor, files, directories, - onUploaded: (ids) => ids.forEach(services.editorFiles.makePublic), + onUploaded: (ids) => + ids.forEach(composeContext.editorFiles.makePublic), }), }, - isMobile: services.presentation.isMobile, - scheduleEnabled: services.presentation.scheduleEnabled, - attachmentFailure: services.notices.feedback.failure, - onUpgrade: services.presentation.onUpgrade, - viewerLoading: services.presentation.viewerLoading, + 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)} /> @@ -99,7 +100,7 @@ export function EmailComposeView(props: EmailComposeViewProps) { }; const [draftBackMenuOpen, setDraftBackMenuOpen] = createSignal(false); - if (services.presentation.isMobile()) { + 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; @@ -115,7 +116,7 @@ export function EmailComposeView(props: EmailComposeViewProps) { return ( - +
- + & { - services: EmailComposeEnvironment; + context: EmailComposeContext; markdownDomRef?: (ref: HTMLDivElement) => void | HTMLDivElement; unframed?: boolean; mobileDrawer?: { onClose: () => void }; }; export function ReplyInputView(props: ReplyInputViewProps) { - const services = props.services; + const composeContext = props.context; const ctx = props.session; const [isDragging, setIsDragging] = createSignal(); let composeContainerRef: HTMLDivElement | undefined; @@ -106,15 +106,15 @@ export function ReplyInputView(props: ReplyInputViewProps) { const [editor, setEditor] = createSignal(); const state = createReplyComposer( { - drafts: services.drafts, - attachmentStorage: services.attachmentStorage, - delivery: services.delivery, - notices: services.notices, - accounts: services.accounts, - viewerEmail: services.viewerEmail, - hasPaidAccess: services.hasPaidAccess, - recordMention: services.recordMention, - focusAfterReplyRequest: () => !services.presentation.isTouch(), + 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(), newMessage: props.newMessage, session: props.session, sourceEntityId: props.sourceEntityId, @@ -162,11 +162,11 @@ export function ReplyInputView(props: ReplyInputViewProps) { toggleQuotedText, } = state; const sendActionHidden = () => - services.presentation.isTouch() && + composeContext.presentation.isTouch() && !state.hasBodyText() && state.replyType() !== 'forward'; const signatureHtml = () => - services.presentation.signaturesEnabled() + composeContext.presentation.signaturesEnabled() ? state.signatureHtml() : undefined; const isMobileDrawer = () => props.mobileDrawer !== undefined; @@ -184,17 +184,17 @@ export function ReplyInputView(props: ReplyInputViewProps) { onChange: state.onContentChange, onUserMention: state.handleUserMention, onDocumentMention: (item) => { - services.editorFiles.makePublic(item.id); + composeContext.editorFiles.makePublic(item.id); scheduleDraftSave(); }, onPasteFilesAndDirs: (files, directories) => { - services.editorFiles.uploadEditorFiles({ + composeContext.editorFiles.uploadEditorFiles({ editor: editor(), sourceId: props.sourceEntityId, files, directories, onUploaded: (ids) => { - ids.forEach(services.editorFiles.makePublic); + ids.forEach(composeContext.editorFiles.makePublic); scheduleDraftSave(); }, }); @@ -386,7 +386,7 @@ export function ReplyInputView(props: ReplyInputViewProps) { fields={state.recipients} values={() => form().recipients()} options={props.session.recipientOptions} - inboxes={services.accounts.inboxes} + inboxes={composeContext.accounts.inboxes} activeInboxId={activeInboxId} senderEmail={activeInboxEmail} onSenderChange={persistDraftOnSenderSwitch} @@ -440,7 +440,7 @@ export function ReplyInputView(props: ReplyInputViewProps) { onDrop: (files, directories, event) => { const currentEditor = editor(); if (!currentEditor || !event) return; - services.editorFiles.uploadEditorFiles({ + composeContext.editorFiles.uploadEditorFiles({ editor: currentEditor, sourceId: props.sourceEntityId, files, @@ -448,7 +448,7 @@ export function ReplyInputView(props: ReplyInputViewProps) { dropEvent: event, onUploaded: (ids) => { setIsDragging(false); - ids.forEach(services.editorFiles.makePublic); + ids.forEach(composeContext.editorFiles.makePublic); scheduleDraftSave(); }, }); @@ -486,10 +486,10 @@ export function ReplyInputView(props: ReplyInputViewProps) {
@@ -499,8 +499,8 @@ export function ReplyInputView(props: ReplyInputViewProps) { {(html) => ( { // Dismissal is composer-local state worth keeping — latch @@ -568,8 +568,8 @@ export function ReplyInputView(props: ReplyInputViewProps) { {(html) => ( { // Dismissal is composer-local state worth keeping — latch @@ -604,15 +604,16 @@ export function ReplyInputView(props: ReplyInputViewProps) {
; images?: ImagePolicy; prepareLinks?: (container: HTMLElement) => void; @@ -20,10 +20,10 @@ export interface EmailRenderingDependencies { ): Promise; } -const EmailRenderingContext = createContext(); +const EmailRenderingContext = createContext(); export const EmailRenderingProvider = EmailRenderingContext.Provider; -export function useEmailRendering(): EmailRenderingDependencies { +export function useEmailRenderingContext(): EmailRenderingContextValue { const value = useContext(EmailRenderingContext); if (!value) throw new Error('Email rendering requires an EmailRenderingProvider'); diff --git a/apps/web/src/features/email-message/email-message.tsx b/apps/web/src/features/email-message/email-message.tsx index ad7dd2e86de..68be9a65178 100644 --- a/apps/web/src/features/email-message/email-message.tsx +++ b/apps/web/src/features/email-message/email-message.tsx @@ -1,5 +1,5 @@ import { EmailRenderingProvider } from './context/email-rendering-context'; -import { createEmailRenderingDependencies } from './rendering-adapter'; +import { createEmailRenderingContext } from './rendering-adapter'; import { EmailSenderIcon } from './sender-icon-adapter'; import { EmailMessageView, @@ -8,9 +8,9 @@ import { /** Production composition for rendering a single email on any surface. */ export function EmailMessage(props: EmailMessageViewProps) { - const dependencies = createEmailRenderingDependencies(); + const renderingContext = createEmailRenderingContext(); return ( - + } {...props} diff --git a/apps/web/src/features/email-message/image-adapter.test.ts b/apps/web/src/features/email-message/image-adapter.test.ts index 3ae2ccec3e7..1356d3db936 100644 --- a/apps/web/src/features/email-message/image-adapter.test.ts +++ b/apps/web/src/features/email-message/image-adapter.test.ts @@ -37,10 +37,7 @@ describe('production image adaptation', () => { }); it('does not create a blob URL when disposed during the platform response body', async () => { const { root, img } = imageRoot('https://files.example.com/image'); - let finish!: (blob: Blob) => void; - const blob = new Promise((resolve) => { - finish = resolve; - }); + const { promise: blob, resolve: finish } = Promise.withResolvers(); fetchImage.mockResolvedValue({ ok: true, headers: new Headers({ 'content-type': 'image/png' }), 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 index 000daaf0e15..e88ed18f89a 100644 --- 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 @@ -2,7 +2,10 @@ 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 } from './email-message-body'; +import { + createEmailMessageBody, + type EmailMessageBodyProps, +} from './email-message-body'; const theme = { inkL: 0.2, @@ -13,6 +16,13 @@ const theme = { 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 () => { @@ -45,11 +55,7 @@ describe('independent email body', () => { get message() { return value(); }, - isPersonal: true, - isBodyExpanded: () => true, - setExpandedMessageBody() {}, - setFocusedMessageId() {}, - isFocused: false, + ...bodyOptions, }, { theme: () => theme, resolveImages, prepareLinks } ); @@ -101,11 +107,7 @@ describe('independent email body', () => { const body = createEmailMessageBody( { message: message('markdown', content), - isPersonal: true, - isBodyExpanded: () => true, - setExpandedMessageBody() {}, - setFocusedMessageId() {}, - isFocused: false, + ...bodyOptions, }, { theme: () => theme, resolveImages } ); 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 index 72e120db223..728a5065c08 100644 --- a/apps/web/src/features/email-message/primitives/email-message-body.ts +++ b/apps/web/src/features/email-message/primitives/email-message-body.ts @@ -8,7 +8,7 @@ import { onCleanup, untrack, } from 'solid-js'; -import type { EmailRenderingDependencies } from '../context/email-rendering-context'; +import type { EmailRenderingContextValue } from '../context/email-rendering-context'; import type { EmailMessage } from '../core/email-message'; export interface EmailMessageBodyProps { @@ -24,7 +24,7 @@ export interface EmailMessageBodyProps { /** Solid only translates reactive inputs and owns the renderer's lifetime. */ export function createEmailMessageBody( props: EmailMessageBodyProps, - dependencies: EmailRenderingDependencies + renderingContext: EmailRenderingContextValue ) { const [showFullHTML, setShowFullHTML] = createSignal(false); const prepared = createMemo(() => @@ -37,7 +37,7 @@ export function createEmailMessageBody( { showQuotedContent: showFullHTML(), showFullContent: props.showFullContent, - images: dependencies.images, + images: renderingContext.images, } ) ); @@ -53,15 +53,15 @@ export function createEmailMessageBody( const attachments = props.message.attachments; const host = document.createElement('div'); const renderer = mountEmailBody(host, body, { - theme: dependencies.theme(), + theme: renderingContext.theme(), adaptColors: props.isPersonal || !body.hasTable, normalizeFonts: props.isPersonal && !props.message.from?.email?.toLowerCase().endsWith('@macro.com'), expanded: untrack(props.isBodyExpanded), - prepareLinks: dependencies.prepareLinks, + prepareLinks: renderingContext.prepareLinks, resolveImages: (root, lifetime) => - dependencies.resolveImages(root, attachments, lifetime), + renderingContext.resolveImages(root, attachments, lifetime), }); onCleanup(() => renderer.dispose()); return { host, renderer }; diff --git a/apps/web/src/features/email-message/rendering-adapter.ts b/apps/web/src/features/email-message/rendering-adapter.ts index 6b754f5043e..34391920f17 100644 --- a/apps/web/src/features/email-message/rendering-adapter.ts +++ b/apps/web/src/features/email-message/rendering-adapter.ts @@ -4,10 +4,10 @@ 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 { EmailRenderingDependencies } from './context/email-rendering-context'; +import type { EmailRenderingContextValue } from './context/email-rendering-context'; import { fetchImagesViaPlatform, resolveCidImages } from './image-adapter'; -export function createEmailRenderingDependencies(): EmailRenderingDependencies { +export function createEmailRenderingContext(): EmailRenderingContextValue { const theme = createMemo(() => { themeUpdate(); return { 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 index 6aea99eaa62..4d2b1a03f69 100644 --- a/apps/web/src/features/email-message/views/email-message-body.tsx +++ b/apps/web/src/features/email-message/views/email-message-body.tsx @@ -4,7 +4,7 @@ import DotsThree from '@phosphor/dots-three.svg'; import { Button, cn } from '@ui'; import { Match, Show, Switch } from 'solid-js'; -import { useEmailRendering } from '../context/email-rendering-context'; +import { useEmailRenderingContext } from '../context/email-rendering-context'; import { createEmailMessageBody, type EmailMessageBodyProps, @@ -16,7 +16,7 @@ export function EmailMessageBody(props: EmailMessageBodyProps) { setShowFullHTML, host, hasHiddenReplyStructure, - } = createEmailMessageBody(props, useEmailRendering()); + } = createEmailMessageBody(props, useEmailRenderingContext()); return (
; viewerEmail: Accessor; diff --git a/apps/web/src/features/email-thread/context/email-thread-view-context.tsx b/apps/web/src/features/email-thread/context/email-thread-view-context.tsx new file mode 100644 index 00000000000..c15f1967150 --- /dev/null +++ b/apps/web/src/features/email-thread/context/email-thread-view-context.tsx @@ -0,0 +1,30 @@ +import { createContext, type JSX, useContext } from 'solid-js'; +import type { + EmailComposeContext, + EmailComposeHost, +} from '../../email-compose/context/compose-capabilities'; +import type { + EmailAttachment, + EmailMessage, +} from '../../email-message/core/email-message'; +import type { EmailThreadContext } from './email-thread-context'; + +export interface EmailThreadViewContext { + thread: EmailThreadContext; + copySubject?: (subject: string) => void; + compose: EmailComposeContext; + composeHost?: EmailComposeHost; + rendering: { + renderAvatar?: (message: EmailMessage) => JSX.Element; + openAttachment?: (attachment: EmailAttachment) => void; + }; +} + +const ThreadViewContext = createContext(); +export const EmailThreadViewProvider = ThreadViewContext.Provider; +export function useEmailThreadViewContext() { + const value = useContext(ThreadViewContext); + if (!value) + throw new Error('Email thread views require an EmailThreadViewProvider'); + return value; +} diff --git a/apps/web/src/features/email-thread/context/thread-environment.tsx b/apps/web/src/features/email-thread/context/thread-environment.tsx deleted file mode 100644 index 6d0d4b807fa..00000000000 --- a/apps/web/src/features/email-thread/context/thread-environment.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { createContext, type JSX, useContext } from 'solid-js'; -import type { - EmailComposeEnvironment, - EmailComposeHost, -} from '../../email-compose/context/compose-capabilities'; -import type { - EmailAttachment, - EmailMessage, -} from '../../email-message/core/email-message'; -import type { EmailThreadDependencies } from './email-thread-dependencies'; - -export interface ThreadViewEnvironment { - dependencies: EmailThreadDependencies; - copySubject?: (subject: string) => void; - compose: EmailComposeEnvironment; - composeHost?: EmailComposeHost; - rendering: { - renderAvatar?: (message: EmailMessage) => JSX.Element; - openAttachment?: (attachment: EmailAttachment) => void; - }; -} - -const ThreadEnvironment = createContext(); -export const ThreadEnvironmentProvider = ThreadEnvironment.Provider; -export function useEmailThreadEnvironment() { - const value = useContext(ThreadEnvironment); - if (!value) - throw new Error('Email thread views require a ThreadEnvironmentProvider'); - return value; -} diff --git a/apps/web/src/features/email-thread/core/thread-stops.test.ts b/apps/web/src/features/email-thread/core/thread-stops.test.ts index 6331fa12a19..3f9baba6977 100644 --- a/apps/web/src/features/email-thread/core/thread-stops.test.ts +++ b/apps/web/src/features/email-thread/core/thread-stops.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'; import { adjacentStop, - enterListStop, nextThreadStop, shownStops, type ThreadStop, @@ -81,14 +80,6 @@ describe('adjacentStop', () => { }); }); -describe('enterListStop', () => { - it('enters at the oldest or newest shown card', () => { - const collapsed = shownStops({ length: 6, showMiddle: false }); - expect(enterListStop(collapsed, 'next')).toEqual(message(0)); - expect(enterListStop(collapsed, 'prev')).toEqual(message(5)); - }); -}); - describe('threadStopFromHover', () => { const ids = ['a', 'b', 'c']; diff --git a/apps/web/src/features/email-thread/email-thread.tsx b/apps/web/src/features/email-thread/email-thread.tsx index b9a769a7017..e487881ef34 100644 --- a/apps/web/src/features/email-thread/email-thread.tsx +++ b/apps/web/src/features/email-thread/email-thread.tsx @@ -7,14 +7,14 @@ import { createEffectOnEntityTypeNotification } from '@notifications'; import { clearSavedDraftThreadCache } from '@queries/email/draft-cache'; import { useThreadQuery } from '@queries/email/thread'; import { createEffect, createMemo, onCleanup } from 'solid-js'; -import { createEmailComposeEnvironment } from '../email-compose/compose-adapter'; +import { createEmailComposeContext } from '../email-compose/compose-adapter'; import { createEmailComposeHost } from '../email-compose/compose-host-adapter'; import { convertContactInfoToEmailRecipient } from '../email-compose/core/recipient-conversion'; import { createEmailAttachmentOpener } from '../email-message/attachment-action-adapter'; import type { EmailMessage } from '../email-message/core/email-message'; -import { createEmailRenderingDependencies } from '../email-message/rendering-adapter'; +import { createEmailRenderingContext } from '../email-message/rendering-adapter'; import { EmailSenderIcon } from '../email-message/sender-icon-adapter'; -import type { EmailThreadDependencies } from './context/email-thread-dependencies'; +import type { EmailThreadContext } from './context/email-thread-context'; import { createEmailThreadSource } from './queries/thread-source'; import { createThreadActionAdapter } from './thread-action-adapter'; import { @@ -24,7 +24,7 @@ import { export type EmailThreadProps = Omit< EmailThreadSurfaceProps, - 'environment' | 'emailRendering' + 'context' | 'emailRendering' >; /** App-facing composition. Import the surface or primitives for isolated tests. */ @@ -36,8 +36,8 @@ export function EmailThread(props: EmailThreadProps) { const contacts = useContacts(); const viewerEmail = useEmail(); const user = useUserContext(); - const compose = createEmailComposeEnvironment(); - const dependencies: EmailThreadDependencies = { + const compose = createEmailComposeContext(); + const threadContext: EmailThreadContext = { source, viewerEmail, viewerLoading: user.isLoading, @@ -49,7 +49,7 @@ export function EmailThread(props: EmailThreadProps) { createCommands: (snapshot) => createThreadActionAdapter(props.threadId, snapshot), }; - const environment = { + const viewContext = { copySubject: (subject: string) => { void navigator.clipboard .writeText(subject) @@ -58,7 +58,7 @@ export function EmailThread(props: EmailThreadProps) { compose.notices.feedback.failure('Unable to copy subject') ); }, - dependencies, + thread: threadContext, compose, composeHost: createEmailComposeHost(), rendering: { @@ -68,7 +68,7 @@ export function EmailThread(props: EmailThreadProps) { ), }, }; - const rendering = createEmailRenderingDependencies(); + const rendering = createEmailRenderingContext(); createEffectOnEntityTypeNotification( useGlobalNotificationSource(), 'email', @@ -88,7 +88,7 @@ export function EmailThread(props: EmailThreadProps) { return ( ); diff --git a/apps/web/src/features/email-thread/primitives/email-thread-state.test.ts b/apps/web/src/features/email-thread/primitives/email-thread-state.test.ts index ee6ca2555e5..871d8b4146f 100644 --- a/apps/web/src/features/email-thread/primitives/email-thread-state.test.ts +++ b/apps/web/src/features/email-thread/primitives/email-thread-state.test.ts @@ -1,7 +1,7 @@ import { createRoot, createSignal } from 'solid-js'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import type { EmailThread } from '../core/email-thread'; -import { dependencies, message, thread } from '../tests/fixtures'; +import { createThreadContext, message, thread } from '../tests/fixtures'; import { createEmailThreadState } from './email-thread-state'; describe('thread state with an injected source', () => { @@ -22,14 +22,8 @@ describe('thread state with an injected source', () => { thread([message('parent'), old]) ); const state = createEmailThreadState( - dependencies({ + createThreadContext({ thread: snapshot, - isLoading: () => false, - isFetching: () => false, - isFetchingOlder: () => false, - hasMore: () => false, - async fetchOlder() {}, - async refresh() {}, }) ); expect(state.drafts.initialDraftsSettled()).toBe(true); @@ -57,23 +51,15 @@ describe('thread state with an injected source', () => { try { const source = { thread: () => thread([message('one')]), - isLoading: () => false, - isFetching: () => false, - isFetchingOlder: () => false, - hasMore: () => false, - fetchOlder: vi.fn(async () => {}), - refresh: vi.fn(async () => {}), }; - const first = createEmailThreadState(dependencies(source)); - const second = createEmailThreadState(dependencies(source)); + const first = createEmailThreadState(createThreadContext(source)); + const second = createEmailThreadState(createThreadContext(source)); first.messages.setFocused('one'); first.setIsScrollingToMessage(true); expect(first.messages.focusedId()).toBe('one'); expect(first.isScrollingToMessage()).toBe(true); expect(second.messages.focusedId()).toBeUndefined(); expect(second.isScrollingToMessage()).toBe(false); - first.query.fetchNextPage(); - expect(source.fetchOlder).toHaveBeenCalledOnce(); } finally { dispose(); } diff --git a/apps/web/src/features/email-thread/primitives/email-thread-state.ts b/apps/web/src/features/email-thread/primitives/email-thread-state.ts index 4336628fa30..27453d9b9e2 100644 --- a/apps/web/src/features/email-thread/primitives/email-thread-state.ts +++ b/apps/web/src/features/email-thread/primitives/email-thread-state.ts @@ -6,9 +6,9 @@ import type { ReplyType } from '../../email-compose/core/reply-type'; import type { EmailMessage } from '../../email-message/core/email-message'; import type { ArchiveThreadOptions, - EmailThreadDependencies, + EmailThreadContext, EmailThreadHost, -} from '../context/email-thread-dependencies'; +} from '../context/email-thread-context'; import type { EmailThread } from '../core/email-thread'; import { selectThreadMessages } from '../core/thread-messages'; import type { HoveredThreadStop } from '../core/thread-stops'; @@ -103,10 +103,10 @@ export type EmailThreadState = { }; export function createEmailThreadState( - deps: EmailThreadDependencies, + threadContext: EmailThreadContext, host: EmailThreadHost = {} ): EmailThreadState { - const threadSnapshot = createThreadSnapshot(deps.source); + const threadSnapshot = createThreadSnapshot(threadContext.source); const selected = createMemo(() => { const thread = threadSnapshot(); return thread ? selectThreadMessages(thread) : undefined; @@ -138,7 +138,7 @@ export function createEmailThreadState( const drafts = createThreadDrafts(selected); const recipients = createThreadRecipients( - deps.recipients, + threadContext.recipients, () => selected()?.messages ); @@ -171,7 +171,7 @@ export function createEmailThreadState( !messageList || !containerRef || !untrack(() => selected())?.db_id || - deps.source.isFetching() + threadContext.source.isFetching() ) { return false; } @@ -184,12 +184,12 @@ export function createEmailThreadState( const onInitialDataLoad = (callback: () => boolean) => { createEffect(() => { if (hasHandledTarget()) return; - const fetching = deps.source.isFetching(); + const fetching = threadContext.source.isFetching(); if (fetching) return; // Check if initial loading is complete const isInitialLoadComplete = - (isContainerFilled() || deps.source.hasMore() === false) && - !deps.source.isFetching(); + (isContainerFilled() || threadContext.source.hasMore() === false) && + !threadContext.source.isFetching(); if (!isInitialLoadComplete) return; @@ -212,15 +212,16 @@ export function createEmailThreadState( thread: createMemo(() => selected()), recipientOptions: recipients.options, onRecipientsChange: recipients.add, - ...deps.createCommands(threadSnapshot), + ...threadContext.createCommands(threadSnapshot), messagesContainerRef, messagesListRef, query: { - hasMore: () => deps.source.hasMore() ?? false, - fetchNextPage: deps.source.fetchOlder, + hasMore: () => threadContext.source.hasMore() ?? false, + fetchNextPage: threadContext.source.fetchOlder, isFetching: () => - deps.source.isLoading() || deps.source.isFetchingOlder(), - refetch: deps.source.refresh, + threadContext.source.isLoading() || + threadContext.source.isFetchingOlder(), + refetch: threadContext.source.refresh, }, drafts, messages: { diff --git a/apps/web/src/features/email-thread/primitives/scroll-to-message.test.ts b/apps/web/src/features/email-thread/primitives/scroll-to-message.test.ts index 10c398312d4..a4f7bb36a77 100644 --- a/apps/web/src/features/email-thread/primitives/scroll-to-message.test.ts +++ b/apps/web/src/features/email-thread/primitives/scroll-to-message.test.ts @@ -1,10 +1,9 @@ // @vitest-environment jsdom -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { adjustScrollAfterPrepend, alignmentDelta, fetchOlderMessages, - hiddenMessagesControl, isTruncatedMiddleMessage, keyboardRevealDelta, leadingThrottle, @@ -35,6 +34,40 @@ function box(element: HTMLElement, top: number, bottom: number): void { }); } +function cardInList( + top: number, + bottom: number, + list: { top?: number; bottom?: number; paddingTop?: number } = {} +) { + const container = document.createElement('div'); + const element = document.createElement('div'); + if (list.paddingTop) + container.style.scrollPaddingTop = `${list.paddingTop}px`; + box(container, list.top ?? 0, list.bottom ?? 800); + box(element, top, bottom); + return { container, element }; +} + +function scrollList(height: number, viewport: number, top = 0) { + const container = document.createElement('div'); + Object.defineProperties(container, { + scrollHeight: { get: () => height }, + clientHeight: { get: () => viewport }, + }); + container.scrollTop = top; + return { + container, + resize: (value: number) => { + height = value; + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + describe('isTruncatedMiddleMessage', () => { it('hides only the middle when there are more than three messages', () => { expect(truncatedMiddleCount(3)).toBe(0); @@ -47,152 +80,100 @@ describe('isTruncatedMiddleMessage', () => { }); }); -describe('hiddenMessagesControl', () => { - it('finds the expand control in the list', () => { - const list = document.createElement('div'); - const button = document.createElement('button'); - button.setAttribute('data-hidden-messages', ''); - list.append(button); - expect(hiddenMessagesControl(list)).toBe(button); - }); -}); - describe('threadMessageIsExpanded', () => { - it('opens the newest message and unread, draft, or manual cards', () => { - expect( - threadMessageIsExpanded({ - chronologicalIndex: 2, - listLength: 3, - isUnread: false, - hasDraft: false, - }) - ).toBe(true); - expect( - threadMessageIsExpanded({ - chronologicalIndex: 0, - listLength: 3, - isUnread: true, - hasDraft: false, - }) - ).toBe(true); - expect( - threadMessageIsExpanded({ - chronologicalIndex: 1, - listLength: 3, - isUnread: false, - hasDraft: false, - }) - ).toBe(false); - }); - - it('lets an explicit collapse win over newest or unread', () => { - expect( - threadMessageIsExpanded({ - chronologicalIndex: 2, - listLength: 3, - expansionOverride: false, - isUnread: false, - hasDraft: false, - }) - ).toBe(false); - expect( - threadMessageIsExpanded({ - chronologicalIndex: 0, - listLength: 3, - expansionOverride: false, - isUnread: true, - hasDraft: false, - }) - ).toBe(false); + const readMessage = { + chronologicalIndex: 1, + listLength: 3, + isUnread: false, + hasDraft: false, + }; + const reasonsToOpen = [ + { chronologicalIndex: 2 }, + { isUnread: true }, + { hasDraft: true }, + { expansionOverride: true }, + ]; + + it('opens the newest message and unread, draft, or manually expanded cards', () => { + expect(threadMessageIsExpanded(readMessage)).toBe(false); + for (const reason of reasonsToOpen) { + expect( + threadMessageIsExpanded({ ...readMessage, ...reason }), + JSON.stringify(reason) + ).toBe(true); + } + }); + + it('lets an explicit collapse take precedence over automatic expansion', () => { + for (const reason of reasonsToOpen) { + expect( + threadMessageIsExpanded({ + ...readMessage, + ...reason, + expansionOverride: false, + }), + JSON.stringify(reason) + ).toBe(false); + } }); }); describe('alignmentDelta', () => { it('start-aligns the card to the list top', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 175, 800); - box(element, 175, 400); + const { container, element } = cardInList(175, 400, { top: 175 }); expect(alignmentDelta(container, element, 'start')).toBe(0); box(element, 200, 400); expect(alignmentDelta(container, element, 'start')).toBe(25); }); it('respects scroll-padding on the list container', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - container.style.scrollPaddingTop = '8px'; - box(container, 0, 800); - box(element, 0, 200); + const { container, element } = cardInList(0, 200, { paddingTop: 8 }); expect(alignmentDelta(container, element, 'start')).toBe(-8); }); it('end-aligns the card to the list bottom', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, 600, 800); + const { container, element } = cardInList(600, 800); expect(alignmentDelta(container, element, 'end')).toBe(0); + box(element, 600, 950); + expect(alignmentDelta(container, element, 'end')).toBe(150); }); }); describe('revealDelta', () => { it('does nothing when the card already fits', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, 200, 400); + const { container, element } = cardInList(200, 400); expect(revealDelta(container, element)).toBe(0); }); it('scrolls down when the card grows past the bottom', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, 600, 950); + const { container, element } = cardInList(600, 950); expect(revealDelta(container, element)).toBe(150); }); it('start-aligns a card taller than the list', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, 200, 1200); + const { container, element } = cardInList(200, 1200); expect(revealDelta(container, element)).toBe(200); }); }); describe('nearestDelta', () => { it('does nothing when the card sits inside the scrollport', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, 200, 400); + const { container, element } = cardInList(200, 400); expect(nearestDelta(container, element)).toBe(0); }); it('start-aligns when the card top sits above the scroll-padding inset', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - container.style.scrollPaddingTop = '8px'; - box(container, 0, 800); - box(element, 4, 200); + const { container, element } = cardInList(4, 200, { paddingTop: 8 }); expect(nearestDelta(container, element)).toBe(-4); }); it('start-aligns when the card sits entirely above the list', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, -200, -50); + const { container, element } = cardInList(-200, -50); expect(nearestDelta(container, element)).toBe(-200); }); it('end-aligns a card that sits entirely below the list', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, 900, 1100); + const { container, element } = cardInList(900, 1100); expect(nearestDelta(container, element)).toBe(300); }); }); @@ -223,41 +204,28 @@ describe('leadingThrottle', () => { expect(throttle()).toBe(false); vi.setSystemTime(300); expect(throttle()).toBe(true); - vi.useRealTimers(); }); }); describe('keyboardRevealDelta', () => { it('end-aligns a short card whose bottom is clipped when moving down', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, 720, 820); + const { container, element } = cardInList(720, 820); expect(keyboardRevealDelta(container, element, 'next')).toBe(20); }); it('start-aligns a short card whose top is clipped when moving up', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, -20, 80); + const { container, element } = cardInList(-20, 80); expect(keyboardRevealDelta(container, element, 'prev')).toBe(-20); }); it('does nothing when the card already fits in the scrollport', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, 200, 400); + const { container, element } = cardInList(200, 400); expect(keyboardRevealDelta(container, element, 'next')).toBe(0); expect(keyboardRevealDelta(container, element, 'prev')).toBe(0); }); it('does nothing for a tall card at the bottom so the next message can load', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, -800, 800); + const { container, element } = cardInList(-800, 800); expect(keyboardRevealDelta(container, element, 'next')).toBe(0); expect(pageThenAdvanceDelta(container, element, 'next')).toBe(0); }); @@ -265,35 +233,23 @@ describe('keyboardRevealDelta', () => { describe('pageThenAdvanceDelta', () => { it('advances when the card already fits', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, 200, 400); + const { container, element } = cardInList(200, 400); expect(pageThenAdvanceDelta(container, element, 'next')).toBe(0); expect(pageThenAdvanceDelta(container, element, 'prev')).toBe(0); }); it('pages down by the remaining overflow when it is less than a viewport', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, 100, 950); + const { container, element } = cardInList(100, 950); expect(pageThenAdvanceDelta(container, element, 'next')).toBe(150); }); it('pages down by one viewport when overflow is larger', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, 0, 2400); + const { container, element } = cardInList(0, 2400); expect(pageThenAdvanceDelta(container, element, 'next')).toBe(800); }); it('pages up when the card top is above the list', () => { - const container = document.createElement('div'); - const element = document.createElement('div'); - box(container, 0, 800); - box(element, -400, 600); + const { container, element } = cardInList(-400, 600); expect(pageThenAdvanceDelta(container, element, 'prev')).toBe(-400); }); }); @@ -314,66 +270,31 @@ describe('scrollToListStartDelta', () => { describe('scrollToListEndDelta', () => { it('scrolls the leftover distance to the bottom', () => { - const container = document.createElement('div'); - Object.defineProperty(container, 'scrollHeight', { - value: 1200, - writable: true, - }); - Object.defineProperty(container, 'clientHeight', { - value: 800, - writable: true, - }); - container.scrollTop = 200; + const { container } = scrollList(1200, 800, 200); expect(scrollToListEndDelta(container)).toBe(200); }); it('is zero when the list is already at the bottom', () => { - const container = document.createElement('div'); - Object.defineProperty(container, 'scrollHeight', { - value: 1200, - writable: true, - }); - Object.defineProperty(container, 'clientHeight', { - value: 800, - writable: true, - }); - container.scrollTop = 400; + const { container } = scrollList(1200, 800, 400); expect(scrollToListEndDelta(container)).toBe(0); }); }); describe('adjustScrollAfterPrepend', () => { it('keeps the same card on screen when height grows above', () => { - const container = document.createElement('div'); - let scrollHeight = 200; - Object.defineProperty(container, 'scrollHeight', { - get: () => scrollHeight, - }); - Object.defineProperty(container, 'clientHeight', { get: () => 150 }); - container.scrollTop = 50; - scrollHeight = 400; + const { container } = scrollList(400, 150, 50); adjustScrollAfterPrepend(container, 200, 50); expect(container.scrollTop).toBe(250); }); it('leaves the title pinned when you were already at the top', () => { - const container = document.createElement('div'); - Object.defineProperty(container, 'scrollHeight', { get: () => 400 }); - Object.defineProperty(container, 'clientHeight', { get: () => 150 }); - container.scrollTop = 0; + const { container } = scrollList(400, 150); adjustScrollAfterPrepend(container, 200, 0); expect(container.scrollTop).toBe(0); }); it('leaves the title pinned when the first page did not overflow', () => { - const container = document.createElement('div'); - let scrollHeight = 200; - Object.defineProperty(container, 'scrollHeight', { - get: () => scrollHeight, - }); - Object.defineProperty(container, 'clientHeight', { get: () => 400 }); - container.scrollTop = 0; - scrollHeight = 500; + const { container } = scrollList(500, 400, 0); adjustScrollAfterPrepend(container, 200, 0); expect(container.scrollTop).toBe(0); }); @@ -414,15 +335,11 @@ describe('listNeedsOlderPage', () => { describe('fetchOlderMessages', () => { it('uses live scrollTop after the fetch completes', async () => { vi.useFakeTimers(); - const list = document.createElement('div'); - let scrollHeight = 200; - Object.defineProperty(list, 'scrollHeight', { get: () => scrollHeight }); - Object.defineProperty(list, 'clientHeight', { get: () => 150 }); - list.scrollTop = 10; + const { container: list, resize } = scrollList(200, 150, 10); const fetchNextPage = vi.fn(async () => { list.scrollTop = 40; - scrollHeight = 400; + resize(400); }); const pending = fetchOlderMessages(list, fetchNextPage); @@ -430,6 +347,5 @@ describe('fetchOlderMessages', () => { await pending; expect(list.scrollTop).toBe(240); - vi.useRealTimers(); }); }); diff --git a/apps/web/src/features/email-thread/primitives/thread-navigation.test.ts b/apps/web/src/features/email-thread/primitives/thread-navigation.test.ts index 71bb84419f4..c6d1fab11c2 100644 --- a/apps/web/src/features/email-thread/primitives/thread-navigation.test.ts +++ b/apps/web/src/features/email-thread/primitives/thread-navigation.test.ts @@ -1,7 +1,7 @@ import { createRoot, createSignal } from 'solid-js'; import { afterEach, expect, it, vi } from 'vitest'; import type { EmailThreadKeyboardHandlers } from '../core/thread-keyboard'; -import { dependencies, message, thread } from '../tests/fixtures'; +import { createThreadContext, message, thread } from '../tests/fixtures'; import { createEmailThreadState } from './email-thread-state'; import { createThreadNavigation } from './thread-navigation'; @@ -12,28 +12,8 @@ afterEach(() => { }); it('registers reply-all for the latest or selected message and ignores an empty thread', () => { - let handlers: EmailThreadKeyboardHandlers | undefined; - const state = createRoot((dispose) => { - const [snapshot, setSnapshot] = createSignal( - thread([message('first'), message('last')]) - ); - const deps = dependencies({ - thread: snapshot, - isLoading: () => false, - isFetching: () => false, - isFetchingOlder: () => false, - hasMore: () => false, - async fetchOlder() {}, - async refresh() {}, - }); - const context = createEmailThreadState(deps); - createThreadNavigation({ threadId: () => 'thread' }, context, deps, { - registerKeyboard: (registered) => { - handlers = registered; - }, - }); - return { context, setSnapshot, dispose }; - }); + const state = navigationWithList(['first', 'last']); + const handlers = state.handlers(); try { expect(handlers?.replyAllToFocusedMessage?.()).toBe(true); expect(state.context.replyRequest.messageId()).toBe('last'); @@ -50,31 +30,12 @@ it('registers reply-all for the latest or selected message and ignores an empty }); it('leaves Enter to a focused button and still activates the thread container', () => { - let handlers: EmailThreadKeyboardHandlers | undefined; - const state = createRoot((dispose) => { - const deps = dependencies({ - thread: () => thread([message('last')]), - isLoading: () => false, - isFetching: () => false, - isFetchingOlder: () => false, - hasMore: () => false, - async fetchOlder() {}, - async refresh() {}, - }); - const context = createEmailThreadState(deps); - createThreadNavigation({ threadId: () => 'thread' }, context, deps, { - registerKeyboard: (registered) => { - handlers = registered; - }, - }); - return { context, dispose }; - }); - const container = document.createElement('div'); - container.tabIndex = 0; + const state = navigationWithList(['last']); + const handlers = state.handlers(); + const container = state.container; const button = document.createElement('button'); button.textContent = 'Remove attachment'; container.append(button); - document.body.append(container); try { button.focus(); @@ -87,7 +48,6 @@ it('leaves Enter to a focused button and still activates the thread container', expect(state.context.replyRequest.messageId()).toBe('last'); expect(state.context.replyRequest.replyType()).toBe('reply-all'); } finally { - container.remove(); state.dispose(); } }); @@ -106,30 +66,25 @@ function navigationWithList(ids: string[]) { document.body.append(container); let handlers: EmailThreadKeyboardHandlers | undefined; const state = createRoot((dispose) => { - const deps = dependencies({ - thread: () => thread(ids.map((id) => message(id))), - isLoading: () => false, - isFetching: () => false, - isFetchingOlder: () => false, - hasMore: () => false, - async fetchOlder() {}, - async refresh() {}, - }); + const [snapshot, setSnapshot] = createSignal( + thread(ids.map((id) => message(id))) + ); + const threadContext = createThreadContext({ thread: snapshot }); const host = { focusContainer: () => container.focus({ preventScroll: true }), registerKeyboard: (registered: EmailThreadKeyboardHandlers) => { handlers = registered; }, }; - const context = createEmailThreadState(deps, host); + const context = createEmailThreadState(threadContext, host); const navigation = createThreadNavigation( { threadId: () => 'thread' }, context, - deps, + threadContext, host ); context.registerMessagesList(container); - return { context, navigation, dispose }; + return { context, navigation, setSnapshot, dispose }; }); return { ...state, diff --git a/apps/web/src/features/email-thread/primitives/thread-navigation.ts b/apps/web/src/features/email-thread/primitives/thread-navigation.ts index ee4ec2f4182..2928ccfdd77 100644 --- a/apps/web/src/features/email-thread/primitives/thread-navigation.ts +++ b/apps/web/src/features/email-thread/primitives/thread-navigation.ts @@ -13,9 +13,9 @@ import { } from 'solid-js'; import { match } from 'ts-pattern'; import type { - EmailThreadDependencies, + EmailThreadContext, EmailThreadHost, -} from '../context/email-thread-dependencies'; +} from '../context/email-thread-context'; import { adjacentStop, nextThreadStop, @@ -51,10 +51,10 @@ const KEYBOARD_SCROLL_MS = 250; export function createThreadNavigation( props: { threadId: Accessor }, context: EmailThreadState, - deps: EmailThreadDependencies, + threadContext: EmailThreadContext, host: EmailThreadHost = {} ) { - const isTouchDevice = deps.isTouch; + const isTouchDevice = threadContext.isTouch; const setIsScrollingToMessage = context.setIsScrollingToMessage; let disposed = false; const pendingWaits = new Set<() => void>(); @@ -478,7 +478,7 @@ export function createThreadNavigation( return openEmailReplyComposerForMessage({ ctx: context, - isMobile: deps.isMobile(), + isMobile: threadContext.isMobile(), message: target.message, replyType, isLastMessage: target.isLastMessage, @@ -542,7 +542,7 @@ export function createThreadNavigation( return openEmailReplyComposerForMessage({ ctx: context, - isMobile: deps.isMobile(), + isMobile: threadContext.isMobile(), message: target.message, replyType: 'reply-all', isLastMessage: target.isLastMessage, diff --git a/apps/web/src/features/email-thread/primitives/thread-snapshot.ts b/apps/web/src/features/email-thread/primitives/thread-snapshot.ts index c03e6e4e34c..49ec37e2039 100644 --- a/apps/web/src/features/email-thread/primitives/thread-snapshot.ts +++ b/apps/web/src/features/email-thread/primitives/thread-snapshot.ts @@ -1,5 +1,5 @@ import { createMemo } from 'solid-js'; -import type { EmailThreadSource } from '../context/email-thread-dependencies'; +import type { EmailThreadSource } from '../context/email-thread-context'; import type { EmailThread } from '../core/email-thread'; /** Keep the current thread readable during transient reloads; never show a previous thread. */ diff --git a/apps/web/src/features/email-thread/queries/thread-source.test.ts b/apps/web/src/features/email-thread/queries/thread-source.test.ts index 71c7b548ad6..ff268598c2c 100644 --- a/apps/web/src/features/email-thread/queries/thread-source.test.ts +++ b/apps/web/src/features/email-thread/queries/thread-source.test.ts @@ -47,8 +47,6 @@ describe('thread query adaptation', () => { const [id, setId] = createSignal('thread'); const [status, setStatus] = createSignal('pending'); const [data, setData] = createSignal(); - const fetchNextPage = vi.fn(); - const refetch = vi.fn(); const read = vi.fn(() => { if (status() === 'pending') throw new Error('suspending resource read'); @@ -70,8 +68,6 @@ describe('thread query adaptation', () => { get data() { return read(); }, - fetchNextPage, - refetch, } as unknown as ThreadQueryResult; const source = createEmailThreadSource(id, query); expect(source.thread()).toBeUndefined(); @@ -85,10 +81,6 @@ describe('thread query adaptation', () => { expect(source.thread()).not.toBe(original); setStatus('error'); expect(source.thread()?.db_id).toBe('thread'); - source.fetchOlder(); - source.refresh(); - expect(fetchNextPage).toHaveBeenCalledOnce(); - expect(refetch).toHaveBeenCalledOnce(); setId('other'); expect(source.thread()).toBeUndefined(); batch(() => { @@ -134,17 +126,15 @@ it('exposes cached data when the first observed query result is an error', () => it.each(['fetchOlder', 'refresh'] as const)( '%s remains pending until the query request finishes', async (operation) => { - let finish!: () => void; - const request = new Promise((resolve) => { - finish = resolve; - }); + const { promise: request, resolve: finish } = Promise.withResolvers(); const { source, dispose } = createRoot((dispose) => ({ dispose, source: createEmailThreadSource(() => 'thread', { isSuccess: false, isError: false, - fetchNextPage: () => request, - refetch: () => request, + fetchNextPage: () => + operation === 'fetchOlder' ? request : Promise.resolve(), + refetch: () => (operation === 'refresh' ? request : Promise.resolve()), } as unknown as ThreadQueryResult), })); try { diff --git a/apps/web/src/features/email-thread/queries/thread-source.ts b/apps/web/src/features/email-thread/queries/thread-source.ts index 45654436df3..7c4cce42e1d 100644 --- a/apps/web/src/features/email-thread/queries/thread-source.ts +++ b/apps/web/src/features/email-thread/queries/thread-source.ts @@ -1,7 +1,7 @@ import type { ThreadQueryData, ThreadQueryResult } from '@queries/email/thread'; import type { ApiThread } from '@service-email/generated/schemas'; import { type Accessor, createMemo } from 'solid-js'; -import type { EmailThreadSource } from '../context/email-thread-dependencies'; +import type { EmailThreadSource } from '../context/email-thread-context'; import type { EmailThread } from '../core/email-thread'; /** Project transport data onto feature-owned values; this is not runtime validation. */ diff --git a/apps/web/src/features/email-thread/tests/fixtures.ts b/apps/web/src/features/email-thread/tests/fixtures.ts index b48ef3b01e5..789e82f46f6 100644 --- a/apps/web/src/features/email-thread/tests/fixtures.ts +++ b/apps/web/src/features/email-thread/tests/fixtures.ts @@ -1,8 +1,8 @@ import type { EmailMessage } from '../../email-message/core/email-message'; import type { - EmailThreadDependencies, + EmailThreadContext, EmailThreadSource, -} from '../context/email-thread-dependencies'; +} from '../context/email-thread-context'; import type { EmailThread } from '../core/email-thread'; export { message } from '../../email-message/tests/messages'; @@ -22,14 +22,19 @@ export function thread( }; } -export function dependencies( - source: Omit & - Partial> -): EmailThreadDependencies { +export function createThreadContext( + source: Pick & Partial +): EmailThreadContext { return { source: { id: () => source.thread()?.db_id ?? 'thread', isError: () => false, + isLoading: () => false, + isFetching: () => false, + isFetchingOlder: () => false, + hasMore: () => false, + async fetchOlder() {}, + async refresh() {}, ...source, }, recipients: () => [], diff --git a/apps/web/src/features/email-thread/thread-action-adapter.tsx b/apps/web/src/features/email-thread/thread-action-adapter.tsx index db183fe5eb0..dd9262d680f 100644 --- a/apps/web/src/features/email-thread/thread-action-adapter.tsx +++ b/apps/web/src/features/email-thread/thread-action-adapter.tsx @@ -6,7 +6,7 @@ import { markSenderSignalWithToast, } from '@queries/email/thread'; import type { Accessor } from 'solid-js'; -import type { EmailThreadCommands } from './context/email-thread-dependencies'; +import type { EmailThreadCommands } from './context/email-thread-context'; import type { EmailThread } from './core/email-thread'; import { selectThreadSender } from './core/thread-messages'; import { createThreadCompletionAdapter } from './thread-completion-adapter'; diff --git a/apps/web/src/features/email-thread/thread-completion-adapter.tsx b/apps/web/src/features/email-thread/thread-completion-adapter.tsx index 40bc97faafd..c6d0c44a1a9 100644 --- a/apps/web/src/features/email-thread/thread-completion-adapter.tsx +++ b/apps/web/src/features/email-thread/thread-completion-adapter.tsx @@ -32,7 +32,7 @@ import type { Accessor } from 'solid-js'; import type { ArchiveThreadOptions, EmailThreadCommands, -} from './context/email-thread-dependencies'; +} from './context/email-thread-context'; export function createThreadCompletionAdapter( threadSource: Accessor, toHeaderLinkId: (linkId: string | null | undefined) => string | undefined diff --git a/apps/web/src/features/email-thread/thread-read-adapter.ts b/apps/web/src/features/email-thread/thread-read-adapter.ts index 4c0c0cc740f..ce3a0e914a2 100644 --- a/apps/web/src/features/email-thread/thread-read-adapter.ts +++ b/apps/web/src/features/email-thread/thread-read-adapter.ts @@ -6,7 +6,7 @@ import { import { refetchSoupEntity } from '@queries/soup/cache'; import type { Accessor } from 'solid-js'; import { createEffect, createSignal } from 'solid-js'; -import type { EmailThreadCommands } from './context/email-thread-dependencies'; +import type { EmailThreadCommands } from './context/email-thread-context'; import type { EmailThread } from './core/email-thread'; export function createThreadReadAdapter( diff --git a/apps/web/src/features/email-thread/views/bottom-reply-buttons.tsx b/apps/web/src/features/email-thread/views/bottom-reply-buttons.tsx index 229442ee5f2..93988173000 100644 --- a/apps/web/src/features/email-thread/views/bottom-reply-buttons.tsx +++ b/apps/web/src/features/email-thread/views/bottom-reply-buttons.tsx @@ -11,7 +11,7 @@ import { createCallback } from '@solid-primitives/rootless'; import { Button, cn } from '@ui'; import { type Component, Show } from 'solid-js'; import { useEmailThreadState } from '../context/email-thread-state-context'; -import { useEmailThreadEnvironment } from '../context/thread-environment'; +import { useEmailThreadViewContext } from '../context/email-thread-view-context'; import { openEmailReplyComposerForMessage } from '../primitives/reply-actions'; function ReplyActionButton(props: { @@ -20,14 +20,14 @@ function ReplyActionButton(props: { ariaLabel?: string; onClick: () => void; }) { - const environment = useEmailThreadEnvironment(); + const viewContext = useEmailThreadViewContext(); return ( ', '', ])('removes active markup before insertion: %s', (html) => { - const result = sanitizeEmailHtml(html); + const result = sanitizeEmailHtml('

Safe content

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

Safe content

'); expect(result).not.toMatch( /onerror|alert\(| { ); 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( diff --git a/packages/email-renderer/tests/rendering.pw.ts b/packages/email-renderer/tests/rendering.pw.ts index 706ea6105e5..187e1fdd58a 100644 --- a/packages/email-renderer/tests/rendering.pw.ts +++ b/packages/email-renderer/tests/rendering.pw.ts @@ -1,35 +1,35 @@ -import { readdirSync, readFileSync } from 'node:fs'; -import { expect, test } from '@playwright/test'; +import { expect, type Page, test } from '@playwright/test'; import type {} from '../viewer/main'; -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([]); +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 }) => { @@ -39,24 +39,12 @@ test.beforeEach(async ({ page }) => { test('preserves CSS recovery and native variable cascade and fallbacks', async ({ page, }) => { - await page.evaluate(() => { - const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; - const host = document.createElement('div'); - host.id = 'css-compat'; - host.style.setProperty('--gap', '9px'); - document.body.append(host); - mountEmailBody( - host, - prepareEmailBody( - { - html: '
Hidden preview

Inherited green

Fallback red

Image map footer

', - }, - { images: { remote: 'allow' } } - ), - { theme: themes.light, adaptColors: false, normalizeFonts: false } - ); - }); - const host = page.locator('#css-compat'); + 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'); @@ -86,26 +74,14 @@ test('loads protocol-relative images and retains protocol-relative navigation', await page.route('https://renderer.invalid/image', (route) => route.fulfill({ contentType: 'image/png', - body: Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=', - 'base64' - ), + body: imagePixel, }) ); - await page.evaluate(() => { - const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; - const host = document.createElement('div'); - host.id = 'url-compat'; - document.body.append(host); - mountEmailBody( - host, - prepareEmailBody({ - html: 'Open', - }), - { theme: themes.light, adaptColors: false, normalizeFonts: false } - ); - }); - const host = page.locator('#url-compat'); + const host = await mountHtml( + page, + 'url-compat', + 'Open' + ); await expect .poll(() => host @@ -122,20 +98,11 @@ test('loads protocol-relative images and retains protocol-relative navigation', test('preserves namespace, layer and scope styling in the browser', async ({ page, }) => { - await page.evaluate(() => { - const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; - const host = document.createElement('div'); - host.id = 'grouped-css'; - document.body.append(host); - mountEmailBody( - host, - prepareEmailBody({ - html: '

Styled inside scope

Outside scope

', - }), - { theme: themes.light, adaptColors: false, normalizeFonts: false } - ); - }); - const host = page.locator('#grouped-css'); + 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)' @@ -154,20 +121,12 @@ test('preserves nested and body theme rules while stripping top-level head overr page, }) => { await page.emulateMedia({ colorScheme: 'dark' }); - await page.evaluate(() => { - const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; - const host = document.createElement('div'); - host.id = 'theme-rule-scope'; - document.body.append(host); - mountEmailBody( - host, - prepareEmailBody({ - html: '

Head

Nested

Body

', - }), - { theme: themes.dark, adaptColors: false, normalizeFonts: false } - ); - }); - const host = page.locator('#theme-rule-scope'); + 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)' @@ -185,22 +144,12 @@ test('preserves nested and body theme rules while stripping top-level head overr test('keeps custom-property names consistent with selectors and container queries', async ({ page, }) => { - await page.evaluate(() => { - const { prepareEmailBody, mountEmailBody, themes } = window.emailRenderer; - const host = document.createElement('div'); - host.id = 'variable-names'; - document.body.append(host); - mountEmailBody( - host, - prepareEmailBody({ - html: '

Variable styled text

', - }), - { theme: themes.light, adaptColors: false, normalizeFonts: false } - ); - }); - const text = page - .locator('#variable-names') - .getByText('Variable styled text'); + 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'); }); @@ -213,10 +162,7 @@ test('allows image-set backgrounds explicitly and blocks strings expanded by CSS requests.push(route.request().url()); await route.fulfill({ contentType: 'image/png', - body: Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=', - 'base64' - ), + body: imagePixel, }); }); await page.evaluate(() => { 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([]); + }); + } + } +} From 71449489cc6a6ba4b7333bc736cb68741e356dd7 Mon Sep 17 00:00:00 2001 From: synoet Date: Wed, 9 Sep 2026 04:01:25 +0000 Subject: [PATCH 19/21] more cleanup --- .../core/recipient-conversion.ts | 48 +-- .../features/email-compose/core/reply-type.ts | 18 - .../primitives/attachment-persistence.test.ts | 2 +- .../primitives/attachment-persistence.ts | 13 +- .../primitives/compose-persistence.test.ts | 6 +- .../primitives/compose-view-state.ts | 12 +- .../primitives/email-composer.test.ts | 5 +- .../primitives/email-composer.ts | 9 +- .../primitives/email-form-state.ts | 76 ++-- .../primitives/reply-composer.ts | 398 +++++++----------- .../primitives/send-schedule.test.ts | 72 +++- .../email-compose/tests/capabilities.ts | 9 +- .../features/email-compose/tests/composer.ts | 14 +- .../src/features/email-compose/undo-send.ts | 2 +- .../email-compose/views/reply-input.tsx | 141 +++---- .../context/email-rendering-context.tsx | 4 +- .../features/email-message/email-message.tsx | 20 - .../primitives/email-message-body.ts | 1 - .../views/email-message-body.tsx | 11 +- .../email-message/views/email-message.tsx | 35 +- .../email-thread/core/thread-stops.ts | 2 +- .../primitives/email-thread-state.ts | 64 +-- .../primitives/scroll-to-message.ts | 5 +- apps/web/src/lib/core/email/index.ts | 5 +- docs/EMAIL_FEATURE_ARCHITECTURE.md | 13 +- packages/email-renderer/src/core/html.ts | 49 +-- 26 files changed, 396 insertions(+), 638 deletions(-) delete mode 100644 apps/web/src/features/email-message/email-message.tsx diff --git a/apps/web/src/features/email-compose/core/recipient-conversion.ts b/apps/web/src/features/email-compose/core/recipient-conversion.ts index 328a7772592..6225c656647 100644 --- a/apps/web/src/features/email-compose/core/recipient-conversion.ts +++ b/apps/web/src/features/email-compose/core/recipient-conversion.ts @@ -1,4 +1,7 @@ -import type { EmailRecipient } from '@app/features/email-compose/core/email-recipient'; +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 = ( @@ -25,14 +28,9 @@ export const convertContactInfoToEmailRecipient = ( export const getReplyAllRecipients = ( 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) { @@ -52,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: EmailMessage, - 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: 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 index 8acab4b7729..24fbe7b8f6b 100644 --- a/apps/web/src/features/email-compose/core/reply-type.ts +++ b/apps/web/src/features/email-compose/core/reply-type.ts @@ -1,19 +1 @@ -import type { EmailMessage } from '@app/features/email-message/core/email-message'; - export type ReplyType = 'reply' | 'reply-all' | 'forward'; - -export const getReplyTypeFromDraft: ( - draft: EmailMessage | undefined -) => ReplyType | undefined = (draft: EmailMessage | 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/email-compose/primitives/attachment-persistence.test.ts b/apps/web/src/features/email-compose/primitives/attachment-persistence.test.ts index fd139c7ce7c..91c09b6c746 100644 --- a/apps/web/src/features/email-compose/primitives/attachment-persistence.test.ts +++ b/apps/web/src/features/email-compose/primitives/attachment-persistence.test.ts @@ -23,7 +23,7 @@ function setup( }; const persistence = createAttachmentPersistence({ services, - attachments: () => form.attachments, + attachments: form.attachments, draftId: () => 'draft', inboxId: () => 'secondary-inbox', }); diff --git a/apps/web/src/features/email-compose/primitives/attachment-persistence.ts b/apps/web/src/features/email-compose/primitives/attachment-persistence.ts index 6e7445caa06..8d378034ccf 100644 --- a/apps/web/src/features/email-compose/primitives/attachment-persistence.ts +++ b/apps/web/src/features/email-compose/primitives/attachment-persistence.ts @@ -15,7 +15,7 @@ type AttachmentState = Pick< /** Attachment transport and completion, independent of draft/send orchestration. */ export function createAttachmentPersistence(options: { - attachments: Accessor; + attachments: AttachmentState; draftId: Accessor; inboxId: Accessor; services: Pick< @@ -31,8 +31,7 @@ export function createAttachmentPersistence(options: { return { uploading, async upload(draftId: string, inbox = { inboxId: options.inboxId() }) { - const attachments = options - .attachments() + const attachments = options.attachments .list() .filter( ( @@ -46,10 +45,8 @@ export function createAttachmentPersistence(options: { draftId: draftId, attachments: attachments.map((attachment) => attachment.file), inboxId: inbox.inboxId, - onAttachmentAdded: (file, id) => - options.attachments().assignAttachmentId(file, id), - onAttachmentUploadFailed: (file) => - options.attachments().clearAttachmentId(file), + onAttachmentAdded: options.attachments.assignAttachmentId, + onAttachmentUploadFailed: options.attachments.clearAttachmentId, }); const settled = run.then( () => undefined, @@ -67,7 +64,7 @@ export function createAttachmentPersistence(options: { if (run) await run; }, remove(attachment: DraftFormAttachment) { - const state = options.attachments(); + const state = options.attachments; if (attachment.type === 'local') state.removeByFile(attachment.file); else if (attachment.type === 'forwarded') state.removeForwarded(attachment.attachmentId); diff --git a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts index 611698ba880..703f025baee 100644 --- a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts +++ b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts @@ -151,10 +151,8 @@ it('keeps a successful send completed when navigation fails', async () => { const composeContext = createComposeContext(); const error = new Error('Navigation failed'); const root = mountEmailComposer(composeContext, { - host: { - showThread: () => { - throw error; - }, + showThread: () => { + throw error; }, }); root.edit('Send this'); diff --git a/apps/web/src/features/email-compose/primitives/compose-view-state.ts b/apps/web/src/features/email-compose/primitives/compose-view-state.ts index ff5a51b6ea1..a02d7160a75 100644 --- a/apps/web/src/features/email-compose/primitives/compose-view-state.ts +++ b/apps/web/src/features/email-compose/primitives/compose-view-state.ts @@ -1,14 +1,10 @@ -import type { EmailFormRecipients } from '../core/email-recipient'; - -export type { - EmailFormRecipients, - RecipientFieldId, -} from '../core/email-recipient'; - import type { LexicalEditor } from 'lexical'; import type { Accessor, JSX } from 'solid-js'; import type { ComposeBodyActions } from '../context/editor-capabilities'; -import type { EmailRecipient } from '../core/email-recipient'; +import type { + EmailFormRecipients, + EmailRecipient, +} from '../core/email-recipient'; import type { DraftFormAttachment } from './email-form-state'; export type ComposeValidationError = { diff --git a/apps/web/src/features/email-compose/primitives/email-composer.test.ts b/apps/web/src/features/email-compose/primitives/email-composer.test.ts index 9bd191c8f25..7e388b1e957 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.test.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.test.ts @@ -79,9 +79,8 @@ describe('standalone compose controller', () => { it('blocks immediate send and overlapping changes while a scheduling request is pending', async () => { const pending = Promise.withResolvers(); - const context = createComposeContext({ - delivery: { schedule: vi.fn(() => pending.promise) }, - }); + const context = createComposeContext(); + vi.mocked(context.delivery.schedule).mockReturnValue(pending.promise); const root = mountEmailComposer(context); try { root.edit('Schedule this reply', 'Schedule review'); diff --git a/apps/web/src/features/email-compose/primitives/email-composer.ts b/apps/web/src/features/email-compose/primitives/email-composer.ts index 8d1a6b05058..6e9c77e2de2 100644 --- a/apps/web/src/features/email-compose/primitives/email-composer.ts +++ b/apps/web/src/features/email-compose/primitives/email-composer.ts @@ -141,7 +141,7 @@ export function createEmailComposer(props: EmailComposerOptions) { const attachmentPersistence = createAttachmentPersistence({ services: props.attachmentStorage, - attachments: () => form.attachments, + attachments: form.attachments, draftId: currentDraftId, inboxId: activeInboxId, }); @@ -266,7 +266,6 @@ export function createEmailComposer(props: EmailComposerOptions) { persist: ({ draft, inboxId }) => persistDraft(draft, inboxId), paused: persistencePaused, }); - const executeSaveDraft = () => autosave.save(); const markDirtyAndScheduleSave = () => { if (persistencePaused()) return; setDraftDirty(true); @@ -432,7 +431,7 @@ export function createEmailComposer(props: EmailComposerOptions) { // draft id to snapshot and restore (the send reuses the draft's db_id). autosave.cancel(); try { - await executeSaveDraft(); + await autosave.save(); } catch { // Draft save is best-effort; the send still works without one. } @@ -522,7 +521,7 @@ export function createEmailComposer(props: EmailComposerOptions) { delivery: props.delivery, notices: props.notices, draftId: currentDraftId, - saveDraft: executeSaveDraft, + saveDraft: autosave.save, threadId: currentThreadId, inboxId: activeInboxId, sendTime: form.sendTime, @@ -690,7 +689,7 @@ export function createEmailComposer(props: EmailComposerOptions) { form.setSelectedInbox(inboxId); setDraftDirty(true); autosave.cancel(); - void executeSaveDraft().catch(() => {}); + void autosave.save().catch(() => {}); }, hasPaidAccess, }; diff --git a/apps/web/src/features/email-compose/primitives/email-form-state.ts b/apps/web/src/features/email-compose/primitives/email-form-state.ts index 784003cdf89..a9586f1b25a 100644 --- a/apps/web/src/features/email-compose/primitives/email-form-state.ts +++ b/apps/web/src/features/email-compose/primitives/email-form-state.ts @@ -1,9 +1,6 @@ import type { EmailFormRecipients } from '../core/email-recipient'; -export type { - EmailFormRecipients, - RecipientFieldId, -} 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'; @@ -48,15 +45,10 @@ export interface EmailFormStateOptions { } type EmailFormState = { - recipients: { - to: EmailRecipient[]; - cc: EmailRecipient[]; - bcc: EmailRecipient[]; - }; + recipients: EmailFormRecipients; replyType: ReplyType; withQuotedText: boolean; subject: string; - markdownBody: string; sendTime?: Date; }; @@ -69,7 +61,6 @@ const EMPTY_FORM_STATE: EmailFormState = { replyType: 'reply-all', withQuotedText: false, subject: '', - markdownBody: '', }; /** @@ -91,7 +82,7 @@ export function createEmailFormState( let replyingTo: EmailMessage | undefined; if (purpose?.type === 'replying_to') { - replyingTo = options?.getMessageById?.(purpose.messageId); + replyingTo = options?.getMessageById(purpose.messageId); } let draft: EmailMessage | undefined; @@ -99,7 +90,7 @@ export function createEmailFormState( if (purpose?.type === 'draft') { draft = options?.getMessageById(purpose.messageId); } else if (purpose?.type === 'replying_to') { - draft = options?.getDraftForMessageReply(purpose?.messageId); + draft = options?.getDraftForMessageReply(purpose.messageId); } // The inbox this compose sends from. Defaults to the inbox that owns the @@ -145,9 +136,9 @@ export function createEmailFormState( if (draft) { initialRecipients = { - to: draft.to.map(convertContactInfoToEmailRecipient) ?? [], - cc: draft.cc.map(convertContactInfoToEmailRecipient) ?? [], - bcc: draft.bcc.map(convertContactInfoToEmailRecipient) ?? [], + to: draft.to.map(convertContactInfoToEmailRecipient), + cc: draft.cc.map(convertContactInfoToEmailRecipient), + bcc: draft.bcc.map(convertContactInfoToEmailRecipient), }; } else if (replyingTo) { initialRecipients = @@ -161,22 +152,17 @@ export function createEmailFormState( 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 [state, setState] = createStore(getInitialState()); // Values and edit revisions may outlive a mounted composer; effects do not. const [editRevision, setEditRevision] = createSignal(0); - // 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, @@ -214,7 +200,6 @@ export function createEmailFormState( const setReplyType = (next: ReplyType) => { setState('replyType', next); - const rt = state.replyType; const msg = replyingTo; // Clear forwarded attachments when switching away from forward @@ -223,7 +208,7 @@ export function createEmailFormState( if (msg) { let calculated: EmailFormRecipients = { to: [], cc: [], bcc: [] }; - switch (rt) { + switch (next) { case 'reply-all': { calculated = getReplyAllRecipients(msg, inboxEmail()); break; @@ -233,13 +218,13 @@ export function createEmailFormState( } } - setRecipients('to', calculated.to ?? []); - setRecipients('cc', calculated.cc ?? []); - setRecipients('bcc', calculated.bcc ?? []); + setRecipients('to', calculated.to); + setRecipients('cc', calculated.cc); + setRecipients('bcc', calculated.bcc); - setSubject(getSubjectText(msg, rt)); + setSubject(getSubjectText(msg, next)); - if (rt === 'forward') { + if (next === 'forward') { setState('withQuotedText', true); // Populate forwarded attachments from original message (skip inline images) const fwdAttachments: DraftFormAttachment[] = (msg.attachments ?? []) @@ -256,7 +241,7 @@ export function createEmailFormState( } callDirty(); - return rt; + return next; }; // Change the inbox this compose sends from. For an active reply, re-derive the @@ -268,9 +253,9 @@ export function createEmailFormState( state.replyType === 'reply-all' ? getReplyAllRecipients(replyingTo, inboxEmail()) : getReplyRecipientsFromParent(replyingTo, inboxEmail()); - setRecipients('to', recalculated.to ?? []); - setRecipients('cc', recalculated.cc ?? []); - setRecipients('bcc', recalculated.bcc ?? []); + setRecipients('to', recalculated.to); + setRecipients('cc', recalculated.cc); + setRecipients('bcc', recalculated.bcc); }; const setSendTime = (date: Date | null) => { @@ -281,19 +266,8 @@ export function createEmailFormState( setEditRevision((revision) => revision + 1); }; - 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)); - - setAttachments([]); - }; - - const clear = () => { - setState(reconcile({ ...EMPTY_FORM_STATE })); + const reset = (next: EmailFormState) => { + setState(reconcile(next)); const recipients = state.recipients; // Notify context of the full recipient list after reset @@ -303,7 +277,7 @@ export function createEmailFormState( setAttachments([]); }; - const value = { + return { draft, replyAppended: () => state.withQuotedText, setReplyAppended: (next: boolean) => setState('withQuotedText', next), @@ -313,13 +287,13 @@ export function createEmailFormState( setSubject, replyType: () => state.replyType, setReplyType, - selectedInboxId: () => selectedInboxId(), + selectedInboxId, setSelectedInbox, editRevision, sendTime: () => state.sendTime, setSendTime, - reset, - clear, + reset: () => reset(getInitialState()), + clear: () => reset({ ...EMPTY_FORM_STATE }), attachments: { list: attachments, add: (attachment: DraftFormAttachment) => { @@ -362,6 +336,4 @@ export function createEmailFormState( }, }, }; - - return value; } diff --git a/apps/web/src/features/email-compose/primitives/reply-composer.ts b/apps/web/src/features/email-compose/primitives/reply-composer.ts index 009a86d1ab0..8c14c63278a 100644 --- a/apps/web/src/features/email-compose/primitives/reply-composer.ts +++ b/apps/web/src/features/email-compose/primitives/reply-composer.ts @@ -3,29 +3,8 @@ import { MAX_ATTACHMENTS_BYTES_SIZE, } from '@app/features/email-compose/core/constants'; import type { EmailMessage } from '@app/features/email-message/core/email-message'; -import type { - EmailAttachmentStorage, - EmailComposeAccounts, - EmailComposeFeedback, - EmailDelivery, - EmailDraftStorage, - EmailUndoHandle, - PersistedEmailIdentity, -} from '../context/compose-capabilities'; -import type { EmailReplySession } from '../context/email-form-inputs'; -import type { EmailDraft } from '../core/email-draft'; -import { createAttachmentPersistence } from './attachment-persistence'; -import { createDraftAutosave } from './draft-autosave'; -import { createEmailSendSchedule } from './email-send-schedule'; -import { createReplyComposerFocus } from './reply-composer-focus'; -import { createReplyRecipientFields } from './reply-recipient-fields'; -import { createEmailUndoStore } from './undo-store'; - -type EmailDraftId = string | null; - 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 { @@ -45,13 +24,25 @@ import { 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 { getReplyTypeFromDraft } from '../core/reply-type'; +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, @@ -60,7 +51,10 @@ import { 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; @@ -82,7 +76,6 @@ type UndoReplySnapshot = { const replyUndo = createEmailUndoStore(); export type ReplyComposerOptions = { - newMessage?: boolean; drafts: EmailDraftStorage; attachmentStorage: EmailAttachmentStorage; delivery: EmailDelivery; @@ -97,7 +90,6 @@ export type ReplyComposerOptions = { replyingTo: Accessor; isEditingExisting?: boolean; draft?: EmailMessage; - 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 @@ -108,9 +100,7 @@ export type ReplyComposerOptions = { * undo-send restore. The parent latches the seed key on it so the input * stops remounting on later draft versions. */ onEngaged?: () => void; - sideEffectOnSend?: ( - newMessageId: EmailDraftId | null - ) => void | Promise; + sideEffectOnSend?: (newMessageId: string | null) => void | Promise; onMarkDone?: (opts?: { silent?: boolean; onUndoHandle?: (handle: EmailUndoHandle) => void; @@ -138,7 +128,7 @@ export function createReplyComposer( const current = ctx.thread(); return current?.db_id === initialThread?.db_id ? current : initialThread; }; - const formState = forms( + const form = forms( replyTarget?.db_id ? { type: 'replying_to', @@ -153,7 +143,6 @@ export function createReplyComposer( } : undefined ); - const form = () => formState; const sourceEntityId = props.sourceEntityId; const undoKey = `${sourceEntityId}:${replyTarget?.db_id ?? draftSeed?.replying_to_id ?? draftSeed?.db_id ?? 'new'}`; const userEmail = props.viewerEmail; @@ -161,7 +150,7 @@ export function createReplyComposer( const primaryInboxId = props.accounts.primaryId; // Capture this domain inbox ID for each asynchronous operation. const activeInboxId = () => - form().selectedInboxId() ?? + form.selectedInboxId() ?? thread()?.link_id ?? draftSeed?.link_id ?? primaryInboxId() ?? @@ -200,11 +189,11 @@ export function createReplyComposer( // 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() + !form.replyAppended() ); const recipients = createReplyRecipientFields({ - values: () => form().recipients(), - setValues: (field, values) => form().setRecipients(field, values), + values: form.recipients, + setValues: form.setRecipients, onChange: scheduleDraftSave, container: dom.container, }); @@ -242,10 +231,10 @@ export function createReplyComposer( // 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); + form.setSelectedInbox(snapshot.inboxId); + form.setSubject(snapshot.draftRestore.subject); for (const field of ['to', 'cc', 'bcc'] as const) { - form().setRecipients( + form.setRecipients( field, (snapshot.draftRestore[field] ?? []).map( convertContactInfoToEmailRecipient @@ -259,10 +248,10 @@ export function createReplyComposer( // Restored content is local state worth keeping — latch the seed. props.onEngaged?.(); for (const attachment of restoredSnapshot.attachments) { - form().attachments.add(attachment); + form.attachments.add(attachment); } setIncludeSignature(restoredSnapshot.includeSignature); - form().setReplyAppended(restoredSnapshot.replyAppended); + form.setReplyAppended(restoredSnapshot.replyAppended); // Reopen with the quote visible, as it was when the send was undone. if (restoredSnapshot.replyAppended) setQuoteCollapsed(false); }); @@ -280,10 +269,10 @@ export function createReplyComposer( setEditorStateFromHtml(currentEditor, snapshot.bodyHtml); } for (const attachment of snapshot.attachments) { - form().attachments.add(attachment); + form.attachments.add(attachment); } setIncludeSignature(snapshot.includeSignature); - form().setReplyAppended(snapshot.replyAppended); + form.setReplyAppended(snapshot.replyAppended); // Reopen with the quote visible, as it was when the send was undone. if (snapshot.replyAppended) setQuoteCollapsed(false); }; @@ -310,18 +299,9 @@ export function createReplyComposer( setEditorConnected(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: EmailUndoHandle | 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. + // server-side draft and the composer. const restoreAfterUndoSend = async ( draftId: string, sentThreadId: string | undefined, @@ -345,116 +325,22 @@ export function createReplyComposer( setTimeout(() => replyUndo.restore(undoKey, snapshot), 0); 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: () => - props.notices.feedback.failure('Failed to restore thread to inbox'), - }); - } - }; - - // Undo retains the sending inbox and thread after navigation disposes the view. - 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 - ) => { - autosave.cancel(); - const draftId = identity.draftId; - 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, identity.threadId, inboxId).catch( - props.notices.reportError - ); - }, - }, - ] - : undefined, - duration: 5_000, - }); - } catch (error) { - props.notices.reportError(error); - } - const mentions = pendingMentions; - pendingMentions = []; - for (const mention of mentions) { - try { - props.recordMention(sourceEntityId, mention.documentId); - } catch (error) { - props.notices.reportError(error); - } - } - if (shouldMarkDoneOnSuccess()) { - try { - props.onMarkDone?.({ - silent: true, - onUndoHandle: (handle) => { - markDoneUndoHandle = handle; - }, - nextEntityId: pendingMarkDoneNavigationTargetId, - }); - } catch (error) { - props.notices.reportError(error); - props.notices.feedback.failure( - 'Email sent, but unable to mark thread done' - ); - } finally { - pendingMarkDoneNavigationTargetId = undefined; - setShouldMarkDoneOnSuccess(false); - } - } - 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); - } }; const attachmentPersistence = createAttachmentPersistence({ services: props.attachmentStorage, - attachments: () => form().attachments, + attachments: form.attachments, draftId: savedDraftId, inboxId: activeInboxId, }); createEffect( - on( - () => form().editRevision(), - () => scheduleDraftSave(), - { defer: true } - ) + on(form.editRevision, () => scheduleDraftSave(), { defer: true }) ); // The mounted composer owns focus, editor commands, and their cleanup. createEffect(() => { - const rt = form().replyType(); + const rt = form.replyType(); if (!editorConnected()) return; untrack(() => { setComposerExpanded(false); @@ -463,7 +349,7 @@ export function createReplyComposer( focus.forward(); const message = replyTarget; const currentEditor = editor(); - if (message && currentEditor && form().replyAppended()) { + if (message && currentEditor && form.replyAppended()) { // The editor's lazy command registration completes after this batch. const timer = setTimeout( () => @@ -486,16 +372,6 @@ export function createReplyComposer( }); }); - const effectiveReplyType = createMemo(() => { - return ( - form().replyType() ?? - getReplyTypeFromDraft(draftSeed) ?? - ((replyTarget?.to.length ?? 0) + (replyTarget?.cc.length ?? 0) > 1 - ? 'reply-all' - : 'reply') - ); - }); - const [sendPhase, setSendPhase] = createSignal< 'idle' | 'preparing' | 'sending' >('idle'); @@ -515,21 +391,21 @@ export function createReplyComposer( if ( !hasDraftContent( prepared.bodyText, - form().subject(), - form().attachments.list().length + 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), + bcc: form.recipients().bcc.map(convertEmailRecipientToContactInfo), body_html: prepared.bodyHtml, - cc: form().recipients().cc.map(convertEmailRecipientToContactInfo), + 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), + subject: form.subject(), + to: form.recipients().to.map(convertEmailRecipientToContactInfo), }; } @@ -558,30 +434,19 @@ export function createReplyComposer( setSavedDraft(undefined); return; } - const newMessage = props.newMessage ?? false; - - if (!currentThread && !newMessage) { + if (!currentThread) { props.notices.reportError( new Error('Failed to save draft: thread not found') ); return; } - if (newMessage && currentThread) { - props.notices.reportError( - new Error( - 'Failed to save draft: new message and current thread cannot be provided together' - ) - ); - return; - } - const draftResponse = await props.drafts.saveDraft({ draft: { ...draftToSave, db_id: savedDraftId(), - provider_thread_id: currentThread?.provider_id, - thread_db_id: currentThread?.db_id, + provider_thread_id: currentThread.provider_id, + thread_db_id: currentThread.db_id, }, inboxId, completingThread, @@ -596,8 +461,8 @@ export function createReplyComposer( }); await attachmentPersistence.upload(draftId, { inboxId }); - const forwarded = form() - .attachments.list() + const forwarded = form.attachments + .list() .filter((attachment) => attachment.type === 'forwarded'); if (forwarded.length) { await props.attachmentStorage.addForwardedAttachments({ @@ -633,7 +498,7 @@ export function createReplyComposer( const persistDraftOnSenderSwitch = (inboxId: string) => { if (submitting() || pendingDeletion || scheduling()) return; props.onEngaged?.(); - form().setSelectedInbox(inboxId); + form.setSelectedInbox(inboxId); autosave.cancel(); void executeSaveDraft().catch(() => {}); }; @@ -643,8 +508,8 @@ export function createReplyComposer( if (!requestReplyType) return; - if (form().replyType() !== requestReplyType) { - form().setReplyType(requestReplyType); + 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 @@ -675,9 +540,9 @@ export function createReplyComposer( 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); + 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( @@ -687,9 +552,7 @@ export function createReplyComposer( } const currentThread = thread(); - const newMessage = props.newMessage ?? false; - - if (!currentThread && !newMessage) { + if (!currentThread) { props.notices.reportError( new Error("Can't send email, no email thread found") ); @@ -697,16 +560,8 @@ export function createReplyComposer( return; } - if (newMessage && currentThread) { - props.notices.feedback.failure('Email failed to send'); - props.notices.reportError( - 'New message and thread cannot be provided together' - ); - return; - } - let inboxId = activeInboxId(); - if (newMessage || !inboxId) { + if (!inboxId) { if (props.accounts.loading()) { props.notices.feedback.alert('Loading email accounts...'); return; @@ -720,15 +575,15 @@ export function createReplyComposer( return; } - const linksData = { links: props.accounts.inboxes() }; - if (!linksData || linksData.links.length < 1) { + 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() ?? linksData.links[0].id; + inboxId = primaryInboxId() ?? inboxes[0].id; } const currentEditor = editor(); @@ -736,8 +591,8 @@ export function createReplyComposer( // 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 + const willMarkDone = markDone || currentThread.inbox_visible; + const nextEntityId = willMarkDone ? ctx.getMarkDoneNavigationTargetId() : undefined; @@ -761,17 +616,17 @@ export function createReplyComposer( inboxId, draftId: snapshotDraftId, bodyHtml: snapshotHtml, - attachments: [...form().attachments.list()], + attachments: [...form.attachments.list()], includeSignature: includeSignature(), - replyAppended: form().replyAppended(), + replyAppended: form.replyAppended(), draftRestore: { bcc, cc, db_id: snapshotDraftId, provider_id: draftSeed?.provider_id, - provider_thread_id: currentThread?.provider_id, + provider_thread_id: currentThread.provider_id, replying_to_id: replyTarget?.db_id, - subject: form().subject(), + subject: form.subject(), thread_db_id: snapshotThreadId, to, }, @@ -780,7 +635,7 @@ export function createReplyComposer( } // Scheduling may have started while the draft save was pending. - if (scheduling() || form().sendTime()) { + if (scheduling() || form.sendTime()) { return; } @@ -797,7 +652,7 @@ export function createReplyComposer( currentEditor, replyingTo ? { - replyType: effectiveReplyType(), + replyType: form.replyType(), replyingTo, } : undefined @@ -807,10 +662,6 @@ export function createReplyComposer( return; } - pendingMentions = prepared.mentions; - setShouldMarkDoneOnSuccess(willMarkDone); - markDoneUndoHandle = undefined; - const processedMacroBody = prepareMacroBody(bodyMacro()); const currentDraftId = savedDraftId(); @@ -825,10 +676,10 @@ export function createReplyComposer( body_text: prepared.bodyText, cc, provider_id: draftSeed?.provider_id, - provider_thread_id: currentThread?.provider_id, + provider_thread_id: currentThread.provider_id, replying_to_id: replyTarget?.db_id, - subject: form().subject(), - thread_db_id: currentThread?.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. @@ -852,8 +703,6 @@ export function createReplyComposer( result = await pendingSend; } catch (error) { autosave.cancel(); - pendingMarkDoneNavigationTargetId = undefined; - setShouldMarkDoneOnSuccess(false); if (mounted && currentDraftId) { const snapshot = replyUndo.peek(currentDraftId); if (snapshot) restoreMountedReply(snapshot); @@ -862,7 +711,79 @@ export function createReplyComposer( props.notices.feedback.failure('Failed to send email'); return; } - afterSend(result, inboxId); + 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 { @@ -874,7 +795,7 @@ export function createReplyComposer( clearEmailBody(editor()); setBodyMacro(''); setSavedDraft(undefined); - form().reset(); + form.reset(); }; const clearDraftState = () => { @@ -898,7 +819,7 @@ export function createReplyComposer( }); } resetState(); - form().setReplyAppended(false); + form.setReplyAppended(false); clearDraftState(); } finally { // Yield past any sync/microtask save scheduling triggered by resetState, @@ -916,10 +837,10 @@ export function createReplyComposer( addUserMentionToCc({ mention, recipientOptions: ctx.recipientOptions(), - toRecipients: form().recipients().to, - ccRecipients: form().recipients().cc, - bccRecipients: form().recipients().bcc, - setCc: (next) => form().setRecipients('cc', next), + 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`); }, @@ -927,7 +848,7 @@ export function createReplyComposer( }; const handleAddAttachments = (files: File[]) => { - const currentAttachments = form().attachments.list(); + const currentAttachments = form.attachments.list(); const attachmentsToAddByteSize = files.reduce((sum, f) => sum + f.size, 0); @@ -954,7 +875,7 @@ export function createReplyComposer( } for (const file of files) { - form().attachments.add({ + form.attachments.add({ type: 'local', file, }); @@ -972,10 +893,10 @@ export function createReplyComposer( saveDraft: executeSaveDraft, threadId: savedDraftThreadId, inboxId: activeInboxId, - sendTime: () => form().sendTime(), - setSendTime: (date) => form().setSendTime(date), + sendTime: form.sendTime, + setSendTime: form.setSendTime, recipientCount: () => { - const recipients = form().recipients(); + const recipients = form.recipients(); return ( recipients.to.length + recipients.cc.length + recipients.bcc.length ); @@ -992,25 +913,25 @@ export function createReplyComposer( submitting() || scheduling() || attachmentPersistence.uploading() || - !!form().sendTime(); + !!form.sendTime(); const scheduleSendDisabled = () => scheduleBlocked() || scheduling() || - (form().recipients().to.length === 0 && - form().recipients().cc.length === 0 && - form().recipients().bcc.length === 0); + (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); + 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(), + replyType: form.replyType(), visible: !currentlyAppended, isPersonal: ctx.isPersonalReply(), }); @@ -1027,7 +948,7 @@ export function createReplyComposer( form, activeInboxId, activeInboxEmail, - replyType: effectiveReplyType, + replyType: form.replyType, signatureHtml: replySignatureHtml, setIncludeSignature, setScrollContainer, @@ -1036,7 +957,6 @@ export function createReplyComposer( quoteCollapsed, setQuoteCollapsed, savedDraftId, - initialHtml, handleEditorConnect, isSending: submitting, isUploading: attachmentPersistence.uploading, diff --git a/apps/web/src/features/email-compose/primitives/send-schedule.test.ts b/apps/web/src/features/email-compose/primitives/send-schedule.test.ts index d428394ed23..417c930d448 100644 --- a/apps/web/src/features/email-compose/primitives/send-schedule.test.ts +++ b/apps/web/src/features/email-compose/primitives/send-schedule.test.ts @@ -90,6 +90,55 @@ function composer( } describe('send and schedule ordering', () => { + 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(); @@ -168,11 +217,11 @@ describe('send and schedule ordering', () => { first.dispose(); const newer = replyComposer(composeContext); try { - newer.form().setSubject('New subject'); + newer.form.setSubject('New subject'); newer.edit('Newer reply'); reject(new Error('Offline')); await send; - expect(newer.form().subject()).toBe('New subject'); + expect(newer.form.subject()).toBe('New subject'); expect(decodeBase64Utf8(newer.collectDraft()?.body_html ?? '')).toContain( 'Newer reply' ); @@ -276,14 +325,14 @@ describe('send and schedule ordering', () => { ).mockReturnValueOnce(uploading); const state = replyComposer(composeContext); try { - state.form().setSelectedInbox('secondary'); + 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'); + state.form.setSelectedInbox('inbox'); expect(composeContext.delivery.schedule).not.toHaveBeenCalled(); finish(); await scheduling; @@ -381,7 +430,7 @@ describe('send and schedule ordering', () => { mimeType: 'text/plain', fileSize: 10, }; - state.form().attachments.add(attachment); + state.form.attachments.add(attachment); state.edit('Forwarding'); await vi.advanceTimersByTimeAsync(500); state.handleRemoveAttachment(attachment); @@ -443,9 +492,9 @@ describe('send and schedule ordering', () => { 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', [ + state.form.setSelectedInbox('secondary'); + state.form.setSubject('Custom reply subject'); + state.form.setRecipients('cc', [ { kind: 'custom', id: 'reviewer@example.com', @@ -564,11 +613,8 @@ describe('send and schedule ordering', () => { Promise.withResolvers(); const { promise: scheduled, resolve: finishScheduling } = Promise.withResolvers(); - const composeContext = createComposeContext({ - delivery: { - schedule: vi.fn(() => scheduled), - }, - }); + const composeContext = createComposeContext(); + vi.mocked(composeContext.delivery.schedule).mockReturnValue(scheduled); vi.mocked(composeContext.drafts.saveDraft).mockImplementationOnce( () => saving ); diff --git a/apps/web/src/features/email-compose/tests/capabilities.ts b/apps/web/src/features/email-compose/tests/capabilities.ts index c74f981e7f5..8178f24d53c 100644 --- a/apps/web/src/features/email-compose/tests/capabilities.ts +++ b/apps/web/src/features/email-compose/tests/capabilities.ts @@ -1,12 +1,7 @@ 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( - overrides: { - drafts?: Partial; - delivery?: Partial; - } = {} -): EmailComposeContext { +export function createComposeContext(): EmailComposeContext { return { recipientName: (id) => id, recordMention: vi.fn(), @@ -52,7 +47,6 @@ export function createComposeContext( })), deleteDraft: vi.fn(async () => {}), restoreDraft: vi.fn(async () => {}), - ...overrides.drafts, }, delivery: { sendMessage: vi.fn(async () => ({ @@ -64,7 +58,6 @@ export function createComposeContext( schedule: vi.fn(async () => {}), archive: vi.fn(async () => {}), undoSend: vi.fn(async () => {}), - ...overrides.delivery, }, attachmentStorage: { uploadAttachments: 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 index 151d407fc0f..5dec24511fd 100644 --- a/apps/web/src/features/email-compose/tests/composer.ts +++ b/apps/web/src/features/email-compose/tests/composer.ts @@ -1,22 +1,22 @@ import { createRoot } from 'solid-js'; -import type { EmailComposeContext } from '../context/compose-capabilities'; -import { - createEmailComposer, - type EmailComposerOptions, -} from '../primitives/email-composer'; +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, - options: Pick = {} + host?: EmailComposeHost ) { const root = createRoot((dispose) => ({ dispose, state: createEmailComposer({ ...context, initialTo: ['colleague@example.com'], - ...options, + host, }), })); const editor = createEmailEditor(); diff --git a/apps/web/src/features/email-compose/undo-send.ts b/apps/web/src/features/email-compose/undo-send.ts index 2351d0d6dca..8eb2585c00c 100644 --- a/apps/web/src/features/email-compose/undo-send.ts +++ b/apps/web/src/features/email-compose/undo-send.ts @@ -27,7 +27,7 @@ import { endUndoSend, tryBeginUndoSend } from './primitives/undo-send-claim'; * 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 ) { diff --git a/apps/web/src/features/email-compose/views/reply-input.tsx b/apps/web/src/features/email-compose/views/reply-input.tsx index 861cd502bec..8a6550c6302 100644 --- a/apps/web/src/features/email-compose/views/reply-input.tsx +++ b/apps/web/src/features/email-compose/views/reply-input.tsx @@ -3,7 +3,6 @@ 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 type { UserMentionRecord } from '@core/component/LexicalMarkdown/utils/mentionsUtils'; import { fileFolderDrop } from '@core/directive/fileFolderDrop'; import { fileSelector } from '@core/directive/fileSelector'; import { registerHotkey, useHotkeyDOMScope } from '@core/hotkey/hotkeys'; @@ -18,7 +17,7 @@ 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 { type Accessor, createSignal, For, onMount, Show } from 'solid-js'; +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'; @@ -30,56 +29,11 @@ import { ReplyEnvelope } from './reply-envelope'; false && fileFolderDrop; false && fileSelector; -function createConfiguredEmailMarkdownEditor(options: ReplyEditorOptions) { - 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; -} - import { createReplyComposer, type ReplyComposerOptions, } from '../primitives/reply-composer'; -type ReplyEditorOptions = { - namespace: string; - onChange?: (markdown: string) => void; - onUserMention?: (mention: UserMentionRecord) => void; - onDocumentMention?: (item: { id: string }) => void; - onPasteFilesAndDirs?: ( - files: FileSystemFileEntry[], - directories: FileSystemDirectoryEntry[] - ) => void; - scrollContainer?: Accessor; -}; type ReplyInputViewProps = Omit< ReplyComposerOptions, | 'drafts' @@ -115,13 +69,11 @@ export function ReplyInputView(props: ReplyInputViewProps) { hasPaidAccess: composeContext.hasPaidAccess, recordMention: composeContext.recordMention, focusAfterReplyRequest: () => !composeContext.presentation.isTouch(), - newMessage: props.newMessage, session: props.session, sourceEntityId: props.sourceEntityId, replyingTo: props.replyingTo, isEditingExisting: props.isEditingExisting, draft: props.draft, - preloadedBody: props.preloadedBody, preloadedHtml: props.preloadedHtml, formSeed: props.formSeed, onEngaged: props.onEngaged, @@ -144,7 +96,6 @@ export function ReplyInputView(props: ReplyInputViewProps) { quoteCollapsed, setQuoteCollapsed, savedDraftId, - initialHtml, handleEditorConnect, isSending, isUploading, @@ -178,28 +129,45 @@ export function ReplyInputView(props: ReplyInputViewProps) { 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 = createConfiguredEmailMarkdownEditor({ - namespace: 'email-base-input-markdown', - scrollContainer: state.scrollContainer, - onChange: state.onContentChange, - onUserMention: state.handleUserMention, - onDocumentMention: (item) => { - composeContext.editorFiles.makePublic(item.id); - scheduleDraftSave(); - }, - onPasteFilesAndDirs: (files, directories) => { - composeContext.editorFiles.uploadEditorFiles({ - editor: editor(), - sourceId: props.sourceEntityId, - files, - directories, - onUploaded: (ids) => { - ids.forEach(composeContext.editorFiles.makePublic); - scheduleDraftSave(); - }, - }); - }, - }); + 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 @@ -216,7 +184,7 @@ export function ReplyInputView(props: ReplyInputViewProps) { scopeId: composeHotkeyScope, description: 'Send email', keyDownHandler: () => { - if (form().sendTime()) return false; + if (form.sendTime()) return false; sendEmail(); return true; }, @@ -230,7 +198,7 @@ export function ReplyInputView(props: ReplyInputViewProps) { scopeId: composeHotkeyScope, description: 'Send and mark done', keyDownHandler: () => { - if (form().sendTime()) return false; + if (form.sendTime()) return false; sendEmail(true); return true; }, @@ -285,14 +253,14 @@ export function ReplyInputView(props: ReplyInputViewProps) { }); const AttachmentsRow = (rowProps?: { class?: string }) => ( - 0}> + 0}>
- + {(attachment) => ( form().recipients()} + values={form.recipients} options={props.session.recipientOptions} inboxes={composeContext.accounts.inboxes} activeInboxId={activeInboxId} senderEmail={activeInboxEmail} onSenderChange={persistDraftOnSenderSwitch} - subject={() => form().subject()} + subject={form.subject} onSubjectChange={(subject) => { - form().setSubject(subject); + form.setSubject(subject); scheduleDraftSave(); }} - showSubject={!!(props.isEditingExisting || props.newMessage)} + showSubject={!!props.isEditingExisting} mobile={isMobileDrawer} portalScope={composePortalScope} replyType={state.replyType} @@ -472,7 +440,6 @@ export function ReplyInputView(props: ReplyInputViewProps) { isDragging() && 'blur' )} disabled={isSending()} - initialValue={initialHtml() ? undefined : props.preloadedBody} placeholder={ isMobileDrawer() ? 'Use `@` to reference files' @@ -515,7 +482,7 @@ export function ReplyInputView(props: ReplyInputViewProps) { {/* 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. */} - +

Ug=KN<%8xaE?}oQ| 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8d18bf41f0bccc9113ec686d895812bd32a6a344 GIT binary patch literal 12723 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..225e1cc5987a73b4317b88cdb5f5bc6bca995aeb GIT binary patch literal 11006 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 KqC)J4|NjFklrqx* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1829de91333e738ee5b5f31264ecffb4f8c71015 GIT binary patch literal 11026 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e73134630d32c39fc2ff72f8528cf1d2eddc6961 GIT binary patch literal 1982 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 E8>utDG5`Po literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a6bfa3dba3e9fe0cfa15bdc4d256f858382fd142 GIT binary patch literal 3230 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 = { + 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[]; +} +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: !prepared.hasTable, + 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'], + }, +}); From 6a319a66093306dab1bd04f7f87804ab4641f640 Mon Sep 17 00:00:00 2001 From: synoet Date: Sun, 6 Sep 2026 19:10:27 +0000 Subject: [PATCH 04/21] fix(web): restore email compatibility after extraction --- .../primitives/prepare-email-body.test.ts | 58 ++++ .../attachment-action-adapter.ts | 2 +- .../primitives/email-message-body.test.ts | 56 +-- .../primitives/email-message-body.ts | 10 +- .../views/email-message-body.tsx | 7 + .../email-thread/core/thread-messages.test.ts | 28 +- .../email-thread/core/thread-messages.ts | 9 + .../email-thread/thread-action-adapter.tsx | 30 +- apps/web/src/lib/core/email/index.ts | 1 - docs/AGENT_GUIDE/surfaces.md | 6 +- docs/EMAIL_FEATURE_ARCHITECTURE.md | 91 +++-- docs/EMAIL_REFACTOR_VERIFICATION.md | 204 +++++++++++ packages/email-renderer/README.md | 63 +++- packages/email-renderer/src/browser/colors.ts | 2 +- packages/email-renderer/src/browser/index.ts | 2 +- .../email-renderer/src/browser/renderer.ts | 43 ++- packages/email-renderer/src/core/css.ts | 99 +++++- packages/email-renderer/src/core/html.test.ts | 196 +++++++++-- packages/email-renderer/src/core/html.ts | 106 ++++-- packages/email-renderer/src/core/index.ts | 8 +- .../src/core/resource-policy.ts | 14 +- .../fixtures/personal-calendar-response.json | 7 + .../tests/fixtures/personal-letter.json | 7 + packages/email-renderer/tests/rendering.pw.ts | 325 ++++++++++++++++++ .../personal-calendar-response-dark-600.png | Bin 0 -> 23410 bytes .../personal-calendar-response-light-600.png | Bin 0 -> 23367 bytes .../snapshots/personal-letter-dark-600.png | Bin 0 -> 12671 bytes .../snapshots/personal-letter-light-600.png | Bin 0 -> 12636 bytes packages/email-renderer/viewer/main.ts | 6 +- 29 files changed, 1154 insertions(+), 226 deletions(-) create mode 100644 docs/EMAIL_REFACTOR_VERIFICATION.md create mode 100644 packages/email-renderer/tests/fixtures/personal-calendar-response.json create mode 100644 packages/email-renderer/tests/fixtures/personal-letter.json create mode 100644 packages/email-renderer/tests/snapshots/personal-calendar-response-dark-600.png create mode 100644 packages/email-renderer/tests/snapshots/personal-calendar-response-light-600.png create mode 100644 packages/email-renderer/tests/snapshots/personal-letter-dark-600.png create mode 100644 packages/email-renderer/tests/snapshots/personal-letter-light-600.png 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 index 5836e13af51..4dca4b35c4c 100644 --- 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 @@ -1,5 +1,8 @@ // @vitest-environment jsdom import type { EmailMessage } from '@app/features/email-message/core/email-message'; +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 { prepareEmailBodyFromHtml } from './prepare-email-body'; @@ -20,6 +23,61 @@ const replyingTo = { } as unknown as EmailMessage; 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('