designComponents

Search the design system…

Search the design system…

patterns

EmailTemplateEditor

Block-based email template editor — designs branded HTML campaigns AND plain person-to-person emails from a single JSON contract. Same JSON drives the UI editor and the server renderer; consumer apps (Cip, Archi, future tools) integrate by pointing it at a variable schema and persisting the document.

Wrapped in a 'technical instrument' frame (rounded border + monospace chrome bar) that mirrors the RuleBuilder visual idiom. Two modes per template: `branded` renders block-based HTML on a locked 600px canvas with a dot-grid frame; `plain` renders a single rich-text document that comes through as `text/plain` + minimal HTML so it reads as a real person typing in their email client. Inline rich text inside any TipTap-backed block (heading / paragraph / footer / plain body) gets a floating bubble toolbar on selection — bold, italic, underline, strikethrough, link, plus pop-out pickers for text size (S 12px · M 14px · L 18px), font family (Sans / Serif / Mono — email-safe stacks), and text color (token-bound swatches; no free hex picker). Block-level structure (heading, paragraph, image, button, divider, spacer, footer) is our own React components with `@dnd-kit` reordering. The chrome bar exposes a `Branded / Plain` mode toggle, an `Edit / Preview` view toggle, and a viewport selector (Desktop / Tablet / Mobile) that flips the canvas into a sandboxed iframe wrapped in a stylised device-frame outline. Standalone preview routes can use the exported `<EmailPreview>`. The renderer is server-safe — no JSDOM — and turns the template JSON into Outlook-compatible table HTML for branded mode, or minimal HTML for plain. The same JSON travels through API and UI; no separate code paths.

Install

Import from the workspace package:

import { EmailTemplateEditor, EmailPreview, EmailTemplate, EmailMode, EmailBlock, EmailBlockType, PreviewViewport, VariableSchema, renderEmailToHtml, interpolateVariables, extractVariables } from "@8maverik8/design";

Examples

Branded mode — invite email

Live editor with the full surface area. **Try it:** select any text in the canvas to reveal the floating bubble toolbar (bold / italic / underline / strike / link, plus pop-outs for size · family · color). Click `Preview` in the chrome bar to flip the canvas into a sandboxed iframe and switch viewports between desktop / tablet / mobile device frames. Three columns in edit mode: variable + block palette (left), dot-grid canvas with the 600px frame, and a contextual inspector that swaps between block-level and template-level props.

Email template·
·
Renders as table-HTML with inline styles
Accept invitation
import {
  EmailTemplateEditor,
  type EmailTemplate,
  type VariableSchema,
} from "@8maverik8/design";

const schema: VariableSchema = {
  variables: [
    { path: "user.first_name", label: "First name",     sample: "Oleg" },
    { path: "user.email",      label: "Email",          sample: "oleg@oapps.io" },
    { path: "org.name",        label: "Organization",   sample: "OAPPS" },
    { path: "invite.url",      label: "Invite link",    sample: "https://app.oapps.io/i/…" },
    { path: "app.unsubscribe_url", label: "Unsubscribe", sample: "https://app.oapps.io/u/…" },
  ],
};

const initial: EmailTemplate = {
  id: "tpl_invite",
  name: "Team invitation",
  subject: "You're invited to {{org.name}}",
  preheader: "Set up your account in 7 days",
  fromName: "OAPPS",
  replyTo: "support@oapps.io",
  mode: "branded",
  body: {
    mode: "branded",
    width: 600,
    blocks: [
      // … shape laid out per the EmailBlock variants table.
    ],
  },
};

const [value, setValue] = useState<EmailTemplate>(initial);

<EmailTemplateEditor
  schema={schema}
  value={value}
  onChange={setValue}
  // Optional — when provided, an "Open ↗" button appears in the
  // chrome bar in Preview mode, pointing at the consumer-owned
  // standalone preview route. The route should render <EmailPreview>.
  previewUrl={`/_preview/${value.id}`}
/>

// Standalone preview route (e.g. app/_preview/[id]/page.tsx in Cip):
// <EmailPreview value={template} viewport="desktop" schema={schema} />

// Persist value as JSON on save.
// On send: const html = renderEmailToHtml(value, { user, org, invite, app });

Plain mode — re-engagement nudge

Same component, `mode: 'plain'`. The block palette disappears (no blocks in plain mode), the canvas drops the frame and dot-grid, the inspector hides block-props. The variable palette stays — variables are still interpolated. The hint at the bottom of the inspector reminds you the message goes out as `text/plain` plus minimal HTML so it reads as a personal note.

Email template·
·
Renders as text/plain + minimal HTML
const initial: EmailTemplate = {
  id: "tpl_trial_day_7",
  name: "Trial — day 7 nudge",
  subject: "Quick note about your {{org.name}} trial",
  preheader: undefined,
  fromName: "Oleg from OAPPS",
  replyTo: "oleg@oapps.io",
  mode: "plain",
  signature: "auto",
  body: {
    mode: "plain",
    doc: {
      // ProseMirror JSON — paragraphs with bold / italic / link / variable marks.
    },
  },
};

<EmailTemplateEditor schema={schema} value={value} onChange={setValue} />

Variants

EmailTemplate.mode

  • branded(default)HTML newsletter / transactional with brand identity. Block list inside a locked 600px frame on dot-grid canvas. Renders to table-based HTML with inline styles. Bubble toolbar gives bold/italic/underline/strike/link/size/family/color on every text block.
  • plainPerson-to-person email. Single TipTap document, no frame, no fancy blocks. Same bubble toolbar applies. Renders to `text/plain` + minimal HTML so the message reads as if written manually in an email client.

