- Force light mode (appearance: false) — no more dark theme by default - Fix feature card titles: ensure text is always readable (--vp-c-text-1) - Replace all 21 via.placeholder.com URLs with real picsum.photos images - Regenerate all 22 template HTML previews with real photos - Templates now show real stock photography instead of gray placeholders - Tests pass (147+)
@lab2view/vue-email-editor
A professional, extensible drag-and-drop email editor built with Vue 3 and MJML.
Design responsive HTML emails visually with 43 pre-built blocks, a plugin system, full i18n support, and a complete imperative API.
Free and open-source alternative to Unlayer, Beefree, and Stripo.
Screenshots
| Blocks & Layout | Ready-made Templates |
|---|---|
![]() |
![]() |
| Property Editing | Layers Panel |
|---|---|
![]() |
![]() |
Features
Visual Drag & Drop Builder
- Intuitive block-based editor with live preview
- Drag blocks from the sidebar onto the canvas
- Reorder and nest elements with visual drop indicators
- Responsive preview in Desktop, Tablet, and Mobile modes
- Iframe-isolated canvas for accurate email rendering
43 Pre-built Blocks
| Category | Count | Examples |
|---|---|---|
| Layout | 6 | 1-4 columns, sidebar left/right |
| Content | 7 | Text, Image, Button, Divider, Spacer, Social, Hero |
| Composite | 30 | Header, Hero Banner, Pricing, Testimonial, FAQ, Product Card, Footer, and more |
Rich Text Editing
Powered by TipTap, with inline formatting:
- Bold, Italic, Underline, Strikethrough
- Text alignment (left, center, right)
- Text color picker
- Link insertion and editing
MJML-Powered Rendering
- Real-time MJML to HTML compilation via mjml-browser
- 13 supported MJML node types
- Export as MJML, compiled HTML, or design JSON
- Legacy GrapesJS format detection for migration
Undo / Redo
- Full history with keyboard shortcuts (
Ctrl+Z/Ctrl+Shift+Z) - Reactive
canUndo/canRedostate
Merge Tags
- Insert dynamic variables into text content (
{{first_name}},{{company}}, etc.) - Categorized merge tag picker in the inline toolbar
- Visual chip rendering in the editor
- TipTap extension for atomic merge tag nodes
Conditional Content
- Show/hide email sections based on merge tag values
- Visual condition builder on any node (variable, operator, value)
- 6 operators: equals, not equals, contains, not contains, exists, not exists
- Exports as HTML conditional comments for ESP processing
AI Text Generation
- BYOAI (Bring Your Own AI) pattern — plug in any AI provider
- Generate, improve, shorten, expand, and translate text
- Custom prompt input for freeform generation
- Non-blocking async generation with loading state
Dark Mode Preview
- Toggle dark mode preview in the toolbar (Moon/Sun icon)
- Simulates email client dark mode behavior in the canvas
- Background inversion, text color adjustment, link color adaptation
ESP Export Presets
- Export HTML pre-formatted for 6 email service providers
- Mailchimp:
*|FNAME|*merge tags, auto-inject unsubscribe - SendGrid:
{{variable}}handlebars format - Brevo:
{{ contact.ATTRIBUTE }}format - AWS SES:
{{camelCase}}template variables - Postmark:
{{variable}}Mustache format - Resend:
{{variable}}format - Custom preset support for any ESP
Image Upload
- Drag-and-drop image upload with progress indicator
- Asset browser integration via callback
- File type validation and size limits
- Preview with change/remove actions
Property Editing
- 40+ editable MJML attributes across 11 property types
- Color pickers, padding controls, alignment selectors
- Global styles panel (email background, default font, preview text)
- Custom fonts support
22 Starter Templates
Professional email templates for common use cases: welcome, newsletter, e-commerce, abandoned cart, product launch, shipping notification, birthday, seasonal sale, SaaS onboarding, and more.
Installation
npm install @lab2view/vue-email-editor
Peer dependencies:
npm install vue@^3.4 mjml-browser@^4.15
Quick Start
<script setup lang="ts">
import { ref } from 'vue'
import { EmailEditor, FR_LABELS } from '@lab2view/vue-email-editor'
const editorRef = ref()
const mjml = ref('')
const html = ref('')
const designJson = ref()
</script>
<template>
<EmailEditor
ref="editorRef"
v-model="mjml"
:design-json="designJson"
:labels="FR_LABELS"
@update:compiled-html="html = $event"
@update:design-json="designJson = $event"
/>
</template>
Theming
Customize the editor appearance with the theme prop:
<EmailEditor
:theme="{
primaryColor: '#7C3AED',
primaryHover: '#6D28D9',
borderRadius: '8px',
fontFamily: 'Inter, sans-serif',
}"
/>
Available Theme Properties
| Property | Default | Description |
|---|---|---|
primaryColor |
#01A8AB |
Main accent color |
primaryHover |
#018F91 |
Hover state |
primaryActive |
#017375 |
Active/pressed state |
backgroundColor |
#ffffff |
Panel backgrounds |
borderColor |
#e5e7eb |
Border color |
textPrimary |
#1f2937 |
Primary text |
textSecondary |
#6b7280 |
Secondary text |
canvasBg |
#e5e7eb |
Canvas background |
selectionColor |
#01A8AB |
Selected node outline |
fontFamily |
System stack | UI font family |
fontSize |
13px |
UI font size |
borderRadius |
6px |
UI border radius |
See ThemeConfig in src/types.ts for the full list of 25 properties.
Internationalization (i18n)
The editor ships with English defaults and a complete French translation. Pass custom labels via the labels prop:
<script setup>
import { EmailEditor, FR_LABELS } from '@lab2view/vue-email-editor'
</script>
<!-- French UI -->
<EmailEditor :labels="FR_LABELS" />
<!-- Custom labels (partial override) -->
<EmailEditor :labels="{ editor_title: 'Mein Editor', undo: 'Ruckgangig' }" />
175+ label keys cover all UI elements: toolbar, sidebar tabs, block names, property labels, option values, and status messages. See EditorLabels in src/labels.ts.
Imperative API
Access the editor programmatically via template ref:
<script setup>
import { ref, onMounted } from 'vue'
import { EmailEditor } from '@lab2view/vue-email-editor'
const editor = ref()
onMounted(() => {
// Export
const mjml = editor.value.getMjml()
const html = editor.value.getHtml()
const json = editor.value.getDesignJson()
// History
editor.value.undo()
editor.value.redo()
// Selection
editor.value.selectNode('node-id')
const selected = editor.value.getSelectedNode()
editor.value.clearSelection()
// Manipulation
editor.value.deleteNode('node-id')
editor.value.duplicateNode('node-id')
editor.value.updateNodeAttribute('node-id', 'color', '#ff0000')
// Events
editor.value.on('editor:change', ({ document }) => {
console.log('Document changed', document)
})
})
</script>
Full API Reference
| Method | Returns | Description |
|---|---|---|
getDocument() |
EmailDocument |
Current document tree |
setDocument(doc) |
void |
Replace entire document |
getMjml() |
string |
Export as MJML string |
getHtml() |
string |
Export as compiled HTML |
getDesignJson() |
EmailDesignJson |
Export persisted format |
undo() |
void |
Undo last change |
redo() |
void |
Redo last undone change |
canUndo() |
boolean |
Whether undo is available |
canRedo() |
boolean |
Whether redo is available |
selectNode(id) |
void |
Select a node by ID |
getSelectedNode() |
EmailNode | null |
Get selected node |
clearSelection() |
void |
Deselect current node |
deleteNode(id) |
void |
Remove a node |
duplicateNode(id) |
NodeId | null |
Clone a node |
insertBlock(block, parentId, index?) |
NodeId | null |
Insert a block |
updateNodeAttribute(id, key, value) |
void |
Update an attribute |
loadTemplate(doc) |
void |
Load an EmailDocument |
on(event, handler) |
void |
Subscribe to event |
off(event, handler) |
void |
Unsubscribe from event |
Events
Subscribe to editor events for real-time notifications:
editor.value.on('node:selected', ({ nodeId, node }) => {
console.log('Selected:', node.type)
})
editor.value.on('editor:change', ({ document }) => {
// Auto-save
saveToServer(document)
})
| Event | Payload | Trigger |
|---|---|---|
editor:ready |
{ document } |
Editor initialized |
editor:change |
{ document } |
Any document change |
node:selected |
{ nodeId, node } |
Node selected |
node:deselected |
{ nodeId } |
Selection cleared |
node:deleted |
{ nodeId } |
Node removed |
node:moved |
{ nodeId, fromParentId, toParentId } |
Node repositioned |
node:duplicated |
{ originalId, newId } |
Node cloned |
block:dropped |
{ blockId, parentId } |
Block added from panel |
history:undo |
{ canUndo, canRedo } |
Undo performed |
history:redo |
{ canUndo, canRedo } |
Redo performed |
property:changed |
{ nodeId, key, value } |
Attribute updated |
Plugin System
Extend the editor with custom blocks, property editors, toolbar actions, and sidebar panels:
import type { Plugin } from '@lab2view/vue-email-editor'
import { createText } from '@lab2view/vue-email-editor'
const myPlugin: Plugin = (ctx) => {
// Add a custom block
ctx.registerBlock({
id: 'custom-banner',
label: 'Custom Banner',
category: 'content',
icon: 'Sparkles',
factory: () => createText('<h1>My Custom Block</h1>', {
align: 'center',
'font-size': '24px',
}),
})
// Add a new block category
ctx.registerBlockCategory({
id: 'my-category',
label: 'My Blocks',
icon: 'Package',
order: 50,
})
// Add a toolbar button
ctx.registerToolbarAction({
id: 'save',
label: 'Save',
icon: 'Save',
handler: () => console.log('Saving...'),
position: 'right',
})
// Listen to events
ctx.on('editor:change', ({ document }) => {
console.log('Auto-saving...')
})
}
<EmailEditor :plugins="[myPlugin]" />
Plugin Context API
| Method | Description |
|---|---|
registerBlock(block) |
Add a custom block to the blocks panel |
registerBlockCategory(category) |
Define a new block category |
registerPropertyEditor(type, component) |
Override a property editor component |
registerToolbarAction(action) |
Add a button to the toolbar |
registerSidebarPanel(panel) |
Add a custom sidebar tab/panel |
on(event, handler) |
Subscribe to editor events |
off(event, handler) |
Unsubscribe from events |
labels |
Reactive reference to current labels |
Exports
The package provides 35+ exports for advanced usage:
// Component
import { EmailEditor } from '@lab2view/vue-email-editor'
// Labels & i18n
import { DEFAULT_LABELS, FR_LABELS } from '@lab2view/vue-email-editor'
// Types
import type {
EmailDocument, EmailNode, EmailDesignJson, EmailEditorAPI,
Plugin, PluginContext, ThemeConfig, EditorEventMap,
BlockDefinition, PropertyDefinition,
} from '@lab2view/vue-email-editor'
// Serialization
import { compileMjml, documentToMjml, mjmlToDocument } from '@lab2view/vue-email-editor'
// Node factories
import {
createDefaultDocument, createSection, createColumn,
createText, createImage, createButton, createDivider,
createSpacer, createSocial, createHero, createWrapper,
} from '@lab2view/vue-email-editor'
// Tree utilities
import { findNode, findParent, removeNode, moveNode, cloneSubtree } from '@lab2view/vue-email-editor'
// Constants
import {
DEFAULT_THEME, STATIC_BLOCKS,
CONTENT_NODE_TYPES, CONTAINER_NODE_TYPES, SELF_CLOSING_NODE_TYPES,
} from '@lab2view/vue-email-editor'
// ESP Export Presets
import {
exportForEsp, exportForMailchimp, exportForSendGrid,
exportForBrevo, exportForAwsSes, exportForPostmark, exportForResend,
ESP_PRESETS, MAILCHIMP_PRESET, SENDGRID_PRESET,
} from '@lab2view/vue-email-editor'
Keyboard Shortcuts
| Shortcut | Action |
|---|---|
Ctrl+Z |
Undo |
Ctrl+Shift+Z |
Redo |
Ctrl+D |
Duplicate selected node |
Delete / Backspace |
Delete selected node |
Escape |
Deselect / close inline editor |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue |
string |
'' |
MJML content (v-model) |
designJson |
Record<string, unknown> |
— | Persisted design JSON |
variables |
string[] |
[] |
Available merge variables |
labels |
Partial<EditorLabels> |
DEFAULT_LABELS |
i18n label overrides |
label |
string |
— | Form field label |
required |
boolean |
false |
Form validation flag |
theme |
Partial<ThemeConfig> |
DEFAULT_THEME |
Visual customization |
plugins |
Plugin[] |
[] |
Editor extensions |
mergeTags |
MergeTag[] |
— | Dynamic variable tags |
aiProvider |
AiProvider |
— | AI text generation callbacks |
onImageUpload |
(file: File) => Promise<{ url }> |
— | Image upload handler |
onBrowseAssets |
() => Promise<string | null> |
— | Asset browser handler |
Merge Tags
Insert dynamic content with merge tag variables:
<EmailEditor
:merge-tags="[
{ name: 'First Name', value: '{{first_name}}', category: 'Contact' },
{ name: 'Company', value: '{{company}}', category: 'Contact' },
{ name: 'Unsubscribe', value: '{{unsubscribe_url}}', category: 'Links' },
]"
/>
AI Text Generation
Bring your own AI provider for inline text generation:
<EmailEditor
:ai-provider="{
generateText: async (prompt, context) => {
const response = await fetch('/api/ai/generate', {
method: 'POST',
body: JSON.stringify({ prompt, context }),
})
return (await response.json()).text
},
improveText: async (text, instruction) => {
const response = await fetch('/api/ai/improve', {
method: 'POST',
body: JSON.stringify({ text, instruction }),
})
return (await response.json()).text
},
}"
/>
ESP Export
Export HTML pre-formatted for your email service provider:
import {
exportForMailchimp,
exportForSendGrid,
exportForBrevo,
exportForAwsSes,
exportForEsp,
} from '@lab2view/vue-email-editor'
// Get the document from the editor
const document = editorRef.value.getDocument()
// Export for Mailchimp (transforms {{first_name}} → *|FNAME|*)
const { html, mjml } = await exportForMailchimp(document)
// Export for SendGrid
const result = await exportForSendGrid(document)
// Export with custom merge tag mappings
const custom = await exportForEsp(document, 'brevo', {
mergeTags: { plan_name: '{{ contact.PLAN }}' },
})
Development
# Type check
npm run typecheck
# Run tests (147 tests)
npm test
# Build library
npm run build
Tech Stack
- Vue 3 — Reactive component framework
- MJML — Email markup language
- TipTap — Rich text editing
- Lucide — Icon system (400+ icons)
- VueUse — Vue composable utilities
- TypeScript — Full type safety
- Vite — Build tooling
- Vitest — Testing framework




