npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

esender-email-editor

v2.1.0

Published

Esender drag-and-drop email editor for React + Ant Design: JSON design model, email-safe HTML export, merge tags, backend-licensed exports.

Downloads

196

Readme

esender-email-editor

A drag-and-drop email editor for React, built on Ant Design. Drop it into your app, hand it an editor API key from your Esender project, and your users can design responsive, email-safe campaigns — rows, columns, text, images, buttons, lists, tables, dividers, spacers, social links, icons, menus, video, GIFs, stickers and raw HTML — with undo/redo, a mobile preview and merge tags. The design is a plain JSON tree you can store anywhere; HTML and JSON exports are produced by the Esender backend according to your account's plan.

Package name. This package is published as esender-email-editor. Earlier private builds were tagged @bitbeast/email-editor; that name is no longer used or supported.


Contents


Features

  • Visual editing — drag blocks and row layouts from a palette onto a canvas; edit titles, paragraphs and buttons in place with a rich-text toolbar; every other block through a properties panel.
  • A JSON design model — the whole document is one plain object ({ settings, rows: [{ columns: [{ blocks }] }] }) you can persist, diff and reload with loadJson().
  • Email-safe HTML — table-based layout, inline styles, a single media query for mobile stacking, and a sanitiser applied to user-authored HTML.
  • Undo / redo history with a timeline of named steps.
  • Desktop / mobile preview with merge-tag sample values substituted.
  • Merge tags — {{first_name}}-style tokens with a built-in catalogue, custom variables, and unknown-token highlighting.
  • Responsive editor chrome — the panel docks or becomes a drawer based on the editor's own width, so it works inside a narrow column of your page.
  • Backend-licensed exports — HTML/JSON downloads are produced by the Esender backend, which enforces the account's plan, export quota and branding policy.
  • Media uploads and AI assistance when the account's plan includes them.

Requirements

| Requirement | Supported range | | ------------------- | ---------------------------------- | | Node.js (build) | >= 20 | | react | >= 18.0.0 < 20.0.0 | | react-dom | >= 18.0.0 < 20.0.0 | | antd | >= 5.0.0 < 6.0.0 | | @ant-design/icons | >= 5.0.0 < 7.0.0 | | html2canvas | >= 1.0.0 < 2.0.0 — optional | | Browser | Modern evergreen browsers (ES2020) |

The editor renders inside your own Ant Design ConfigProvider (if you use one), so it follows your theme.

Installation

npm install esender-email-editor

Peer dependencies — install the ones your app does not already have:

npm install react react-dom antd @ant-design/icons

The DnD Kit packages (@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities) and @ant-design/x are regular dependencies of this package and are installed automatically; you do not need to add them. They are private to the editor, so they can coexist with different versions of the same packages in your app.

html2canvas is an optional peer and is not needed for any documented feature (see Optional html2canvas).

Ant Design reset styles

The editor styles itself at runtime (Ant Design v5 CSS-in-JS plus a runtime <style> for the canvas), so there is no package stylesheet to import. Import Ant Design's reset once in your app, as you would for any antd project:

import 'antd/dist/reset.css';

Quick start

import { useRef } from 'react';
import { EmailEditor, type EmailEditorHandle } from 'esender-email-editor';
import 'antd/dist/reset.css';

export function Composer() {
  const ref = useRef<EmailEditorHandle>(null);

  const download = async () => {
    // Produced by the Esender backend: metered against the account's export
    // quota, with the plan's branding policy applied.
    const { html, filename } = await ref.current!.exportHtml();
    // …save or send `html`
  };

  return (
    <div style={{ height: '100vh' }}>
      <button onClick={download}>Download HTML</button>
      <EmailEditor
        ref={ref}
        licensing={{
          apiKey: process.env.EDITOR_API_KEY!, // eed_live_…  (publishable, project-scoped)
          projectId: process.env.EDITOR_PROJECT_ID! // prj_…
        }}
      />
    </div>
  );
}

The editor fills its container, so give the container a height.

Licensing configuration

