vue-mail-editor
v1.3.0
Published
A customizable, extensible drag-and-drop email template editor for Vue 3.
Maintainers
Readme
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 vueQuick 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_atonLoad()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