Editor view (chrome bar)

  • edit(default)Three-column editing layout — variable / block palette · canvas · contextual inspector.
  • previewSide rails collapse; canvas is replaced by a sandboxed iframe rendering the actual email HTML at the selected viewport (Desktop 800px · Tablet 600px · Mobile 380px) wrapped in a stylised device-frame outline. Pass `previewUrl` to surface an `Open ↗` button that pops a consumer-owned standalone preview route in a new tab.

EmailBlock.type (branded mode only)

  • headingTitle row. `level: 1 | 2 | 3`, `align: left | center`. TipTap inside with bubble toolbar (bold/italic/underline/strike/link/size/family/color).
  • paragraphBody text. `align: left | center`. TipTap inside with bubble toolbar (bold/italic/underline/strike/link/size/family/color/variable).
  • imageStatic image. `src`, `alt`, `width`, `align`. Width caps at frame width.
  • buttonCTA. `label`, `href` (variables allowed in href), `align`. Renders as bulletproof table button in HTML.
  • divider1px hairline at frame width. No props.
  • spacerVertical whitespace. `height: 8 | 16 | 24 | 32 | 48`.
  • footerLast block — small print. TipTap inside with bubble toolbar, typically with unsubscribe link via `{{app.unsubscribe_url}}`.

Anatomy

  • valueControlled JSON document — `EmailTemplate`. Persist as-is, hydrate on mount. Subject, preheader, fromName, replyTo, mode, signature, and a `body` discriminated by mode.
  • schema`VariableSchema` — list of variables the consumer's data model exposes. Same shape as `RuleSchema.fields`, reused. Each variable has `path` (dotted, e.g. `user.first_name`), `label`, optional `sample` value for previewing.
  • onChangeFires on every edit with the next `EmailTemplate`. Caller decides debounce / autosave / explicit Save.
  • VariablePaletteLeft rail — categorised list of variables and (in branded mode) the block palette. Click a variable to insert at cursor; click a block to append at end.
  • EmailCanvasCenter column. In `branded`, renders the dot-grid background and centers a `<EmailFrame>` locked to 600px. In `plain`, no frame — centered `max-w-prose` over plain background.
  • EmailInspectorRight rail. Contextual: when a block is selected → block-level props (image src, button href, alignment, padding). When nothing selected → template-level props (subject, preheader, fromName, replyTo, signature for plain mode).
  • BubbleToolbarFloating formatting menu over selected text in any TipTap-backed block. Bold / Italic / Underline / Strikethrough / Link, plus pop-outs for text size (S 12px · M 14px · L 18px), font family (Sans / Serif / Mono — email-safe stacks), and text color (6 token-bound swatches). No arbitrary px or hex input — clients render fractional sizes and free palettes inconsistently.
  • EmailPreviewRead-only preview component for the chrome bar's Preview mode AND for consumer-owned standalone preview routes (e.g. `/_preview/<id>` in Cip). Renders the actual email HTML inside a sandboxed iframe at one of three viewports — Desktop (800px), Tablet (600px), Mobile (380px) — wrapped in a stylised device-frame outline. Auto-fills sample variable values from `schema.variables[].sample` when `vars` is omitted. Pass `bare` to drop the device chrome.
  • renderEmailToHtml(value, vars)Server-safe renderer. Branded → table-based HTML with inline styles for Outlook compatibility. Plain → minimal HTML + `text/plain` alternative. Supports `bold`, `italic`, `underline`, `strike`, `link`, and a unified `textStyle` mark carrying `color` / `fontFamily` / `fontSize`. Variables interpolated via the second arg.

Guidelines

  • Define the variable schema close to your data model and pass it in. Like RuleBuilder, the editor is dumb about your variables — that's the point. One component, every product, every email type.
  • Treat the emitted JSON as the contract you persist server-side. The same JSON is what your `POST /api/templates` accepts and what the renderer consumes when sending — no UI-to-API translation layer.
  • Use `mode: 'plain'` for trial-day-7 / re-engagement / personal outreach where the message should feel hand-typed. Branded HTML in those slots fights the intent.
  • Use `mode: 'branded'` for transactional (invite, password reset, receipt) and broadcast announcements where brand identity matters and you control rendering across clients.
  • Render an HTML editor (TipTap full kit, Slate, ProseMirror raw) directly as your editing surface.HTML-as-source-of-truth makes variable insertion fragile, breaks when you need server-side rendering for transactional sends (no JSDOM), and ties your contract to a vendor's document model. JSON blocks + targeted TipTap-for-inline is the right split.
  • Use the editor as a generic page-builder.Email is its own constraint set: 600px-ish width, table-based HTML, inline styles, conservative typography. Don't extend the block list with grids / columns / accordions — those don't render reliably in Outlook 2007+, Apple Mail, Gmail clipping, etc. If you need a marketing page, build a marketing page.
  • Switch modes mid-edit by remounting with a different value shape.Mode is a top-level prop (`value.mode`); the editor handles the transition with deterministic content migration (branded→plain merges all text-blocks into one TipTap doc; plain→branded wraps the doc in one paragraph block). Don't rebuild value externally.
  • Bypass the renderer when sending.`renderEmailToHtml(value, vars)` is the only supported way to produce the wire HTML. It does the table conversion, inline-styles, variable interpolation, link rewriting (UTM / unsubscribe / preheader-injection). Calling it from one place keeps preview === production.