The editor always runs against the Esender backend, and always with a short-lived session token that is kept in memory only — never in localStorage, sessionStorage, cookies or the design. Everything the editor may do — save templates, export, upload media, use AI, remove branding — follows the account's plan as the backend reports it. There are two ways to open that session:

| Mode | licensing | Who opens the session | Use it when | | ------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | API key | { apiKey, projectId } | The editor, with the project's publishable editor API key, from a domain the project lists. | Embedding the editor in a site or product where one project's plan applies to all. | | Session | { getSession } | Your own authenticated backend, per logged-in user and selected project, through a callback you pass. | A multi-tenant app where each customer's own subscription decides what they get. |

API key mode

On mount the editor exchanges your editor API key for a session token and mounts only when the backend accepts the key from the page's domain.

<EmailEditor
  licensing={{
    apiKey: 'eed_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', // from the Esender dashboard
    projectId: 'prj_XXXXXXXXXXXXXXXXXXXXXXXX', // from the Esender dashboard
    apiBaseUrl: 'https://editor.esender.in', // optional — this is the default
    sdkVersion: undefined // optional — defaults to SDK_VERSION
  }}
/>

| Field | Required | Description | | ------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | | apiKey | yes | The project's editor API key (eed_live_…). It is a publishable, project-scoped identifier restricted to the domains you list. | | projectId | yes | The project id (prj_…). | | apiBaseUrl | no | Backend base URL. Defaults to DEFAULT_EDITOR_API_URL (https://editor.esender.in). A trailing slash is ignored. | | sdkVersion | no | Reported to the backend as the SDK version. Defaults to the exported SDK_VERSION. |

Never put secret server credentials in browser code. The editor API key is designed to be used from the browser: it is scoped to one project, only works from the domains registered for that project, and can be revoked from the dashboard. Management keys, dashboard logins, backend tokens and any other secret must stay on your server — do not pass them to the editor, bake them into a bundle, or expose them through window. Prefer supplying apiKey/projectId from your app's own configuration (as in the examples above) rather than committing literal values to source control.

If the key is missing, invalid, revoked, or the page's domain is not listed for the project, the editor does not mount; it shows an explanatory panel instead (see Troubleshooting).

Session mode (multi-tenant)

When every customer of your app should get their own plan's features — a Pro subscriber gets AI, a Basic subscriber does not — one shared API key is the wrong tool: whoever the key belongs to sets the entitlements for everyone. In session mode your backend opens the editor session instead. It knows who is logged in, which project they selected and what they are subscribed to, and it mints a short-lived editor token scoped to exactly that. You pass the editor a getSession() that fetches it:

import { useCallback, useRef } from 'react';
import { EmailEditor, type EmailEditorHandle, type EditorSession } from 'esender-email-editor';

export function Composer({ selectedProjectId }: { selectedProjectId: string }) {
  const ref = useRef<EmailEditorHandle>(null);

  // Your own authenticated API (the user's login is what authorises it).
  const getSession = useCallback(async (): Promise<EditorSession> => {
    const response = await fetch('/api/editor/session', {
      method: 'POST',
      credentials: 'include',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ projectId: selectedProjectId })
    });
    const body = await response.json();
    if (!body.status || !body.session) throw new Error(body.message || 'Unable to start editor session');
    return body.session;
  }, [selectedProjectId]);

  if (!selectedProjectId) return null; // nothing to authorise yet

  return (
    <EmailEditor
      key={selectedProjectId} // a new project is a new session
      ref={ref}
      licensing={{ getSession }}
    />
  );
}

getSession() resolves to:

{
  accessToken: string;   // the editor token; sent as `Authorization: Bearer …`, held in memory only
  expiresAt: string;     // ISO-8601; the editor renews shortly before it
  projectId: string;     // the project the token is scoped to
  entitlements: {        // the plan's feature flags; a missing flag is `false`
    aiText: boolean; aiSubject: boolean; aiRewrite: boolean; aiTranslate: boolean;
    exportHtml: boolean; exportJson: boolean; watermarkRemoval: boolean;
    // optional extras the editor also understands:
    saveTemplate?: boolean; mediaLibrary?: boolean; customAi?: boolean;
  };
  usage?: { aiCreditsRemaining?: number; aiCreditsLimit?: number };
}

