Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ yarn-error.log*

# turbo
.turbo
.serena/
37 changes: 14 additions & 23 deletions apps/docs/editor/advanced/custom-extensions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,11 @@ export function MyEditor() {
}
```

## Creating based on an existing Node
## Wrapping existing TipTap extensions

Wrap an existing TipTap node with email serialization support:
Both `EmailNode` and `EmailMark` provide a `.from()` method that wraps an existing TipTap
extension with email serialization support. This is useful when you want to reuse a community
TipTap extension and add email export support without rewriting it.

```tsx
import { EmailNode } from '@react-email/editor/core';
Expand All @@ -243,34 +245,23 @@ const MyEmailNode = EmailNode.from(MyTipTapNode, ({ children, style }) => {
});
```

This is useful when you want to reuse a community TipTap extension and add email export support.

## EmailMark

For inline marks (like bold, italic, or custom annotations), use `EmailMark`:

```tsx
import { EmailMark } from '@react-email/editor/core';
import { Mark } from '@tiptap/core';

const Highlight = EmailMark.create({
name: 'highlight',

parseHTML() {
return [{ tag: 'mark' }];
},

renderHTML({ HTMLAttributes }) {
return ['mark', HTMLAttributes, 0];
},
const MyTipTapMark = Mark.create({ /* ... */ });

renderToReactEmail({ children, style }) {
return (
<mark style={{ ...style, backgroundColor: '#fef08a' }}>{children}</mark>
);
},
const MyEmailMark = EmailMark.from(MyTipTapMark, ({ children, style }) => {
return <mark style={{ ...style, backgroundColor: '#fef08a' }}>{children}</mark>;
});
```

<Info>
For full API details on all methods (`create`, `from`, `configure`, `extend`), see the
[`EmailNode`](/editor/api-reference/email-node) and [`EmailMark`](/editor/api-reference/email-mark)
reference pages.
</Info>

## Configure and extend

Both `EmailNode` and `EmailMark` support TipTap's standard customization methods:
Expand Down
241 changes: 241 additions & 0 deletions apps/docs/editor/api-reference/compose-react-email.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
---
title: "composeReactEmail"
sidebarTitle: "composeReactEmail"
description: "Convert editor content to email-ready HTML and plain text."
icon: "file-export"
---

The `composeReactEmail` function is the core of the editor's email export system. It takes
the editor's document tree, walks every node and mark, calls each extension's
`renderToReactEmail()` method, applies theme styles, wraps everything in an email-ready
template, and produces both HTML and plain text output.

## Import

```tsx
import { composeReactEmail } from '@react-email/editor/core';
```

## Signature

```tsx
async function composeReactEmail(params: {
editor: Editor;
preview: string | null;
}): Promise<{ html: string; text: string }>;
```

## Parameters

<ResponseField name="editor" type="Editor" required>
The TipTap editor instance. The function reads the editor's JSON document and walks through
each registered extension to serialize nodes and marks.
</ResponseField>

<ResponseField name="preview" type="string | null" required>
Preview text shown in inbox list views before the email is opened. Pass `null` to omit.
</ResponseField>

## Return value

Returns a `Promise` that resolves to an object with:

| Field | Type | Description |
|-------|------|-------------|
| `html` | `string` | Full HTML email string, ready to send |
| `text` | `string` | Plain text version for email clients that don't support HTML |

Both are generated in parallel for performance.

---

## The serialization pipeline

Understanding how `composeReactEmail` works helps you write better custom extensions and
debug rendering issues.

### 1. Extract document and extensions

The function reads the editor's JSON document (via `editor.getJSON()`) and collects all
registered extensions into a name-to-extension map for fast lookup.

### 2. Find the SerializerPlugin

It searches extensions for one that provides a `SerializerPlugin` — an interface with two
methods:

- **`getNodeStyles(node, depth, editor)`** — returns `React.CSSProperties` for a given node
- **`BaseTemplate({ previewText, children, editor })`** — wraps the serialized content in an email structure

The [`EmailTheming`](/editor/features/theming) extension implements this interface. If no
plugin is found, styles default to `{}` and the built-in `DefaultBaseTemplate` is used.

### 3. Traverse the document tree

It recursively walks the ProseMirror document. For each node it:

1. **Resolves styles** — calls `serializerPlugin.getNodeStyles(node, depth, editor)` to get
theme styles, then merges any inline styles from the node's attributes
2. **Skips unknown nodes** — if the node type isn't registered or isn't an
[`EmailNode`](/editor/api-reference/email-node), it's rendered as `null`
3. **Renders the node** — calls the extension's `renderToReactEmail()` component, passing
`children` (from recursing into child nodes), `style`, `node`, and `extension`
4. **Wraps with marks** — iterates through the node's marks (bold, italic, link, etc.) and
wraps the rendered output with each mark's `renderToReactEmail()`


### 4. Depth tracking

Depth starts at `0` and only increments inside list nodes (`bulletList`, `orderedList`).
This enables different styling for nested vs. top-level elements — for example, paragraphs
inside list items use the `listParagraph` theme key instead of `paragraph`.

### 5. Style resolution order

Styles are resolved in this priority (highest wins):

1. **Inline styles** — styles set directly on a node via the editor (e.g., text alignment)
2. **Theme styles** — styles from the active theme via `getNodeStyles()`
3. **Extension defaults** — hardcoded styles in each extension's `renderToReactEmail()`

Inside each extension's renderer, these are typically merged:

```tsx
renderToReactEmail({ children, style, node }) {
return (
<p style={{
...style, // theme styles
...inlineCssToJs(node.attrs?.style), // inline overrides
}}>
{children}
</p>
);
}
```

### 6. Wrap in BaseTemplate

The serialized content is wrapped in a `BaseTemplate` that provides the email's
outer structure.

The **default template** renders:

```tsx
<Html>
<Head>
<meta content="width=device-width" name="viewport" />
<meta content="IE=edge" httpEquiv="X-UA-Compatible" />
<meta name="x-apple-disable-message-reformatting" />
<meta
content="telephone=no,address=no,email=no,date=no,url=no"
name="format-detection"
/>
</Head>
{previewText && <Preview>{previewText}</Preview>}
<Body>
<Section width="100%" align="center">
<Section style={{ width: '100%' }}>
{children}
</Section>
</Section>
</Body>
</Html>
```

When [`EmailTheming`](/editor/features/theming) is active, its `BaseTemplate` replaces the
default — it adds theme-specific body/container styles and can inject global CSS via a
`<style>` tag in the `<Head>`.

### 7. Render to HTML and plain text

The React tree is rendered to an HTML string using `@react-email/components`' `render()`
function. Both the formatted HTML and a plain text version (tags stripped, text preserved)
are rendered in parallel from the final React.

---

## Usage

### Basic export

```tsx
import { composeReactEmail } from '@react-email/editor/core';

const { html, text } = await composeReactEmail({ editor, preview: null });
```

### With preview text

The `preview` parameter sets the inbox preview snippet — the text shown before the email
is opened:

```tsx
const { html, text } = await composeReactEmail({
editor,
preview: 'Check out our latest updates!',
});
```

Pass `null` to omit preview text entirely.

### With theming

When the [`EmailTheming`](/editor/features/theming) extension is in your extensions array,
theme styles are automatically inlined into every node during export:

```tsx
import { StarterKit } from '@react-email/editor/extensions';
import { EmailTheming } from '@react-email/editor/plugins';

const extensions = [StarterKit, EmailTheming.configure({ theme: 'basic' })];

// Theme styles are injected automatically — no extra config needed
const { html } = await composeReactEmail({ editor, preview: null });
```

### Full example with export panel

```tsx
import { composeReactEmail } from '@react-email/editor/core';
import { useCurrentEditor } from '@tiptap/react';
import { useState } from 'react';

function ExportPanel() {
const { editor } = useCurrentEditor();
const [html, setHtml] = useState('');
const [exporting, setExporting] = useState(false);

const handleExport = async () => {
if (!editor) return;
setExporting(true);
const result = await composeReactEmail({ editor, preview: null });
setHtml(result.html);
setExporting(false);
};

return (
<div>
<button onClick={handleExport} disabled={exporting}>
{exporting ? 'Exporting...' : 'Export HTML'}
</button>
{html && (
<textarea
readOnly
value={html}
rows={16}
style={{ width: '100%', fontFamily: 'monospace' }}
/>
)}
</div>
);
}
```

---

## See also

- [`EmailNode`](/editor/api-reference/email-node) — defines how nodes serialize via `renderToReactEmail()`
- [`EmailMark`](/editor/api-reference/email-mark) — defines how marks serialize via `renderToReactEmail()`
- [Email Export](/editor/features/email-export) — guide with full editor + export examples
- [Theming](/editor/features/theming) — how `EmailTheming` provides styles and templates to the serializer
Loading
Loading