@maildeno/editor is the same drag-and-drop editor used in the Maildeno dashboard, packaged as a standalone, MIT-licensed component you embed in your own application. Storage, sending, authentication and image hosting all stay on your side — the editor is the visual layer and nothing else.
MIT v0.4.9 No backend required
| This page covers the self-hosted, embeddable editor. For the hosted dashboard, see Email Builder. |
At a glance
| Property | Value |
|---|---|
Package |
|
License |
MIT |
Peer dependency |
|
Runtime dependencies |
None. Everything it needs is bundled. |
Node requirement |
Node 20+ to build; the editor itself runs in the browser |
Entry points |
|
Export formats |
HTML, MJML, React Email, JSON |
ESP conditional syntaxes |
14 built in, plus custom registration |
Backend |
Not required. |
Viewport |
Desktop only — a notice is shown below the breakpoint |
Installation
npm install @maildeno/editor
There is no separate CSS import. Styling is injected programmatically when the editor mounts.
On the init() and custom-element paths you do not need Vue on the host page at all — the runtime is bundled. The vue peer dependency applies only if you import the EmailEditor Vue component directly.
|
Three ways to mount it
The package has three entry points, deliberately separated so you only pay for what you use.
| Entry | Import | Use when |
|---|---|---|
Vue component |
|
Your app is already Vue 3. Smallest bundle — it uses your Vue runtime. |
Framework-free helper |
|
React, Angular, Svelte, plain HTML — anything that is not Vue. Recommended default. |
Custom element |
|
You want |
|
Tree-shaking does not save you from module-load side effects. If it runs at import time, it runs. |
Framework-free — init()
The recommended path for everything that is not Vue.
import { init, type EditorHandle } from "@maildeno/editor/init"
const editor: EditorHandle = await init({
container: "#editor", // element or CSS selector
onSave: ({ templateId }) => console.log("saved", templateId),
})
import { useEffect, useRef } from "react"
import { init, type EditorHandle } from "@maildeno/editor/init"
export default function Editor() {
const container = useRef<HTMLDivElement>(null)
const handle = useRef<EditorHandle | null>(null)
useEffect(() => {
let cancelled = false
init({
container: container.current!,
onSave: ({ templateId }) => console.log("saved", templateId),
})
return () => {
cancelled = true
handle.current?.destroy()
}
}, [])
return <div ref={container} />
}
<div id="editor"></div>
<script type="module">
import { init } from "@maildeno/editor/init"
const handle = await init({
container: "#editor",
capabilities: { export: ["html", "mjml", "json"] },
onSave: ({ templateId }) => console.log("saved", templateId),
onSendTestEmail: async ({ to, subject, html }) => {
await fetch("/api/send-test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to, subject, html }),
})
},
})
// Single-page apps: tear down before removing the container
window.addEventListener("beforeunload", () => handle.destroy())
</script>
Framework-specific walkthroughs live under Framework Guides.
As a Vue component
<script setup lang="ts">
import { EmailEditor } from "@maildeno/editor"
import type { PartialStorageAdapter } from "@maildeno/editor"
const storageAdapter: PartialStorageAdapter = { /* … */ }
</script>
<template>
<EmailEditor
:storage-adapter="storageAdapter"
:capabilities="{ export: ['html', 'mjml', 'react', 'json'] }"
color-mode="auto"
@save="({ templateId }) => console.log(templateId)"
@ready="() => console.log('canvas painted')"
/>
</template>
As a custom element
import { registerMaildenoEditorElement } from "@maildeno/editor/element"
registerMaildenoEditorElement() // defines <maildeno-editor>
<maildeno-editor brand-name="Acme" color-mode="dark" versions></maildeno-editor>
Primitive props reflect as attributes. Object props — storageAdapter, theme, assistant — cannot round-trip through an attribute string, so set them as DOM properties:
const el = document.querySelector("maildeno-editor")
el.storageAdapter = myAdapter
el.theme = { primary: "#3f5e4a" }
Vue’s defineCustomElement treats props beginning with on as event listeners rather than props, so el.onSave = fn never reaches the component — the Save button appears and the callback never fires. Use el.saveHandler = fn on this path. init() handles this for you.
|
The editor handle
init() returns an EditorHandle. Every method is also available on a Vue template ref and on the custom element instance.
| Method | Signature | Notes |
|---|---|---|
|
|
Production-ready HTML email |
|
|
MJML source |
|
|
React Email |
|
|
The portable template document |
|
|
Accepts what |
|
|
The selected node, or |
|
|
Returns |
|
|
Fires after each committed change. Returns an unsubscribe function. |
|
|
Re-themes live, no remount |
|
|
Additional listeners for the save event |
|
|
The underlying element, for anything the handle does not wrap |
|
|
Removes the element and unmounts the internal app |
|
|
setJson’s `history option defaults to "undoable" — one Ctrl+Z-able step, correct for anything the user triggered. Pass "reset" when swapping which document is open, where undoing back into the previous document would be wrong.
Options
| Option | Type | Description |
|---|---|---|
|
|
Required. An element, or a selector resolved with |
|
|
Load this template through the adapter on mount. Omit to start blank. |
|
|
Every method optional; anything omitted falls back to |
|
|
~70 colour tokens. See Theming. |
|
|
Restricts only, never grants. See Capabilities. |
|
|
Its presence is what reveals the Save button. |
|
|
Presence reveals the Send-test button. |
|
|
Shown in the loading overlay and the desktop-only notice. Empty string removes the line entirely. |
|
|
Swaps the saved-templates panel for version history. |
|
|
Fills the assistant drawer. See The assistant drawer. |
|
|
Default |
|
Several features are gated on handler presence rather than a config flag. Omit This is deliberate: "did you provide a handler" cannot get out of sync with reality the way a |
Capabilities
capabilities layers explicit restrictions on top of the implicit ones. Omit any key to leave that area fully enabled.
capabilities: {
export: ["html", "json"], // MJML and React removed
savedRows: { create: true, rename: false, delete: false },
}
| Key | Effect |
|---|---|
|
Which formats appear in the Export dropdown. Omit for all four. |
|
Which saved-row controls this user gets — the canvas bookmark button, and the panel’s rename and delete. |
For a host with roles, savedRows is what stops a viewer being shown three buttons that can only 403. The adapter’s error toast is the backstop, not the design.
| Compute capabilities server-side and pass them down with the page. Client-side permission checks are UX, not security. |
versions is a top-level option rather than a capabilities key because capabilities documents itself as only ever restricting, and versions grants.
Storage adapters
Templates, saved rows, version history and images are all read and written through one interface. Every method is optional — anything you omit falls back to the built-in localStorage adapter.
The minimum useful integration is four methods:
import type { PartialStorageAdapter } from "@maildeno/editor"
const storageAdapter: PartialStorageAdapter = {
async loadTemplate(templateId) {
const res = await fetch(`/api/templates/${templateId}`)
return res.ok ? res.json() : null
},
async saveTemplate(snapshot, templateId) {
const res = await fetch(`/api/templates/${templateId ?? ""}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(snapshot),
})
return res.json() // → { templateId }
},
async listTemplates() {
return (await fetch("/api/templates")).json()
},
async uploadImage(file) {
const form = new FormData()
form.append("file", file)
const res = await fetch("/api/uploads", { method: "POST", body: form })
const { url } = await res.json()
return url // must be permanently public
},
}
|
The merge is per-method, not per-area. If you implement |
|
The URL returned by |
Multi-tenancy. The editor never sees a tenant id, deliberately. Your adapter closes over the session and your API reads the tenant from the auth cookie. There is no tenant field in the template and no way for a client-side bug to write across tenants, because the client never names one.
The full 17-method interface, version history, and the shared row library are covered in Storage adapters.
The assistant drawer
The editor ships the place an assistant goes, not an assistant. It provides the drawer, its trigger button, open/close state, escape handling, focus return and shadow-DOM-safe positioning. What goes inside is entirely yours.
await init({
container: "#editor",
assistant: {
mount(el, editor) {
// el — the drawer body element
// editor — { getJson, setJson, getSelection, setSelection, onChange }
el.innerHTML = `<button>Rewrite this block</button>`
el.querySelector("button")!.onclick = async () => {
const selection = editor.getSelection()
if (!selection) return
const next = await myApi.rewrite(editor.getJson(), selection.id)
editor.setJson(next) // one undoable step
}
},
unmount(el) { /* optional cleanup */ },
},
})
mount is called on first open, not at startup, and unmount on close — a drawer the user never opens costs nothing.
Vue hosts can use the #assistant slot instead. Either one being present reveals the trigger button.
Shadow DOM and the light-DOM escape hatch
By default the editor renders inside a shadow root, which isolates its styles from the host page. That is usually what you want.
Set shadowDom: false when predictable DOM behaviour matters more than isolation. Inside a shadow root, event.target is retargeted to the host, document.activeElement returns the host, document.querySelector cannot see in, and @font-face and @property are ignored. The package handles all of that internally — but your page’s own scripts, or a third-party library, may not, and that is not fixable from inside the editor.
await init({ container: "#editor", shadowDom: false })
The handle API is identical, so nothing downstream needs to know which mode is active. The trade-off cuts both ways: in light DOM, host CSS reaches the editor and vice versa.
Host page requirements
Three things to get right, and two of them cause bugs that look like package faults.
The container needs a real height.
#editor { height: 100vh; } /* or a sized flex parent */
A zero-height container mounts and renders nothing.
Avoid transform on ancestors.
/* Any of these on an ancestor breaks fixed-position floating UI */
transform · filter · perspective · will-change
They create a containing block for position: fixed, which lands toolbars and pickers in the wrong place. A transform: translateZ(0) added years ago for a scroll performance fix will silently break every popover in any embedded component.
Wait for ready, not for mount. "The component mounted" and "the user can look at it" are different moments. ready fires after the canvas has actually painted. If you remove your own loading placeholder on mount, users see blocks appearing one after another, which reads as breakage.
If your own placeholder outranks the editor’s z-9000 loading overlay, drop yours. It is the same overlay, already themed with your tokens, and it knows when the canvas is genuinely ready.
|
The editor is desktop-only and shows a notice below the breakpoint rather than attempting mobile drag-and-drop.
Extension points
Four registries. All are module-level — call them at import time, not inside a component.
| Registry | Purpose |
|---|---|
|
Add a custom block type. See Custom blocks. |
|
Add an ESP conditional syntax the 14 built-ins do not cover. See ESP syntaxes. |
|
Make your own fields discoverable in the merge-tag picker. |
|
Persist anywhere. See Storage adapters. |
import { registerBlock, registerESPSyntax, registerMergeTags } from "@maildeno/editor"
registerMergeTags([
{ key: "customer.first_name" },
{ key: "order.total" },
])
Export targets
The same document exports to four formats:
editor.getHtml() // production-ready HTML email
editor.getMjml() // MJML source
editor.getReactEmail() // React Email .tsx
editor.getJson() // portable template document
Store the JSON. HTML is a build artifact — you will want to regenerate it with a newer engine one day.
Prune or wrap
When a template uses conditional visibility, HTML can be produced two ways:
editor.getHtml("prune") // evaluate now, emit only matching branches
editor.getHtml("wrap") // keep every branch, emit the ESP's own {% if %} syntax
Use prune when the personalisation data lives in your database — smaller email, and your ESP needs no conditional support. Use wrap when the data lives in the ESP.
Most teams end up wrapping because it is what their ESP’s editor produced, then discover their conditions cannot express what they need. It is worth an hour of thought up front.
From editor output to a rendered email
Exporting Export → JSON produces the same template document used everywhere in Maildeno, so it plugs directly into either rendering path:
-
Offline, no API key:
@maildeno/renderer -
Through the hosted Render API: any of the hosted SDKs
Where to go next
-
Editor API reference — every prop, event, slot and method
-
Theming — the full token list
-
Custom blocks — a worked example
-
ESP syntaxes — the 14 built-ins and how to add one
-
Framework guides — React, Vue, Next.js, Nuxt, Svelte, Angular, Astro, vanilla