What the editor does with it:

  • On mount it calls getSession() once and shows "Authorizing editor…" until it resolves. If it rejects, the editor does not mount; the panel shows your error's message and a Try again button.
  • On every protected request it sends the access token as a bearer token. When the backend answers 401 or SESSION_EXPIRED, it calls getSession() once and retries the request once. A 403 (PLAN_FEATURE_DENIED, INSUFFICIENT_CREDITS, …) is final — no refresh is attempted, because a new session would not change the plan.
  • Shortly before expiresAt it calls getSession() again so an active user never sees a failed save. If that fails, the editor stays mounted with the design intact, protected operations stop, and a banner offers Reconnect. Nothing is retried in a loop.
  • Entitlements decide what is shown: AI controls appear only for the tasks the flags allow (aiText for generation, aiRewrite for rewriting, aiSubject for subject lines, aiTranslate for translation), exports need exportHtml / exportJson, and branding is added unless watermarkRemoval is true. This is a convenience, not the security boundary: the backend validates entitlements and credits on each request.
  • ref.current.refreshLicense() asks for a fresh session now and applies its entitlements immediately, without touching the design. Call it after a plan upgrade or downgrade (see below). A refresh that fails rejects and leaves a working session as it was.

Plan changes

// After your billing flow confirms an upgrade or downgrade:
await editorRef.current?.refreshLicense();

A Pro upgrade makes the AI controls available right away; a downgrade removes the controls the new plan lacks and re-adds branding if required, with the current design preserved. If the editor is not mounted when the plan changes, nothing is needed: it opens a fresh session the next time it mounts.

Switching projects

Give the editor a key that changes with the selected project (as in the example above). A new key remounts the editor, which opens a session for the new project; the old token is discarded with the old instance.

The backend is the authority. Never derive a plan or entitlements from anything the browser holds. Your /api/editor/session should identify the user from its own authentication, verify the project belongs to them, load the active subscription from its database and compute entitlements from server-side plan configuration. It must not accept a plan or entitlements in the request body. The editor's protected endpoints re-check entitlements and AI credits on every request, so a manipulated UI can enable a button but never a feature.

EmailEditor props

| Prop | Type | Required | Description | | --------------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------ | | licensing | EditorLicensing | yes | { apiKey, projectId } or { getSession } — see Licensing configuration. | | initialDesign | EmailDesign | no | A design to open with instead of the default starting layout. Normalised on load, so partial objects are accepted. | | ref | EmailEditorHandle | no | Gives you the imperative API below. |

EmailEditor is a forwardRef component and is also the package's default export.

EmailEditorHandle ref methods

| Method | Returns | Metered | Description | | ------------------------------ | ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------------- | | loadJson(json) | void | no | Replace the design. Accepts a JSON string or a parsed EmailDesign. | | loadHtml(html) | void | no | Import email HTML as editable blocks. | | refreshLicense() | Promise<void> | no | Fetch a fresh session and apply its entitlements now, keeping the design. Call after a plan change. | | exportHtml(options?) | Promise<HtmlExportResult> | yes | Export the current design as HTML. Does not save. | | exportJson(options?) | Promise<JsonExportResult> | yes | Export the current design as JSON. Does not save. | | export({ format, …options }) | Promise<ExportResult> | yes | The same, with the format chosen at call time ('html' default, or 'json'). | | exportAndSaveHtml(options?) | Promise<HtmlExportAndSaveResult> | yes | Save the design to the account's template, then export the saved version as HTML. | | exportAndSaveJson(options?) | Promise<JsonExportAndSaveResult> | yes | Save the design to the account's template, then export the saved version as JSON. | | getHtml() / getJson() | never returns | — | Removed. Always throw LocalExportRemovedError; see Migrating. |

"Metered" means the call consumes one unit of the account's export quota when it succeeds.

