@marlinjai/email-editor
v0.6.1
Published
Embeddable visual email editor (React component and vanilla factory) that produces an MJML-compilable document
Downloads
1,234
Maintainers
Readme
@marlinjai/email-editor
A visual, drag-and-drop email editor you embed in your own app. It produces a JSON document; your server compiles that document to email-safe HTML with MJML (the Mailjet Markup Language, a markup that compiles to HTML which renders consistently across mail clients).
- React component (
EmailEditorReact) and a framework-agnostic factory (createEditor) - 14 block types (text, image, button, hero, social, navbar, table and more) and 35 pre-built sections
- Containers around several sections (MJML's
mj-wrapper): one background, border, radius and padding for a group of sections, edited visually - Your own image picker through the
onRequestImagehook - A prebuilt stylesheet scoped to the editor, safe beside Tailwind CSS 4 or any other host styles
- Server-side compilation through
@marlinjai/email-editor-core/server
Peer ranges accept React 18 and 19; the integration is verified on React 19.2, Next.js 16.3 and Tailwind CSS 4. Node.js 20.19 or newer.
Install
pnpm add @marlinjai/email-editor @marlinjai/email-editor-core react react-dom@marlinjai/email-editor-core is only needed directly if you compile or validate documents on your server (you almost certainly do).
Use it in React
'use client';
import { useState } from 'react';
import { EmailEditorReact, type TemplateSnapshotOut } from '@marlinjai/email-editor/react';
import '@marlinjai/email-editor/styles.css';
export function Composer({ initial }: { initial?: TemplateSnapshotOut }) {
const [doc, setDoc] = useState(initial);
return (
<div style={{ height: '80vh' }}>
<EmailEditorReact initialTemplate={initial} onChange={setDoc} onSave={() => save(doc)} />
</div>
);
}- The editor fills its container, so give the container a height.
initialTemplateis read once, on mount (the editor is uncontrolled). To load a different document, remount it with a newkey.onChangereceives the full document, debounced by 300 ms. Persist it as JSON.- A document's
idis optional. When the document you pass has none, the editor assigns one on mount (on its own copy: your object is not changed), and every document it hands back (onChange,onExport) carries that id. Save what you are handed and the id stays stable from then on.
Props
| Prop | Type | What it does |
|------|------|--------------|
| initialTemplate | TemplateSnapshotIn | Document to open. Omit for an empty email. |
| onChange | (doc) => void | Called with the whole document after edits (debounced). |
| onSave | () => void | Shows a Save button in the toolbar and calls this on click. |
| onExport | (doc) => void | Shows an Export button; compile the document on your server. |
| onNavigateBack | () => void | Shows a back arrow in the toolbar. |
| onRequestImage | (request) => Promise<{ url, alt? } \| null> | Your image picker, see below. |
| blocks | BlockDefinition[] | Redefine standard block types (label, icon, category, default props). A new block type is refused with an error. |
| theme | EditorTheme | Brand colors and font of the editor chrome, see below. |
| savedSections | SavedSectionInput[] | Sections this workspace saved, offered in the picker under their own group. |
| onSaveSection | (section, name) => Promise<void> | Enables "Save as a section" on a section's controls. Reject with a readable message. |
| builtInSections | boolean | Offer the 35 sections that ship with this package. true by default. |
| placeholderBase | string | Where placeholder images are served from, see below. |
Next.js (App Router)
The editor runs in the browser only (drag and drop, rich text, MobX state), so load it on the client and skip server rendering:
// app/compose/page.tsx
'use client';
import dynamic from 'next/dynamic';
import '@marlinjai/email-editor/styles.css';
const EmailEditorReact = dynamic(
() => import('@marlinjai/email-editor/react').then((mod) => mod.EmailEditorReact),
{ ssr: false, loading: () => <p>Loading editor...</p> }
);
export default function ComposePage() {
return (
<div style={{ height: '100vh' }}>
<EmailEditorReact onChange={(doc) => console.log(doc)} />
</div>
);
}next.config.ts:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// MJML is Node-only and loads files at runtime: keep it out of the server bundle.
serverExternalPackages: ['mjml', 'mjml-core', 'mjml-parser-xml', 'mjml-preset-core', 'mjml-validator'],
};
export default nextConfig;transpilePackages is not needed: the packages ship compiled ESM and CommonJS. This setup is verified on Next.js 16.3 with React 19.2 and Tailwind CSS 4 by the repository's example app (examples/nextjs).
Compile on the server
Compilation happens on your server, never in the browser (MJML is a large Node.js dependency). Validate the document first with migrateTemplate:
// app/api/compile/route.ts
import { migrateTemplate, isTemplateMigrationError } from '@marlinjai/email-editor-core';
import { createMJMLCompiler } from '@marlinjai/email-editor-core/server';
export async function POST(request: Request) {
try {
const doc = migrateTemplate(await request.json());
// { webFonts: false } leaves out MJML's automatic Google Fonts imports
// (for fonts such as Roboto or Lato), when your mails must load nothing
// from third parties.
const { html, mjml, errors } = await createMJMLCompiler().compile(doc);
return Response.json({ html, mjml, errors });
} catch (error) {
if (isTemplateMigrationError(error)) {
const status = error.code === 'NEWER_VERSION' ? 422 : 400;
return Response.json({ error: error.message, code: error.code, issues: error.issues }, { status });
}
throw error;
}
}Never import @marlinjai/email-editor-core/server from client code.
Export as MJML or HTML, import existing MJML
Both directions run on your server, next to the compiler, and never in the browser bundle.
Export. The same compile call gives both files: mjml is the MJML source of the document, html the finished mail. Serve whichever the person asked for as a download:
// app/api/export/route.ts
import { migrateTemplate } from '@marlinjai/email-editor-core';
import { createMJMLCompiler } from '@marlinjai/email-editor-core/server';
export async function POST(request: Request) {
const format = new URL(request.url).searchParams.get('format') === 'mjml' ? 'mjml' : 'html';
const { html, mjml, errors } = await createMJMLCompiler().compile(migrateTemplate(await request.json()));
return new Response(format === 'mjml' ? mjml : html, {
headers: {
'content-type': format === 'mjml' ? 'text/plain; charset=utf-8' : 'text/html; charset=utf-8',
'content-disposition': `attachment; filename="email.${format}"`,
// errors: MJML's validation messages, worth showing next to the download
},
});
}The editor's getHTML() and getMJML() cannot compile in the browser; call your route with getValue() instead. The editor has no export button of its own on purpose: a download belongs in your app's chrome (where the Lumitra Mail dashboard puts its Export menu), and only your server can compile.
Import. importMjml(source) reads an MJML document into the editor's document model:
import { importMjml, isMjmlImportError } from '@marlinjai/email-editor-core/server';
try {
const { document, warnings } = await importMjml(mjmlSource);
// `document` passed migrateTemplate: open it in the editor, or store it.
// `warnings`: what could not become an editable block, with where and why.
} catch (error) {
if (isMjmlImportError(error)) {
// error.code: invalid_xml | not_mjml | include_not_supported | too_large | too_deep | too_many_elements | invalid_document
// error.line, error.column: where, when it is one place
}
throw error;
}What maps, and what does not:
- Every standard component with the attributes its block has becomes that block: text, image, button, divider, spacer, navbar, carousel, accordion, raw HTML, sections, columns and groups of columns.
- An
mj-wrapperbecomes a container holding its sections, with every wrapper attribute as a field (background, border and each side's border, radius, padding, full width,css-class,gap,text-align). A child the editor cannot read as a section (anmj-hero, anmj-raw, a section with conditional comments) stays inside the container, in place, as raw HTML, so nothing in a wrapper is dropped. Attributes the block has no field for (acss-classyourmj-stylerules target,font-weight,mj-class, ...) are kept on the block and emitted again, and the document keeps itsmj-attributes, so the mail compiles as the source did. - The editor's own export imports back exactly, ids included.
- What the editor cannot hold as a block is compiled in place and kept as a Raw HTML block that renders exactly as before, with a
kept_as_htmlwarning carrying the MJML: anmj-herowith content,mj-social(the editor's Social block draws its own icons), a hand-writtenmj-table, a section with conditional comments between its columns. - A component MJML does not know renders nothing in MJML either; its source is kept in a comment in a Raw block, with an
unknown_componentwarning. mj-includeis refused (include_not_supported): an import has no files next to it, and the importer never reads the disk.
Importing is synchronous and CPU-bound. Limits (MAX_MJML_BYTES, MAX_MJML_DEPTH, MAX_MJML_ELEMENTS) bound one call; for untrusted input run it off your request thread with a deadline, as the Lumitra Mail service does in its compile worker pool.
Containers (wrappers)
A container is MJML's mj-wrapper: several sections sharing one background (colour, gradient or image), border (all sides or each side), corner radius and padding, with an optional gap between the sections inside. In the document it sits at the top level next to sections, { type: 'wrapper', sections: [...] }; containers never nest and never sit inside a section.
- Add one from the Layout tab (Add Container), or select a section and choose Wrap in container (canvas toolbar, inspector, or the Layers panel). A section next to a container can join it from the inspector; Move out takes it back to the top level.
- In the Layers panel a container's sections are nested one level in. Drag sections into, out of and between containers (pointer or keyboard: Space, arrow keys, Space), and drag containers to reorder them.
- The inspector edits every container attribute; the background image goes through your
onRequestImagehook (blockType: 'wrapper'). The canvas draws background, border, radius, MJML's default padding (20px 0) and the gap as the mail will. - Inside a container a section's Full Width has no visible effect (and a full-width container draws its sections at standard width), so the section inspector explains that instead of offering it. Outlook on Windows cannot show a section's background image inside a container that has one; the inspector warns.
- Delete (the key, the toolbar or the Layers panel) asks in the editor's own dialog whether to keep the sections or delete everything. Every container action is one undo step.
Stored documents and migrateTemplate
Every document carries a schema version (today "1.1", exported as CURRENT_TEMPLATE_VERSION; 1.1 added containers). Run stored documents through migrateTemplate(doc) when you load them: it returns the document at the current version. For a 1.1 document it is the identity (the same object comes back, validated); a 1.0 document comes back as a new object whose only change is the version, except that a 1.0 section flagged isWrapper (a wrapper around one section, written by the first MJML import) becomes a container around that section. The input is never changed, and a 1.0 document without that flag compiles to exactly the same mail. The editor opens 1.0 documents the same way and emits 1.1. A build that only knows 1.0 refuses a 1.1 document with NEWER_VERSION. It throws a TemplateMigrationError whose code is one of:
| code | Meaning |
|--------|---------|
| INVALID_INPUT | Not an object, so not a document. |
| MISSING_VERSION | No version string. |
| UNSUPPORTED_VERSION | Malformed version, or an older one with no migration. |
| NEWER_VERSION | Written by a newer editor. Upgrade these packages to open it. |
| INVALID_DOCUMENT | The version is known but the document fails its schema; issues lists where. |
Store the version next to the document (for example a schema_version column), so you can find documents that need upgrading after a future schema change.
Placeholder images: placeholderBase
Every built-in section, and the Image, Hero and Carousel blocks, shows a grey rectangle where no image has been chosen yet. Where that rectangle comes from is your decision, and it matters more than it looks:
- Without
placeholderBaseeach one is an inlinedata:image. It renders in the editor's canvas and needs nothing behind it, which is the right default for trying the package out. But Gmail and Outlook.com do not renderdata:images at all, so one left in a real email is an invisible gap rather than an obvious mistake. - With
placeholderBaseeach becomes<base>/p/<width>x<height>.png, a real image from your own origin. It renders everywhere, and it passes a recipient-privacy policy that only allows images from your own host.
Set it whenever the documents this editor produces are going to be sent:
<EmailEditorReact placeholderBase="https://mail.example.com" />Your server serves the sizes the sections ask for. Each is a flat grey rectangle with a border at exactly the width and height in the path, and the aspect ratio matters: a block that sets only a width lays out from the image's own ratio, so one square image scaled by the browser would distort every text-and-image layout.
Do not point it at a third-party placeholder service. An image address in an email is an instruction to every recipient's mail client to call that host, which hands a stranger the reader's address and the moment they opened it.
Whatever you choose, treat a placeholder that survives to send time as a mistake: warn, or refuse. Lumitra Mail refuses the send.
Your own image picker: onRequestImage
Without the hook, the image block's inspector (and the background image of a section or container) shows a plain URL field. With it, the inspector shows a Choose image (or Replace image) button that calls your function; for a background, blockId is the section's or container's id and blockType is section or wrapper:
<EmailEditorReact
onRequestImage={async ({ blockId, currentUrl, currentAlt }) => {
const picked = await openMyMediaLibrary({ currentUrl }); // your UI
if (!picked) return null; // cancelled: the block is left unchanged
return { url: picked.publicUrl, alt: picked.description };
}}
/>- Resolve with
{ url, alt? }to set the image.urlmust be publicly reachable by your recipients' mail clients.altis optional; without it the block keeps its alt text. - Resolve with
nullto cancel. Nothing changes. - Reject (throw) to show the error's message inline under the button, for example an upload that failed. The user can try again.
- While your promise is pending the button is disabled, so a second request cannot start. If the user deletes the block before you resolve, the result is dropped.
Use an in-page dialog for the picker, not window.prompt.
Styles, Tailwind CSS 4 and theming
Import @marlinjai/email-editor/styles.css once. Every rule in it is scoped under the editor's root element (.ee-root), including its CSS reset, so it does not restyle your page, and its keyframes are prefixed ee-. It is deliberately not inside a CSS cascade layer, so a Tailwind CSS 4 host's own reset (in @layer base) cannot leak into the editor either. Nothing in your Tailwind configuration needs to change, and you should not add the editor's files to Tailwind's content sources.
Theme the editor chrome with the theme prop:
<EmailEditorReact
theme={{
colors: { primary: '#0f766e', primaryHover: '#115e59', surface: '#ffffff', text: '#0f172a', border: '#e2e8f0' },
fonts: { body: 'Inter, system-ui, sans-serif' },
}}
/>Each value sets a design token on the editor's root element only. For finer control, override any --ee-* token in your own CSS; put the rule outside any @layer, because the editor's unlayered stylesheet wins over layered rules:
.ee-root {
--ee-midnight-2: #0b1220; /* toolbar background */
}The tokens are listed at the top of the stylesheet (--ee-midnight-* for the dark chrome, --ee-canvas-* for light surfaces, --ee-text-*, --ee-border-*, --ee-accent*, --ee-success*, --ee-danger*, --ee-font-sans).
Without React: createEditor
import { createEditor } from '@marlinjai/email-editor';
import '@marlinjai/email-editor/styles.css';
const editor = createEditor({
container: document.getElementById('editor')!,
initialValue: storedDoc,
onChange: (doc) => save(doc),
onRequestImage: async () => ({ url: 'https://cdn.example.com/hero.png' }),
theme: { colors: { primary: '#0f766e' } },
});
// load another document (the editor remounts on it; undo history starts over)
editor.setValue(otherDoc);
// the document as it stands, with its id
editor.getValue();
// later
editor.destroy();initialValue and setValue accept a document without an id: the editor opens it on a copy with a fresh id, and getValue, onChange and onSave return that id from the start, before any edit.
createEditor still needs react and react-dom installed (the editor is built with React), but your app does not have to use React.
Related packages
@marlinjai/email-editor-core: document schema,migrateTemplate, store, and the server-side MJML compiler@marlinjai/email-editor-blocks: the standard blocks and pre-built sections@marlinjai/email-editor-ui: the React UI, for hosts that assemble the editor themselves
License
MIT
