Email Template Editor
A standalone, beehiiv-style email template editor built on PlateJS. A rich block canvas with a "+"/slash insert palette and merge-tag chips, a full Theme panel (Basic + Advanced) that live-styles every part of the email via CSS variables, email-safe HTML serialization, and a multi-provider preview simulator (Gmail / Outlook / Apple Mail / Mobile / Dark).
Installation
pnpm dlx @docyrus/cli add @docyrus/ui-email-template-editorpnpm add platejsnpx shadcn@latest add accordion badge button color-picker dropdown-menu input label popover select slider switch tabs textarea toggle-group tooltipnpx shadcn@latest add @plate/align-kit @plate/basic-blocks-kit @plate/basic-marks-kit @plate/code-block-kit @plate/column-kit @plate/editor @plate/fixed-toolbar @plate/font-kit @plate/history-toolbar-button @plate/line-height-kit @plate/link-kit @plate/link-toolbar-button @plate/list-kit @plate/list-toolbar-button @plate/mark-toolbar-button @plate/slash-kit @plate/table-kit @plate/table-toolbar-button @plate/toolbar @plate/turn-into-toolbar-buttonOverview
EmailTemplateEditor is a self-contained email authoring surface (no API client or auth required). The top bar has three modes plus two right-docked toggles:
- Write — the block canvas: a clean, Notion/beehiiv-style writing column with a rich toolbar (including dedicated Variables and Blocks insert buttons), a left
+gutter affordance, a/slash menu, and merge-tag chips. - Preview — a multi-provider simulator that renders the email-safe HTML across Gmail, Outlook (Word-engine approximation), Apple Mail and Mobile, with a light/dark toggle and a live size badge.
- Code — a read-only CodeMirror view of the serialized email-safe HTML with an in-panel Copy HTML button (mirrors the HTML Template Editor's Code tab).
- Theme (right-docked toggle, not a mode) — expands / collapses a right-side panel that styles every part of the email (colors, typography, spacing, borders, background, headings, links, lists, blockquote, tables, footer). Changes apply live to the canvas and stay open across Write / Preview / Code.
- Expand (right-docked toggle) — the editor's own fullscreen overlay; no host wiring needed (hidden in the
dialogvariant, which is already fullscreen).
The document model is a plain, serializable object — EmailDocument = { blocks, theme, settings? } — where blocks is the Plate value and theme is fully decoupled from the content, so persistence is a straight JSON.stringify.
Usage
import { useState } from 'react';
import {
cloneDefaultTheme,
EmailTemplateEditor,
serializeEmail,
type EmailDocument
} from '@docyrus/ui/components/email-template-editor';
const initial: EmailDocument = {
blocks: [{ type: 'h1', children: [{ text: 'Welcome, ' }, { type: 'merge_tag', name: 'first_name', children: [{ text: '' }] }, { text: '!' }] }],
theme: cloneDefaultTheme()
};
export function MyEmailEditor() {
const [doc, setDoc] = useState<EmailDocument>(initial);
return (
<EmailTemplateEditor
defaultValue={initial}
onChange={setDoc}
onExportHtml={(html) => console.log(serializeEmail(doc))}
/>
);
}Mounting modes
variant="page" (default) fills its container (h-full). variant="dialog" opens the same editor inside a fullscreen Radix dialog, controlled via open / onOpenChange:
const [open, setOpen] = useState(false);
<EmailTemplateEditor
variant="dialog"
open={open}
onOpenChange={setOpen}
title="Compose newsletter"
defaultValue={initial}
/>Exporting HTML
The Code mode's Copy HTML button (and the onExportHtml callback) return the full email-safe HTML — table-based layout, inlined CSS, a Cerberus/goodemailcode reset shell, <meta color-scheme>, a hidden preheader, and the themed footer. Call serializeEmail(doc) directly to get the same string headlessly.
import { serializeEmail } from '@docyrus/ui/components/email-template-editor';
const html = serializeEmail(doc); // literal {{merge_tags}}
const preview = serializeEmail(doc, { preview: true, mergeTags }); // sample values substitutedAPI Reference
EmailTemplateEditorProps
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'page' | 'dialog' | 'page' | Full-page (fills container) or fullscreen dialog shell |
open | boolean | — | Dialog open state (dialog variant, controlled) |
onOpenChange | (open: boolean) => void | — | Dialog open-state change handler |
defaultValue | EmailDocument | empty doc | Uncontrolled initial document (blocks + theme) |
value | EmailDocument | — | Controlled document (optional) |
onChange | (doc: EmailDocument) => void | — | Fires on every content or theme change |
mergeTags | MergeTagDef[] | DEFAULT_MERGE_TAGS | Merge-tag catalog for the {{ }} chips + insert menu |
onImageUpload | (file: File) => Promise<string> | — | Image upload handler (UI wired; network app-owned) |
onExportHtml | (html: string) => void | — | Fires with the email-safe HTML on Copy HTML (Code mode) |
readOnly | boolean | false | Disables editing (canvas becomes read-only) |
title | string | 'Email Template Editor' | Dialog title (dialog variant, screen-reader label) |
className | string | — | Class applied to the editor shell |
Type Exports
| Type | Description |
|---|---|
EmailDocument | { blocks: Value; theme: EmailTheme; settings?: { preheader?; subject? } } — the full serializable document |
EmailTheme | The complete theme object (mirrors beehiiv's Basic + Advanced Style panel) |
MergeTagDef | { id: string; label: string; group?: string; sample?: string } — a merge-tag definition |
Align | 'left' | 'center' | 'right' |
Spacing | number | { t; r; b; l } — all-sides or per-side spacing |
FontWeight | 100 … 900 |
EmailTemplateEditorProps | Props for the component |
EmailTheme shape
interface EmailTheme {
colors: { outsideBackground; postBackground; textOnBackground; primary; textOnPrimary; secondary; links };
typography: { heading: { family; weight }; paragraph: { family; weight } };
spacing: { margin: number; padding: number };
borders: { cornerRadius: number; thickness: number };
background: { color: { canvas; post; postBorder }; margin; padding; radius; borderThickness };
emailHeader: { title; subtitle; image; byline; alignment; padding; code };
body: { text: { family; weight; size; lineHeight; color; gap }; headings: Record<'h1'…'h6', {...}> };
widgets: { links; buttons; breaks; lists; images; tables; blockquote };
emailFooter: { backgroundColor; textColor; family; border; margin; padding; copyright; address; alignment };
}Exports
| Export | Description |
|---|---|
EmailTemplateEditor | The component |
serializeEmail(doc, options?) | EmailDocument → full email-safe HTML string ({ preview?, mergeTags? }) |
serializeEmailContent(blocks, theme) | Serialize just the body content (no shell) |
themeToCssVars(theme) | EmailTheme → --et-* CSS custom-property map (canvas styling path) |
cloneDefaultTheme() / DEFAULT_EMAIL_THEME | The default beehiiv-derived theme |
DEFAULT_MERGE_TAGS | The default merge-tag catalog |
CLIENT_PROFILES | The preview render profiles (Gmail / Outlook / Apple Mail / Mobile) |
EmailEditorProvider / useEmailEditorContext | Low-level state provider + hook (advanced) |
Blocks & inserts
Blocks are inserted from the toolbar Blocks button (a 3-column palette), the on-canvas left + gutter (combined blocks + variables), or the / slash menu. Merge-tag variables are inserted from the toolbar Variables button or by typing {{ field | fallback }} (auto-converts to a chip).
| Group | Blocks |
|---|---|
| Basics | Paragraph, Bulleted / Numbered / To-do list, Blockquote, Code block, Table, Content break (divider), Columns |
| Headings | Heading 1–6 |
| Dynamic | Merge tags ({{ }}) |
| Marks | Bold, Italic, Underline, Strikethrough, Inline code, Font color, Alignment, Link |
Theming engine
One EmailTheme, two render targets kept in lockstep:
- Live canvas —
themeToCssVars(theme)produces--et-*CSS custom properties applied to the writing surface, so slider/color edits restyle instantly with no re-serialization. - Email output —
serializeEmailfolds the same theme values into inline styles on a table-based, client-hardened HTML document.
The Theme panel is split into Basic (Colors, Typography, Spacing, Borders) and Advanced (Background, Email Header, Body H1–H6, Widgets, Email Footer). Basic controls write through to the Advanced fields the renderer reads, so simplified edits still change the output.
Multi-provider preview
The Preview mode renders the serialized email-safe HTML in a sandboxed <iframe srcDoc> per client, applying a per-client CSS reset/quirk layer and a width frame:
| Profile | Notes |
|---|---|
| Apple Mail | Most faithful; honors prefers-color-scheme |
| Gmail | Inline-CSS baseline (Gmail strips <style>/drops it over ~8 KB) |
| Outlook (Windows) | Word-engine approximation — no rounded corners / shadows / bg-image, serif fallback |
| Outlook.com | Chromium; [data-ogsc]/[data-ogsb] dark hooks |
| Mobile | 375px viewport; responsive media queries fire |
A light/dark toggle applies each profile's dark behavior, and a size badge warns past Gmail's ~102 KB clipping threshold. Every non-Apple-Mail tab is an approximation — for pixel-accurate QA use a render farm.
Limitations
This is the first phase. The following are intentionally not shipped yet:
- Custom blocks — Button/CTA, Subscriber Break, Cash Tags, and provider-specific embeds (YouTube / Twitter / Instagram / …) are not authored in the canvas yet, so the Buttons / Images / Email Header theme sections have no on-canvas target.
- Persistence — no backend/theme-preset saving; the document lives in memory (wire
value/onChangefor your own store). - Preview fidelity — the in-browser simulator approximates clients (especially Outlook's Word engine); it is not a substitute for a render farm.