Loading a design (JSON)

// A parsed design object …
ref.current?.loadJson(savedDesign);

// … or the JSON string you stored earlier.
ref.current?.loadJson(savedJsonString);

Loading never contacts the backend and never counts as an export. The input is normalised — missing ids and style containers are filled in — so a design produced by an earlier version, or one you assembled by hand, loads safely. A string that is not valid JSON throws synchronously.

A minimal hand-written design:

const design = {
  settings: { subject: 'Welcome aboard' },
  rows: [
    {
      columns: [
        {
          width: 100,
          blocks: [
            { type: 'title', content: { text: 'Welcome to Acme' } },
            { type: 'paragraph', content: { html: '<p>Hi {{first_name}}, thanks for joining.</p>' } },
            { type: 'button', content: { text: 'Get started' }, action: { href: 'https://acme.example' } }
          ]
        }
      ]
    }
  ]
};

Loading HTML

ref.current?.loadHtml('<h1>Hello</h1><p>Imported from an older campaign.</p>');

Supported markup becomes editable blocks; anything the importer cannot read is kept verbatim in a single HTML block, so nothing is lost. The import is one undo step, labelled "Imported HTML". User-authored HTML is sanitised before it is rendered on the canvas and again on export.

Exporting HTML

const result = await ref.current!.exportHtml();
// result: { format: 'html', html, filename, subject?, text?, usage, replayed }
  • html — email-safe HTML with the account's branding policy already applied by the backend.
  • filename — a safe file name, e.g. email.html.
  • subject / text — the subject and plain-text alternative, when set.
  • usage — the account's usage after this export (see ExportUsage), or null.
  • replayed — true when the backend replayed an earlier export for the same idempotency key (see below).

exportHtml() exports the editor's current, in-memory design and nothing else: it never creates or updates a template, even when a template was saved earlier in the session. Only the export quota is charged.

Exporting JSON

const { design, filename, usage } = await ref.current!.exportJson();
// design: EmailDesign — the tree with the plan's branding policy applied

Same rules as exportHtml(): current design, no template written, one export unit charged. The returned design is exactly what loadJson() accepts.

Save and export

const { html, templateId } = await ref.current!.exportAndSaveHtml();
const { design, templateId: sameId } = await ref.current!.exportAndSaveJson();

These save first, then export the saved version:

  1. The design is saved to the account's template — created on the first call, updated in place on later calls in the same editor session, with optimistic version checking so a stale write from another tab is refused (VERSION_CONFLICT).
  2. That stored template is exported.

They need the plan's save entitlement as well as its export entitlement, they occupy a template slot on plans that limit templates, and they charge one export unit per successful call. The result is the export result plus templateId (a string). Keep templateId if you want to associate the template with a record in your own system.

Loading before the editor is ready

The ref is available as soon as <EmailEditor> renders — before the backend session is authorized and the editor itself mounts. A loadJson() / loadHtml() made in that window (typically from your own mount effect) is retained and applied once, in call order, the moment the editor is ready. You never need to poll for readiness or call it twice.

useEffect(() => {
  // Safe even though the session may still be authorizing.
  ref.current?.loadJson(templateFromMyApi);
}, [templateFromMyApi]);

Once mounted, loads are synchronous: an export in the same tick sees the new design. The export methods (and the removed getHtml() / getJson()) need a mounted editor; called before that, the async ones reject and the sync ones throw an ApiError with code NOT_READY.

Idempotency keys and retries

Every export carries an idempotency key. If you omit it, a fresh one is made per call, and a request that gets no answer at all (a network error or a timeout — never an answered refusal) is retried once with the same key, so a slow response that did count an export cannot be counted twice.

To make your own retry safe, pass the same key again:

const key = crypto.randomUUID();
try {
  return await ref.current!.exportHtml({ idempotencyKey: key });
} catch (err) {
  if (err instanceof ApiError && (err.code === 'NETWORK_ERROR' || err.code === 'TIMEOUT')) {
    // Same key → the backend replays the first result (replayed: true) instead of charging again.
    return await ref.current!.exportHtml({ idempotencyKey: key });
  }
  throw err;
}

