esender-email-editor
v1.3.9
Published
Drag and Drop Editor designed for mails in Ant Designac
Readme
esender-email-editor
A drag-and-drop MJML email editor for React 18 + Ant Design 5. The editor renders inside a single <Package /> component, exposes a small imperative API for getting / loading / saving templates, and gates access behind the eSender license system.
Scope: this package ships the UI, MJML pipeline, export logic, template save/load API client, and license handshake. Premium / business logic (template storage backend, AI integrations, billing) lives on the eSender backend.
Installation
The package is published on the public npm registry.
npm install esender-email-editorA transitive runtime dep (
react-giphy-searchbox, used by the Gif block) still declares an ancient[email protected]peer. To keep npm install clean without--legacy-peer-deps, add the followingoverridesblock to your host app'spackage.json. It pins the stale peer to your real React version — runtime is unaffected.{ "overrides": { "react-giphy-searchbox": { "react": "$react", "react-dom": "$react-dom" } } }Yarn users: use
"resolutions"with the same shape. pnpm users: use"pnpm.overrides".
Peer dependencies
The package externalizes its heavy runtime deps so the host app provides them. Peer ranges are intentionally permissive — any version within the declared range works, and hosts that already satisfy a peer can skip it.
npm install \
react@^18 react-dom@^18 \
antd@^5 @ant-design/icons@^5 @ant-design/pro-components@^2 \
@reduxjs/toolkit@^2 react-redux@^9 redux-persist@^6 \
lodash@^4 axios@^1 swr@^2 \
framer-motion@^12 html2canvas@^1 \
styled-components@^6 use-sync-external-store@^1
mjml-browserandcheerioare bundled inside the published package — you do not install them.
Supported version ranges
| Package | Declared range | Notes |
|---|---|---|
| react, react-dom | >=18.0.0 <19.0.0 | React 19 is not supported (see below). |
| antd | >=5.0.0 <6.0.0 | |
| @ant-design/icons | >=5.0.0 <6.0.0 | v6 conflicts with pro-components. |
| @ant-design/pro-components | >=2.0.0 <3.0.0 | |
| @reduxjs/toolkit | >=2.0.0 <3.0.0 | |
| react-redux | >=8.0.0 <10.0.0 | v8 and v9 both work under React 18. |
| redux-persist | >=6.0.0 <7.0.0 | |
| lodash | >=4.17.0 <5.0.0 | |
| axios | >=1.0.0 <2.0.0 | |
| swr | >=2.0.0 <3.0.0 | |
| framer-motion | >=10.0.0 <13.0.0 | v10+; older majors removed motion imports we use. |
| html2canvas | >=1.0.0 <2.0.0 | |
| styled-components | >=5.0.0 <7.0.0 | v5 and v6 both work. |
| use-sync-external-store | >=1.0.0 <2.0.0 | |
| mjml-browser | bundled | Shipped inside dist/; host does not install. |
| cheerio | bundled | Shipped inside dist/ alongside mjml-browser; host does not install. |
The non-negotiables
| Constraint | Why |
|---|---|
| React must be 18.x (not 19) | antd v5 only officially supports React 16–18. Under React 19 you will see [antd: compatible] warnings and cascading hook bugs in dropdown / tooltip / message. |
| Single React copy in node_modules | Two copies of React → Invalid hook call. Run npm ls react — it must show exactly one version. For Vite hosts use resolve.dedupe. |
| @ant-design/icons stays on v5 | @ant-design/pro-components resolves icons v5. Installing v6 can cause runtime mismatches. |
Quick start
import { useRef } from 'react';
import Package, { type EditorHandle, type LicenseError } from 'esender-email-editor';
import 'esender-email-editor/styles.css';
export const Composer = () => {
const ref = useRef<EditorHandle>(null);
const projectId = 'proj_123';
const create = async () => {
// Omit templateId → server creates a new template.
const res = await ref.current?.saveTemplate({ projectId });
if (!res?.status) console.error(res?.message);
};
const update = async (templateId: string) => {
// Pass templateId → server updates the existing template.
await ref.current?.saveTemplate({ projectId, templateId });
};
const load = async (templateId?: string) => {
// Omit templateId → loads the project's current template.
await ref.current?.loadTemplate({ projectId, templateId });
};
return (
<>
<button onClick={() => console.log(ref.current?.getHtml())}>Export HTML</button>
<button onClick={() => console.log(ref.current?.getJson())}>Export JSON</button>
<button onClick={create}>Create</button>
<button onClick={() => load()}>Load latest</button>
<Package
ref={ref}
apiKey={process.env.NEXT_PUBLIC_ESENDER_KEY!}
onLicenseError={(err: LicenseError) => console.error(err)}
onSave={(e) => console.log('saved', e.templateId, e.response)}
onLoad={(e) => console.log('loaded', e.response)}
onTemplateChange={(json) => console.log('changed', json)}
showUndoRedo
/>
</>
);
};Package is the default export. Named import ({ Package }) also works.
Public API
<Package /> props
| Prop | Type | Required | Description |
|---|---|---|---|
| apiKey | string | yes | License key. Exchanged for a JWT on mount. |
| customButtons | ReactNode | no | Slot rendered in the editor's top-right toolbar. |
| showUndoRedo | boolean | no | Show the undo/redo toolbar (default true). |
| onLicenseError | (error: LicenseError) => void | no | Fires when the license verify call fails. |
| onSave | (event: SaveEvent) => void | no | Fires after a successful saveTemplate() call. |
| onLoad | (event: LoadEvent) => void | no | Fires after a successful loadTemplate() call. |
| onTemplateChange | (json: unknown) => void | no | Fires on every edit (the exported JSON object — not stringified). |
| onHtmlExport | (html: string) => void | no | Fires when ref.current.getHtml() is invoked. |
| onMediaUpload | (event: MediaUploadEvent) => void | no | Fires after a successful media upload. |
| projectId | string | no | Deprecated. Ignored at runtime. Kept only for migration. |
EditorHandle (ref.current)
| Method | Returns | Description |
|---|---|---|
| getJson() | string | Stringified MJML JSON tree (export-ready). |
| getHtml() | string | Compiled email HTML via mjml-browser. Triggers onHtmlExport. |
| loadJson(json, raw?) | void | Load a previously saved template. json accepts either the stringified output of getJson() or a deserialized object. Pass null to clear the canvas. |
| loadHtml(html) | void | Load raw HTML into the editor. The HTML is wrapped in an MJML mj-raw block and rendered verbatim. The editor cannot break arbitrary HTML back into editable blocks — for full editability, prefer loadJson with a previously saved template. |
| saveTemplate({ projectId, templateId? }) | Promise<TemplateApiResponse> | Create or update a template. Omit templateId to create; include it to update. POSTs to https://esender.in/api/license/template/create. |
| loadTemplate({ projectId, templateId? }) | Promise<TemplateApiResponse> | GET from https://esender.in/api/license/template/:projectId[/:templateId] and pipe the JSON back into loadJson(...). |
Backend
The editor talks to the hosted eSender API at https://esender.in/api. Endpoints are fixed and not configurable; consumers only supply apiKey.
Events
All callbacks are optional. Pass any subset.
| Callback | Argument | Fires when |
|---|---|---|
| onSave | { projectId, templateId?, response } | saveTemplate() returns status: true. |
| onLoad | { projectId, templateId?, response } | loadTemplate() returns status: true and the JSON is applied. |
| onTemplateChange | The exported JSON object | The editor's MJML tree changes (every edit, drag/drop, undo/redo, loadJson). |
| onHtmlExport | string (the HTML) | A consumer calls ref.current.getHtml(). |
| onMediaUpload | { url, file?, response? } | The uploader receives a hosted URL from uploadMediaUrl. |
Callbacks are stored internally in a ref, so inline arrow functions are safe — they will not cause spurious re-fires.
Theme isolation
The editor does not mount its own <ConfigProvider> or antd <App />. It inherits the host application's antd theme, locale, and static-method context — nothing the editor renders will override your colors, components, or token configuration.
All editor-specific CSS lives under a single .aee-root class scope (the wrapper div around the editor), so SCSS overrides and resets cannot reach host components either.
License handshake
The editor will not render until the eSender license is verified:
- Host passes
apiKeyto<Package />. - On mount, the package checks
localStoragefor a cached access token. - If absent, it
POSTs tohttps://esender.in/api/license/verifywithx-api-key: <apiKey>. - On success the returned
accessTokenandrefreshTokenare stored inlocalStorageand the editor mounts. - While running, the internal axios client attaches the access token to protected calls; on
401/token expiredit callshttps://esender.in/api/license/refreshonce and retries. If refresh fails, tokens are cleared and the next mount re-runs verify.
No further token management is required from the host.
Styling
Import the bundled stylesheet once at your app root:
import 'esender-email-editor/styles.css';The package depends on Ant Design 5. Antd v5's runtime CSS-in-JS handles its own component styles, so you do not need a separate antd reset. The bundled styles.css only contains editor chrome overrides (CKEditor 5, Quill, antd tweaks).
SSR / Next.js / Remix
Package touches the DOM, drag-and-drop, and localStorage, so it must render on the client only. Use dynamic import:
// Next.js (app/ or pages/)
import dynamic from 'next/dynamic';
const Package = dynamic(() => import('esender-email-editor'), { ssr: false });Module-level code in the package is SSR-safe (all window / document / localStorage access is typeof-guarded), so importing it on the server will not crash — only mounting it will.
Routing
Package mounts its own MemoryRouter internally so it can coexist with a host app's BrowserRouter (Next.js, react-router, Remix, etc.). The internal routes never affect the host URL. Do not wrap <Package /> in a second router — and do not need to.
Vite consumer config
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
},
optimizeDeps: {
include: [
'esender-email-editor',
'antd', '@ant-design/icons', '@ant-design/pro-components',
'react-is', 'react-redux',
'use-sync-external-store',
'use-sync-external-store/shim',
'use-sync-external-store/shim/with-selector',
'use-sync-external-store/with-selector',
'@reduxjs/toolkit', 'axios', 'framer-motion', 'html2canvas',
'lodash', 'redux-persist', 'styled-components', 'swr', 'classnames',
],
},
});After changing dep versions or reinstalling the package, restart with vite --force so the optimizer rebuilds.
Bundle
- Single ESM + CJS bundle (
dist/index.js,dist/index.cjs), tree-shakeable. - Type declarations at
dist/index.d.ts. - CSS extracted to
dist/styles.css(not auto-injected — import it once). - All peer deps (React, antd, mjml-browser, lodash, redux, etc.) are externalized.
console.*anddebuggerare stripped from the production bundle.- The bundle is minified by terser and obfuscated twice (rollup-time + post-build). Debug from source.
TypeScript surface
import Package, {
// value (also available as default)
// Package,
// types
type EditorHandle,
type PackageProps,
type SaveTemplateOptions,
type LoadTemplateOptions,
type LicenseError,
type LicenseErrorCode,
type VerifyLicenseResponse,
type RefreshTokenResponse,
type TemplateApiResponse,
type TemplateDocument,
type EditorEventCallbacks,
type SaveEvent,
type LoadEvent,
type MediaUploadEvent,
// convenience aliases
type EditorInstance, // = EditorHandle
type TemplateData, // = TemplateDocument
type TemplateJson, // shape of getJson()/loadJson() payload
type SaveTemplatePayload, // = SaveTemplateOptions
type LoadTemplatePayload, // = LoadTemplateOptions
} from 'esender-email-editor';Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Invalid hook call | Two copies of React are loaded. Run npm ls react — must show one version. Vite: add resolve.dedupe. |
| Canvas shows "Failed to render preview" | You're on an outdated version of the package. Update — current builds bundle mjml-browser + cheerio so this no longer requires a host install. |
| Vite: 'classnames' does not provide an export named 'default' | Vite hasn't pre-bundled antd's CJS transitive deps. Add the optimizeDeps.include list above, delete node_modules/.vite, and restart with vite --force. |
| antd [antd: compatible] React is 16 ~ 18 warning + broken dropdowns / tooltips | You are on React 19. Downgrade to React 18.3.1. |
| Editor shows "Editor unavailable" forever | License verify failed. Check the /license/verify network response, confirm apiKey, watch onLicenseError. |
| Editor renders fine then suddenly fails | Access token expired and refresh failed. Clear localStorage (esender.accessToken, esender.refreshToken) and reload. |
| You cannot render a <Router> inside another <Router> | You're on an outdated version. Update — current package uses MemoryRouter internally. |
| Renders during SSR throw | Mount the component client-side only (see Next.js example above). |
Author
bitbeast private limited
License
MIT License
Copyright (c) 2026 Bitbeast Private Limited
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
