Skip to content
Mail Quilt
Esc
↑↓navigate↵open⌘Jpreview
On this page

Quick start

Add the Mail Quilt email editor to your app with a bundler, from a CDN with no build step, or in React, then save emails and export them as HTML.

Pick the way your app loads JavaScript. Each example does the same four things: it opens an email, saves every change in the browser, shows the reason if the editor refuses an email, and exports the email as HTML. Mail Quilt’s tests install each one against the built packages and run it in a browser, so the code works as written.

starterEmail is the email to open when nothing is saved yet. Use one your app already has, or copy the example email from the document format page.

If the editor will be a page of its own, the Shell gives you this page ready-made: the editor with a header, Export and Import buttons, messages and saving in the browser.

With a bundler

npm install @mailquilt/editor
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Email editor</title>
  </head>
  <body>
    <mail-quilt></mail-quilt>
    <button id="export" type="button">Export HTML</button>
    <output id="exported-html"></output>
    <output id="load-error"></output>
    <script type="module" src="./main.js"></script>
  </body>
</html>
import "@mailquilt/editor";
import { starterEmail } from "./starter-email.js";

const editor = document.querySelector("mail-quilt");
const saved = localStorage.getItem("email");

editor.addEventListener("mq-change", (event) => {
  localStorage.setItem("email", JSON.stringify(event.detail.document));
});
editor.addEventListener("mq-error", (event) => {
  document.querySelector("#load-error").textContent = event.detail.reason;
});
editor.value = saved ? JSON.parse(saved) : starterEmail;

document.querySelector("#export").addEventListener("click", async () => {
  document.querySelector("#exported-html").textContent =
    await editor.exportHtml();
});

Importing @mailquilt/editor registers the <mail-quilt> element. MJML, the library that turns an email into HTML, is large, so it only loads the first time someone opens Preview or you call exportHtml(). This example is tested with Vite.

Without a bundler

For a page with no build step, such as in Rails, Laravel or plain HTML, load the CDN build in a <script type="module">:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Email editor</title>
  </head>
  <body>
    <mail-quilt></mail-quilt>
    <button id="export" type="button">Export HTML</button>
    <output id="exported-html"></output>
    <output id="load-error"></output>
    <script type="module">
      import "https://cdn.jsdelivr.net/npm/@mailquilt/editor@0.1.0/dist/cdn/index.js";

      import { starterEmail } from "./starter-email.js";

      const editor = document.querySelector("mail-quilt");
      const saved = localStorage.getItem("email");

      editor.addEventListener("mq-change", (event) => {
        localStorage.setItem("email", JSON.stringify(event.detail.document));
      });
      editor.addEventListener("mq-error", (event) => {
        document.querySelector("#load-error").textContent = event.detail.reason;
      });
      editor.value = saved ? JSON.parse(saved) : starterEmail;

      document.querySelector("#export").addEventListener("click", async () => {
        document.querySelector("#exported-html").textContent =
          await editor.exportHtml();
      });
    </script>
  </body>
</html>
  • Use the full path to dist/cdn/index.js. A shorter URL, such as https://cdn.jsdelivr.net/npm/@mailquilt/editor, loads a different build that won’t run without a bundler.
  • Pin the version. MJML loads from a file beside the build, and its name can change between releases. An unpinned URL could ask for a file the latest release no longer has.

React

npm install @mailquilt/react

@mailquilt/react needs React 19. This example is a Client Component in a Next.js app:

"use client";

import { MailQuilt } from "@mailquilt/react";
import type { BlocksDocument, MailQuiltElement } from "@mailquilt/react";
import { useRef, useState } from "react";

import { starterEmail } from "./starter-email";

/** Nothing on the page shows the email until the browser has it, so this can't mismatch. */
const savedEmail = (): BlocksDocument | undefined => {
  if (typeof window === "undefined") {
    return;
  }
  const saved = localStorage.getItem("email");
  return saved ? JSON.parse(saved) : starterEmail;
};

export const Editor = () => {
  const editor = useRef<MailQuiltElement>(null);
  const [email, setEmail] = useState(savedEmail);
  const [html, setHtml] = useState("");
  const [loadError, setLoadError] = useState("");

  return (
    <main>
      <MailQuilt
        onChange={(next) => {
          localStorage.setItem("email", JSON.stringify(next));
          setEmail(next);
        }}
        onError={({ reason }) => setLoadError(reason)}
        ref={editor}
        value={email}
      />
      <button
        onClick={async () =>
          setHtml((await editor.current?.exportHtml()) ?? "")
        }
        type="button"
      >
        Export HTML
      </button>
      <output id="exported-html">{html}</output>
      <output id="load-error">{loadError}</output>
    </main>
  );
};
  • ref gives you the <mail-quilt> element once the editor has loaded, so you can call exportHtml().
  • On the server, MailQuilt renders an empty <mail-quilt>, and the editor loads in the browser. You can render it from a Server Component too, though you’ll need a Client Component like this one to pass it callbacks.

The editor’s API

<mail-quilt> has one property, two events and one method:

Name What it does
value The email the editor shows. Set it to open an email, or to undefined to empty the editor.
mq-change An event fired after every change. The new email is in event.detail.document.
mq-error An event fired when the editor refuses an email, which then keeps the email it had. The reason is in event.detail.reason. The document format page lists what the editor accepts.
exportHtml() Returns a promise of the email’s HTML, ready to send. It rejects if the editor has no email.

In React, these are the value, onChange and onError props, and exportHtml() on the ref.

Opening a different email clears undo history. Setting value to the email the editor last sent you changes nothing, even if you copied it or its keys come back in another order. That’s why the examples can save each change and pass it straight back.

Two things to get right:

  • Only pass back the latest email. An older one counts as a different email, so it opens and clears undo history. If you save each change to a server, don’t set value from each response, or a slow response could replace what someone typed since.
  • Listen for mq-error before the editor can refuse anything. It fires just after the code that sets value finishes, and it isn’t fired again. A listener added straight after setting value, as in the examples, hears it. One added later, such as in a React useEffect, misses it, and people see an empty editor with no explanation. The React component’s onError is always listening in time.

Content Security Policy

Preview shows the email in a sandboxed iframe, which follows your page’s Content Security Policy. Emails rely on inline styles, so Preview needs 'unsafe-inline' in style-src:

Content-Security-Policy: style-src 'self' 'unsafe-inline'

Without it, Preview shows the email without its styling. The editor and exportHtml() aren’t affected, and the exported HTML is unchanged.

A page using the CDN build also needs script-src to allow the folder the build is in, because the build is several files. The path must end in /:

Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net/npm/@mailquilt/editor@0.1.0/dist/cdn/

If it allows only dist/cdn/index.js, the editor doesn’t load. An inline script like the one in the CDN example also needs a nonce or hash, or you can move it into a file of your own.