For the save-and-export methods a retry saves again (saves are never metered) but the export replays. Every export method also accepts signal?: AbortSignal.

Errors

import { ApiError, LocalExportRemovedError } from 'esender-email-editor';

ApiError

Every rejected export is an ApiError with:

| Property | Type | Description | | ---------------- | ---------------- | --------------------------------------------------------------------------- | | code | string | A stable code to branch on (below). | | message | string | Human-readable, safe to show. | | status | number | HTTP status, or 0 when no response arrived or the check was made locally. | | details | unknown | Extra data for some codes (e.g. { feature }, { limit, max, used }). | | requestId | string \| null | Correlates a backend failure with its logs. | | isSessionError | boolean | true for 401 responses. |

Codes you should handle:

| Code | Meaning | | --------------------------- | ----------------------------------------------------------------------------------------------------------------- | | USAGE_LIMIT_EXCEEDED | The account's export (or template) allowance is spent. details.limit says which. | | PLAN_FEATURE_DENIED | The plan does not include this feature (details.feature: exportHtml, exportJson, saveTemplate, …). | | SESSION_EXPIRED | The session ended and could not be renewed. The editor shows a Reconnect banner. | | NOT_AUTHORIZED | The editor is not authorized (the session never became ready). | | NOT_READY | Called before the editor mounted (see Loading before the editor is ready). | | NETWORK_ERROR / TIMEOUT | No response arrived. Safe to retry with the same idempotency key. | | VERSION_CONFLICT | Save-and-export: the template was modified elsewhere since this session last saved it. | | EXPORT_VALIDATION_FAILED | The backend refused the design (details.errors lists the problems). | | INVALID_FORMAT | export({ format }) was given something other than 'html' or 'json'. |

import { ApiError } from 'esender-email-editor';

try {
  const { html } = await ref.current!.exportHtml();
  send(html);
} catch (err) {
  if (err instanceof ApiError) {
    switch (err.code) {
      case 'USAGE_LIMIT_EXCEEDED':
        showUpgradePrompt();
        break;
      case 'PLAN_FEATURE_DENIED':
        showPlanNotice(err.details);
        break;
      case 'SESSION_EXPIRED':
        // The editor keeps the user's work and offers "Reconnect".
        break;
      default:
        report(err.code, err.requestId);
    }
  } else {
    throw err;
  }
}

LocalExportRemovedError

Thrown by getHtml() and getJson(), which no longer exist in the published package (code: 'LOCAL_EXPORT_REMOVED'). See Migrating.

Authorization, plans and quotas

  • Authorization is the backend's decision. The editor mounts only after the backend accepts the key for the page's domain (key mode) or your backend returns a session for the user and project (session mode); the session token lives in memory and expires on its own. An expired session is renewed once automatically when a request fails with 401 or SESSION_EXPIRED; if renewal fails, the editor stays mounted so no work is lost, protected operations stop, and a banner offers Reconnect.
  • Entitlements come from the plan and are re-read by the backend on every request, so a plan change takes effect on the next call. The editor mirrors the entitlements it was given to hide controls early, but the backend is the authority.
  • Exports are metered on the backend. Free accounts have a lifetime HTML/JSON export allowance and a template allowance; Basic and Pro accounts export without limit. Both the export-only and the save-and-export methods go through the same backend pipeline, so the quota is counted and branding applied whichever you use. The in-editor preview never counts as an export, and neither does loading a design.
  • Media uploads (Basic/Pro) go through the backend's presigned upload and count against the account's storage quota; no storage credential reaches the browser. Free accounts see an upgrade note and can still use images by URL.
  • AI assistance is available on plans that include it.

Branding

On a plan that does not permit branding removal, every design carries a locked "Designed with Esender" row as its last row. It is shown on the canvas, cannot be moved, edited or deleted, and is applied by the backend to every export whatever the browser sent. Plans that permit removal can remove the row from the editor, and their exports are left as designed.

Merge tags

