Vue Mail Editor
A customizable, extensible drag-and-drop email template editor for Vue 3.
#vue-mail-editor
A customizable, extensible drag-and-drop email template editor for Vue 3. Theme it, register your own blocks and inspector panels, and delegate all persistence/actions to your backend.
📖 Documentation: csesumonpro.github.io/vue-mail-editor
— guides, API reference, and a live interactive demo. You can also run the docs
locally with npm run docs:dev (the docs/ directory is a
VitePress site).
npm install vue-mail-editor
// peer dependency
npm install vue
#Quick start
<script setup lang="ts"> import { ref } from 'vue' import { EmailEditor } from 'vue-mail-editor' import 'vue-mail-editor/style.css' import type { Design } from 'vue-mail-editor' const design = ref<Design>() </script> <template> <!-- give it a sized container; the editor fills 100% height --> <div style="height: 100vh"> <EmailEditor v-model="design" /> </div> </template>
The editor is fully self-contained — no Pinia, no global setup, no directive registration. You can mount several independent instances on one page.
#Props
| Prop | Type | Default | Description |
|---|---|---|---|
v-model |
Design |
— | Two-way design JSON (controlled mode). |
blocks |
BlockDefinition[] |
[] |
Custom blocks (merged with built-ins). |
disabledBlocks |
string[] |
[] |
Hide built-in block types. |
theme |
ThemeTokens |
— | Color/typography overrides (light + dark). |
colorMode |
'light' | 'dark' | 'auto' |
'light' |
Theme mode — two-way via v-model:colorMode. |
preview |
boolean |
— | Preview mode — two-way via v-model:preview. |
config |
EditorConfig |
— | Feature flags (devices, actions, templates…). |
storage |
'local' | 'none' |
'local' |
local = localStorage autosave; none = host owns persistence. |
onImageUpload |
(file: File) => Promise<string> |
base64 | Upload an image, return its URL. |
onSave |
(design) => void | Promise |
download | Handle Save Design. |
onSaveTemplate |
(payload) => void | Promise |
— | Handle Save as template. |
onExport |
(html, design) => void | Promise |
— | Receive exported HTML. |
onLoad |
() => Design | Promise<Design> |
— | Initial design (e.g. server fetch). |
#Events
update:modelValue, update:colorMode, update:preview, change(design),
save(design), save-template(payload), export(html, design),
select(selection), ready(api).
#Slots
#header (replace top bar), #header-brand, #header-actions (inject buttons),
#empty (empty-canvas state).
#Imperative API (@ready or template ref)
interface EditorApi { getDesign(): Design // deep-cloned snapshot loadDesign(design: Design): void newDesign(): void // clear to a fresh, empty design (no prompt) exportHtml(): string // returns email-safe HTML save(): void | Promise // fire Save flow (emit `save` + onSave) export(): void | Promise // fire Export flow (emit `export` + onExport) undo(): void; redo(): void registerBlock(def: BlockDefinition): void selectBody(): void }
Commands live on the API; UI state (dark/light, preview) is controlled via
v-model:colorModeandv-model:preview— one source of truth you can set and read.
#Build your own block
<!-- RatingBlock.vue --> <script setup lang="ts"> defineProps<{ values: { stars: number; color: string }; editing?: boolean }>() defineEmits<{ update: [patch: Partial<{ stars: number; color: string }>] }>() </script> <template> <div :style="{ textAlign: 'center', fontSize: '26px', color: values.color }"> <span v-for="n in values.stars" :key="n">★</span> </div> </template>
import { defineBlock } from 'vue-mail-editor' import { Star } from 'lucide-vue-next' import RatingBlock from './RatingBlock.vue' export const rating = defineBlock<{ stars: number; color: string }>({ type: 'rating', label: 'Rating', icon: Star, defaultValues: () => ({ stars: 5, color: '#f59e0b' }), render: RatingBlock, // canvas component (values/selected/editing + update) inspector: [ { title: 'Rating', controls: [ { type: 'number', key: 'stars', label: 'Stars', min: 1, max: 5 }, { type: 'color', key: 'color', label: 'Color' }, ] }, ], toHtml: (v) => `<div style="text-align:center;color:${v.color}">${'★'.repeat(v.stars)}</div>`, })
<EmailEditor :blocks="[rating]" />
Inspector controls (the allowed catalog): text, textarea, number,
slider, color, select, align, toggle, spacing, border, font,
link, image, background, list.
#Theming
Tokens are scoped to each editor instance, so multiple themes coexist.
<EmailEditor color-mode="auto" :theme="{ colors: { accent: '#7c3aed', primary: '#111827' }, dark: { accent: '#a78bfa' }, font: { sans: 'Inter, sans-serif', baseSize: '15px' }, }" />
You can also override --cvee-* CSS variables on a .vue-email-editor wrapper.
#Server-side (database) usage
<script setup lang="ts"> import { EmailEditor } from 'vue-mail-editor' async function load() { return (await api.get('/designs/1')).data } async function save(design) { await api.put('/designs/1', design) } async function upload(file) { return (await api.upload(file)).url } </script> <template> <EmailEditor storage="none" :on-load="load" :on-save="save" :on-image-upload="upload" @export="(html) => api.post('/render', { html })" > <template #header-actions> <button @click="publish">Publish</button> </template> </EmailEditor> </template>
With storage="none" nothing is written to localStorage — your database is the
single source of truth.
#What to persist
The design is plain JSON — store it in one column (jsonb/json/text).
The exported html is derived; only persist it if you want a ready-to-send
render cache.
designs: id | name | design (jsonb) | html (text, optional) | updated_at
onLoad()runs on mount → fetch and return the design JSON from your DB.onSave(design)runs when the user clicks Save → write the JSON to your DB.exportHtml()(via@ready) or@exportgives you the final email HTML.
#Server-side autosave
config.autosaveMsonly debounces the built-in localStorage autosave (storage="local"). Withstorage="none"it does nothing — you own the save cadence.
For continuous save-to-database, debounce the change event yourself:
<script setup lang="ts"> import { debounce } from 'lodash-es' import type { Design } from 'vue-mail-editor' async function load() { return (await api.get('/designs/1')).data.design } // this 1000ms is your real-DB "autosaveMs" — tune it freely const autosave = debounce((design: Design) => { api.put('/designs/1', { design }) }, 1000) </script> <template> <EmailEditor storage="none" :on-load="load" @change="autosave" /> </template>
change already fires debounced (~300ms) on every edit; the extra debounce
batches DB writes. Use onSave for an explicit "Save" button on top of (or
instead of) this.
#Config (feature flags)
const config: EditorConfig = { contentWidth: 640, devices: ['desktop', 'mobile'], actions: { import: false, saveTemplate: true }, templates: [ /* your starter templates */ ], autosaveMs: 1000, // localStorage debounce only; ignored when storage="none" }
#Customizing the top bar
Everything in the top bar is configurable from the host app — swap the logo, show/hide built-in actions, add your own buttons, or replace the bar entirely.
| Goal | How |
|---|---|
| Show/hide a built-in action | config.actions.<name> (boolean) |
| Rename a built-in label/tooltip | config.labels.<name> (string) |
| Replace the logo / brand | #header-brand slot |
| Add custom buttons (keep built-ins) | #header-actions slot |
| Fully custom toolbar (own labels/layout) | #header slot |
| Trigger the editor from your buttons | @ready="api = $event" (or events) |
#Show / hide built-in actions
<EmailEditor :config="{ actions: { undo: true, preview: true, theme: true, templates: false, new: false, import: false, save: true, saveTemplate: true, export: true, }, }" />
All default to true except saveTemplate (defaults false). Set false to hide.
#Rename built-in labels — config.labels
Relabel the built-in actions (button text + tooltips) without replacing the bar:
<EmailEditor :config="{ labels: { brand: 'Acme Mailer', save: 'Publish', export: 'Get HTML', saveTemplate: 'Save template', undo: 'Undo', redo: 'Redo', preview: 'Preview', templates: 'Templates', new: 'New design', import: 'Import JSON', }, }" />
Every label is optional — unset keys keep their defaults. brand only applies
when you haven't overridden the #header-brand slot.
#Replace the logo — #header-brand
<EmailEditor> <template #header-brand> <img src="/logo.svg" alt="Acme" style="height: 24px" /> </template> </EmailEditor>
#Add custom actions — #header-actions
Your buttons render next to the built-ins; wire them via the imperative API:
<script setup lang="ts"> import { ref } from 'vue' import { EmailEditor } from 'vue-mail-editor' import type { EditorApi } from 'vue-mail-editor' const api = ref<EditorApi>() function publish() { myBackend.publish(api.value!.exportHtml()) } </script> <template> <EmailEditor @ready="api = $event"> <template #header-actions> <button @click="publish">Publish</button> </template> </EmailEditor> </template>
#Fully custom toolbar — #header
Replace the whole bar (your own labels/layout) and drive it via the API:
<EmailEditor @ready="api = $event"> <template #header> <header class="my-toolbar"> <img src="/logo.svg" /> <button @click="api?.undo()">Undo</button> <button @click="api?.exportHtml()">Export</button> </header> </template> </EmailEditor>
With #header you own the markup, so wire every action through the
EditorApi (@ready) and/or the events.
#Notes
- The exported HTML is table-based with inlined styles + Outlook (MSO) conditionals + mobile media queries.
- Predefined social icons load from the Simple Icons CDN (SVG); for maximum client compatibility (e.g. Outlook) supply a custom PNG icon URL per item.
#License
MIT