Merge tags are plain-text {{snake_case}} tokens — they survive sanitising, JSON round-trips and every email client. A built-in catalogue covers contact, company, campaign, date and special-link tokens (such as {{unsubscribe_url}}); custom variables can be added under the editor's template settings. Unknown tokens are highlighted as the user types. The preview substitutes sample values; exports never do — tokens reach the exported HTML verbatim for your sending system to fill.

Optional html2canvas

html2canvas is declared as an optional peer dependency, reserved for rendering PNG thumbnails of a design. It is only ever loaded on demand, and none of the public methods documented here trigger it, so you can leave it uninstalled: nothing in the editor will fail or warn. If a future feature that needs it is used without the package installed, that call throws a clear "install html2canvas to enable this" error rather than failing silently.

Migrating from getHtml() / getJson()

Earlier builds exposed synchronous, browser-local reads that bypassed the account's export quota and branding policy. They are removed from the published package — calling either throws LocalExportRemovedError.

- const html = ref.current.getHtml();
+ const { html } = await ref.current.exportHtml();
- const json = ref.current.getJson();
+ const { design } = await ref.current.exportJson();

If you relied on an export also saving the design, use the save-and-export methods instead:

- const { html } = await ref.current.exportHtml();      // used to create/update a template as a side effect
+ const { html, templateId } = await ref.current.exportAndSaveHtml();

loadJson() / loadHtml() are unchanged.

ESM and CommonJS

The package ships both an ES module and a CommonJS build with identical public APIs, selected automatically through package.json exports.

// ESM / bundlers / TypeScript
import { EmailEditor, ApiError, SDK_VERSION } from 'esender-email-editor';
import EmailEditorDefault from 'esender-email-editor'; // same component as the named export
// CommonJS
const { EmailEditor, ApiError } = require('esender-email-editor');

Exports:

| Export | Kind | Description | | ------------------------- | --------- | ------------------------------------------------------- | | EmailEditor (default) | component | The editor. | | ApiError | class | Backend / export failure. | | LocalExportRemovedError | class | Thrown by the removed synchronous reads. | | DEFAULT_EDITOR_API_URL | string | https://editor.esender.in — the default apiBaseUrl. | | SDK_VERSION | string | The package version, reported to the backend. |

TypeScript

Type declarations ship with the package; no @types package is needed.

import type {
  EmailEditorProps,
  EmailEditorHandle,
  EditorLicensing,
  HostedLicensing,
  SessionLicensing,
  EditorSession,
  EmailDesign,
  ExportOptions,
  ExportUsage,
  ExportResult,
  HtmlExportResult,
  JsonExportResult,
  ExportAndSaveResult,
  HtmlExportAndSaveResult,
  JsonExportAndSaveResult
} from 'esender-email-editor';

| Type | Shape | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | EmailDesign | { settings?: Record<string, unknown>; rows: Array<Record<string, unknown>>; … } | | HostedLicensing | { apiKey: string; projectId: string; apiBaseUrl?: string; sdkVersion?: string } | | SessionLicensing | { getSession: () => Promise<EditorSession>; apiBaseUrl?: string; sdkVersion?: string } | | EditorSession | { accessToken; expiresAt; projectId; entitlements: Record<string, boolean>; usage?: { aiCreditsRemaining?; aiCreditsLimit? } } | | EditorLicensing | HostedLicensing \| SessionLicensing | | EmailEditorProps | { licensing: EditorLicensing; initialDesign?: EmailDesign } | | ExportOptions | { idempotencyKey?: string; signal?: AbortSignal } | | ExportUsage | { exportsUsed, exportLimit, exportsRemaining: number \| null, templatesUsed?, templateLimit?, storageUsedBytes?, storageLimitBytes? } — -1 means unlimited | | HtmlExportResult | { format: 'html'; html; filename; subject?; text?; usage: ExportUsage \| null; replayed: boolean } | | JsonExportResult | { format: 'json'; design: EmailDesign; filename; usage; replayed } | | HtmlExportAndSaveResult / JsonExportAndSaveResult | The matching export result plus templateId: string. |

export() is overloaded so the result type follows the format:

const h = await ref.current!.export(); // HtmlExportResult
const j = await ref.current!.export({ format: 'json' }); // JsonExportResult

Browser and runtime expectations

  • The bundle targets ES2020 and expects a browser environment (window, document, fetch, AbortController). It is a client component: render it only in the browser (in frameworks with server rendering, load it client-side only).
  • Exactly one copy of react and react-dom must be present (see Troubleshooting).
  • The editor talks to apiBaseUrl (default https://editor.esender.in) over HTTPS with fetch; requests carry the session bearer token and never cookies. Allow that origin in any Content Security Policy (connect-src).
  • The editor measures its own container to choose its layout, so it works at any width; give the container a height.

Troubleshooting

"Invalid hook call" / two Reacts / hooks errors — the host and the editor must share one react and one react-dom. Check npm ls react react-dom for duplicates; with linked or workspace packages, make sure the editor resolves React from your app (resolve.dedupe: ['react', 'react-dom'] in Vite, or resolve.alias in webpack).

Missing peer dependencies — npm install react react-dom antd @ant-design/icons. Ant Design 5 and icons 5/6 are required; the editor does not bundle them.

"Editor API key required" — licensing.apiKey or licensing.projectId is missing (and no licensing.getSession was given). Both come from the Esender dashboard.

"Editor session could not be started" — session mode: your getSession() rejected or returned something the editor cannot use (no accessToken, an expiresAt that is not a date or already past, no projectId). The panel shows your error's message; Try again calls getSession() once more.

"Access denied" — the key was not accepted: it may have been revoked, expired, or belong to a different project. Generate a new editor key in the dashboard and check the project id.

"This domain is not allowed" — the page's origin is not in the project's domain list. Add it (with the exact scheme/host/port you serve from, e.g. localhost:3000 for development) in the Esender dashboard.

"Editor service unavailable" — the backend could not be reached (network error or timeout). The panel offers Try again; check the connection, any proxy, and that apiBaseUrl is correct if you override it.

"Your editor session has ended" banner — the session expired and could not be renewed automatically (for example the key was revoked meanwhile). The user's work is intact; Reconnect opens a new session. Exports made in this state reject with SESSION_EXPIRED.

Export rejects with USAGE_LIMIT_EXCEEDED — the Free allowance is spent; result.usage on earlier successful exports tells you how much is left.

loadJson seems ignored right after mount — it is not: loads made before the editor is ready are applied once it mounts. If nothing appears, the editor did not mount (see the panels above), or the string was not valid JSON (that throws).

Thumbnails / html2canvas — not required for any documented feature. If you see "requires the optional html2canvas package", install it: npm install html2canvas.

Security considerations

  • Keep secrets on the server. Only the publishable editor API key belongs in the browser. It is domain-restricted and revocable, and it grants only what the account's plan allows — but treat any exposed key as replaceable.
  • The backend is the authority. Plan entitlements, export quotas and branding are enforced server-side on every request; the editor hides controls for convenience, never as the security boundary.
  • User content is sanitised. HTML authored in paragraph/HTML blocks or imported through loadHtml() is sanitised on the canvas and on export: scripts, event handlers, javascript:/vbscript:/data: URLs (except data:image/… in styles) and dangerous CSS are removed. Continue to treat exported HTML as user content in your own systems.
  • Uploads are validated twice. The picker refuses unsupported files client-side; the backend sniffs each upload's real content type before accepting it.
  • Nothing is persisted in the browser by the licensing layer: the session token, entitlements and usage live in memory only.
  • Obfuscation is not secrecy. The shipped bundle is minified and obfuscated as a deterrent to casual copying. It contains no credentials or privileged logic — anything sensitive lives on the backend — and you should not rely on obfuscation to hide anything you place in the browser either.
  • Network — all backend traffic goes to apiBaseUrl over HTTPS with a bearer token; no cookies are sent. Restrict connect-src accordingly.

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.

The same text is in the LICENSE file at the root of this package.