Skip to content
Open-source SDK

The features other editors gate. In your repo. Open source.

The power features, and a clean set of essentials — all included, all open. Portable JSON in, MJML out, no usage tier in the way.

Open source · FSL-1.1-MIT · Free to self-host
Agent Skill

One skill for the template and the integration

An open-source Agent Skill that gives any AI coding agent both halves of the job: writing Templatical templates from a prompt, validated against the block schema before you ever see them, and wiring @templatical/editor into your own application. It routes each request itself — you never pick a mode. No backend, no API key, nothing sent to us.

A first draft in one sentence — and the integration that ships it, in the same session.

  • Runs on the agent you already use — the model is the inference
  • One command installs it into every skills-compatible agent on your machine
  • Every generated template is schema-validated and quality-linted
  • Live mode previews and hand-edits in the real editor, then reconciles
  • Dedicated converters for the major email builders — anything else it maps onto the block schema by hand
  • Mounts the editor in your app: proposes the change first, then checks it against your running dev server
  • Debugs an integration that misbehaves against a table of verified traps
Agent Skill guide
  • From scratch
    “A product-launch email for our new Pro tier — hero, three feature callouts, and a button to the changelog.”
  • Migrate
    “Import this Unlayer export and rebuild the image-only header as real text.”
  • Refine live
    “Show it live. The CTA is too quiet — make it the accent colour and move it above the fold.”
  • Integrate
    “Mount the editor in our Next.js admin and point save and load at our own API.”
  • Diagnose
    “The editor’s dropdowns render behind the modal it’s mounted in. Why?”
Extensibility

Custom blocks with API-backed data

Register your own block types — static templates or live data fetched from your API at preview time. Built in, not bolted on.

Ship CRM-aware blocks your team drops in without engineering tickets.

  • Per-field config: text, image, color, select, repeatable arrays
  • Static template or live API fetch at preview time
  • Liquid templates with conditionals and built-in filters
  • Type-safe block factories with full TypeScript types
See the block API
const editor = await init({
  container: '#editor',
  customBlocks: [
    {
      type: 'event-details',
      name: 'Event Details',
      description: 'Date, time, location, and a map link',
      fields: [
        { type: 'text',  key: 'eventName', label: 'Event Name', required: true },
        { type: 'text',  key: 'date',      label: 'Date',       default: 'April 15, 2026' },
        { type: 'text',  key: 'location',  label: 'Location' },
        { type: 'text',  key: 'mapUrl',    label: 'Map Link (optional)' },
        { type: 'color', key: 'accent',    label: 'Accent',     default: '#7c3aed' },
      ],
      template: `
        <div style="border: 2px solid {{ accent }}; padding: 20px; border-radius: 8px;">
          <h3 style="color: {{ accent }};">{{ eventName }}</h3>
          <p>📅 {{ date }} · 📍 {{ location }}</p>
          {% if mapUrl %}
            <a href="{{ mapUrl }}">View on Map →</a>
          {% endif %}
        </div>
      `,
    },
  ],
})
Personalization

Merge tags with pluggable syntax

Handlebars, Liquid, JS template literals, or your own — with human-readable labels rendered directly on the canvas. No vendor-locked syntax.

Build a CRM-aware tag picker in an afternoon, not a sprint.

  • Built-in syntaxes plus a hook for your own
  • Human-readable labels rendered directly on the canvas
  • Inline autocomplete — type the syntax opener to surface matching tags
  • Optional sample values render in previews instead of the label
  • Optional onRequest hook to swap the picker for your CRM UI
  • Round-trip safe — JSON stores the canonical token
Merge-tag reference
const editor = await init({
  container: '#editor',
  mergeTags: {
    syntax: 'liquid',
    // An optional sample is what previews render in place of the label,
    // so a preview reads like a delivered email instead of a list of
    // field names. Set one and a Sample / Label switch appears; set
    // none and the editor behaves exactly as it did before.
    tags: [
      { label: 'First name',      value: '{{first_name}}',  sample: 'Ada' },
      { label: 'Email',           value: '{{email}}',       sample: 'ada@example.com' },
      { label: 'Plan name',       value: '{{plan_name}}',   sample: 'Pro' },
      { label: 'Order ID',        value: '{{order_id}}',    sample: 'A-4417' },
      { label: 'Order total',     value: '{{order_total}}', sample: '$128.00' },
      // No sample — this one keeps its label and its highlight, so the
      // remaining highlights double as a list of what's still missing.
      { label: 'Unsubscribe URL', value: '{{unsubscribe_url}}' },
    ],
  },
})
Targeting

Display conditions

Show or hide blocks based on recipient attributes, with live preview in the editor. Built in, not a paid add-on.

Personalize without bolting on a separate targeting service.

  • Per-block show/hide rules from recipient attributes
  • Live preview while editing
  • allowCustom: true lets editors add conditions inline
  • Wrappers are opaque strings — any syntax your ESP evaluates at send time
Conditions guide
const editor = await init({
  container: '#editor',
  displayConditions: {
    conditions: [
      {
        label: 'VIP Partners',
        before: '{% if vip_partner %}',
        after: '{% endif %}',
        group: 'Audience',
        description: 'Show only to VIP partner accounts',
      },
      {
        label: 'Early Bird',
        before: '{% if early_bird %}',
        after: '{% endif %}',
        group: 'Registration',
      },
    ],
    allowCustom: true,
  },
})
Dynamic content

Loops and conditionals inside the copy

Register your template language’s control flow — Liquid, Handlebars, whatever you already send — and authors insert it from a picker. Tags render as styled pills in the rich text and pass through to the output untouched.

Authors write conditional copy without learning your syntax.

  • Wraps a phrase mid-sentence, where display conditions wrap a whole block
  • Standalone tags and open/close pairs, grouped in the picker
  • A pair wraps the current selection — no manual closing tag
  • Also available in inputs: button text, URLs, alt text
  • Passes through to the rendered MJML unchanged
  • Or hand off to your own picker with a single onRequest hook
Logic tags reference
const editor = await init({
  container: '#editor',
  logicTags: {
    tags: [{ label: 'Else', value: '{% else %}', group: 'Conditions' }],
    pairs: [
      {
        label: 'If VIP',
        before: '{% if customer.vip %}',
        after: '{% endif %}',
        group: 'Conditions',
        description: 'Show the wrapped copy only to VIP customers',
      },
      {
        label: 'Loop items',
        before: '{% for item in order.items %}',
        after: '{% endfor %}',
        group: 'Loops',
      },
    ],
  },
})
Preview

Previews with real data, resolved by your backend

The editor recognises merge tags and logic tags — it never evaluates them. Hand it a resolvePreview callback and whatever already renders your sends renders your previews too, with branches taken and data filled in.

A preview that agrees with the delivered email by construction, not by approximation.

  • Your engine, your data, your template language — nothing to reimplement in the browser
  • Evaluates logic that sample values structurally cannot — conditional branches collapse to the one that applies
  • Resolves for the selected recipient in the test-email dialog
  • Display-only — resolved content never reaches getContent(), export, or a send
  • A resolver outage degrades to the unresolved template and says so, never a blank preview
  • Never runs while editing — the canvas always shows the tag you inserted
Preview-rendering guide
const editor = await init({
  container: '#editor',
  // Called by preview surfaces only — never while editing. The
  // test-email dialog passes the selected address as `recipient`;
  // the editor's own preview mode has none.
  resolvePreview: async ({ content, recipient }) => {
    const data = recipient
      ? await fetchSubscriber(recipient)
      : await fetchSampleSubscriber()

    // Whatever renders your sends renders your previews.
    return renderWithMyEngine(content, data)
  },
})
Branding

Theming and brand defaults

27 OKLch tokens, custom fonts, dark mode, complete theme overrides. Every surface tokenized — and the same init() call sets the defaults every new template and block starts from.

The editor looks like your product, and every new block starts on-brand.

  • 27 OKLch design tokens covering every surface
  • Light + dark theme overrides via the same theme.dark key
  • Custom fonts via --tpl-font-sans and --tpl-font-mono
  • Tailwind 4 with `tpl:` prefix — no preflight, no style leaks
  • Per-block-type defaults: button, divider, spacer, image, social
  • Template defaults: width, background, font family
Theming & defaults reference
const editor = await init({
  container: '#editor',
  uiTheme: 'auto',
  theme: {
    '--tpl-color-primary':    '#0d9488',
    '--tpl-color-accent':     '#0ea5e9',
    '--tpl-color-background': '#ffffff',
    '--tpl-radius':           '10px',
    '--tpl-font-sans':        'Inter, system-ui, sans-serif',
    dark: {
      '--tpl-color-primary':    '#22d3ee',
      '--tpl-color-accent':     '#a78bfa',
      '--tpl-color-background': '#0b1220',
    },
  },
})
Isolation

Drop into any page — host CSS can't interfere

The editor mounts inside a Shadow DOM by default. Your app's stylesheets, design system preflight, and CMS template resets stop at the boundary — they never cascade into the toolbar, sidebar, or canvas.

Embed in any framework, CMS, or legacy app — no resets, no !important wars, no surprises after a design-system bump.

  • Shadow DOM mount by default — no host CSS leaks in
  • Editor styles can't leak out either (tpl: Tailwind prefix in light-DOM mode)
  • Project your brand across the shadow boundary via --tpl-user-* CSS variables
  • Opt out with shadowDom: false for light-DOM mount when you need it
  • Multi-instance safe — each editor gets its own shadow root
Style-isolation guide
const editor = await init({
  container: '#editor',
  // Shadow DOM by default — host stylesheets stop at the boundary.
  // Your design system's preflight, *{ box-sizing }, and body font
  // can't cascade into the editor. Set to false for a light-DOM
  // mount if you need to inspect editor nodes from host scripts.
  shadowDom: true,

  // To project your brand across the shadow boundary, set
  // --tpl-user-* CSS variables on the container (or any ancestor).
  // They inherit through the shadow root.
  theme: {
    '--tpl-user-color-primary': '#0d9488',
    '--tpl-user-font-sans':     'Inter, system-ui, sans-serif',
    '--tpl-user-radius':        '10px',
  },
})
Linting

Built-in template linting

30 deterministic rules run while authoring — surfaced in a dedicated sidebar tab and as inline badges on the canvas. Accessibility, structure, and links, with configurable severity and no AI guesswork.

Catch alt text, contrast, broken links, and malformed structure before send — not after.

  • Live checks: errors, warnings, and info — grouped in the sidebar
  • Inline canvas badges with one-click jump and auto-fix where safe
  • 20 accessibility rules: alt text, contrast, heading order, touch targets
  • 5 link rules: javascript: URLs, malformed mailto and tel, staging hosts
  • 5 structure rules: duplicate ids, empty sections, column mismatches
  • Per-rule severity overrides and configurable thresholds
  • Locale-aware vague-text dictionaries
  • Same engine runs standalone — validate templates in CI, on save, or in pre-send pipelines
Linting reference
const editor = await init({
  container: '#editor',
  // Powered by the optional peer @templatical/quality.
  // Lazy-loaded on first use
  lint: {
    accessibility: {
      // Per-rule severity overrides — 'error' | 'warning' | 'info' | 'off'.
      rules: {
        'a11y.img-missing-alt':          'error',
        'a11y.img-alt-is-filename':      'warning',
        'a11y.link-target-blank-no-rel': 'off',
      },
      thresholds: {
        minFontSize:      16,
        minTouchTargetPx: 44,
      },
    },
    links: {
      nonProductionHosts: ['*.staging.*', '*.preview.*'],
    },
    // Set any linter to false to skip it entirely — e.g. structure: false.
    // Or set disabled: true to disable all lint checks.
  },
})
Assets

Bring your own media browser

A single onRequestMedia hook lets the editor open your media browser — S3, Cloudinary, your own CMS, anything. No vendor storage, no asset egress fees, no lock-in.

Reuse the asset pipeline you already run, end-to-end.

  • One async hook returns { url, alt } — bring any backend
  • Triggered from image blocks, image fields, and the toolbar
  • Context-aware accept hint — the editor tells you what it wants
  • No upload happens through Templatical — your storage, your auth
  • Wins over a media provider when both are set — the built-in modal never opens
  • Cloud build adds a managed media browser when you opt in
Media-library reference
const editor = await init({
  container: '#editor',
  // Editor calls onRequestMedia when the user picks an image —
  // open your own asset browser (S3, Cloudinary, your CMS, etc.)
  // and resolve with { url, alt } — or null on cancel.
  async onRequestMedia({ accept } = {}) {
    const picked = await openAssetBrowser({
      accept,                 // e.g. ['images']
      endpoint: '/api/assets',
    })
    if (!picked) return null
    return { url: picked.url, alt: picked.alt }
  },
})
Headless

Build templates without the editor

Every block type has a factory function in the types package — MIT, no editor, no DOM. Compose a template in a script, seed a starter library, or generate one per customer from your own data.

Templates as data, produced by code as easily as by hand.

  • A factory per block type, each with sensible defaults
  • Factories generate the ids, so content is valid by construction
  • Runs anywhere — build script, server, queue worker, test
  • Produces the same JSON the editor reads and writes
  • MIT-licensed with no runtime dependencies
Programmatic templates guide
import {
  createDefaultTemplateContent,
  createTitleBlock,
  createParagraphBlock,
  createButtonBlock,
} from '@templatical/types'

const content = createDefaultTemplateContent()

// Each factory generates its own id, so the result is valid by construction.
content.blocks = [
  createTitleBlock({ content: '<h1>Welcome aboard</h1>' }),
  createParagraphBlock({ content: '<p>Here is what to do first.</p>' }),
  createButtonBlock({ text: 'Open your dashboard', url: 'https://example.com' }),
]

// Feed it to the editor, or straight to the renderer.
await init({ container: '#editor', content })
Connect your backend

One key per capability. The same shape. Absent until you pass one.

Each backend capability is one config key holding methods you implement. Omit a key and the feature is gone — not disabled, and its UI is never downloaded. Pass false instead of a method and the editor hides that control rather than greying it out.

Persistence

Saving and loading, against your own storage

Give the editor somewhere to save and it grows the chrome that goes with it: an inline-editable name, a save button, a status indicator, Cmd/Ctrl+S, optional autosave, and a warning before the tab closes with unsaved work.

The whole save lifecycle, with your API as the only storage.

  • Three methods are the entire integration — load, create, save
  • Debounced autosave that pauses during undo, so a redo never races a write
  • The template id is yours — a database key, a slug, a document id
  • That id is the join key: version history and comments attach to it
  • onSaved carries the trigger — manual, autosave, rename, restore or api
  • A failed save leaves editor state untouched; nothing is marked saved that wasn’t
  • Omit the key and persist from onChange yourself instead
Saving & loading reference
const editor = await init({
  container: '#editor',
  templates: {
    load: (id) =>
      fetch(`/api/templates/${id}`).then((r) => r.json()),

    create: (input) =>
      fetch('/api/templates', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      }).then((r) => r.json()),

    save: (id, patch) =>
      fetch(`/api/templates/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(patch),
      }).then((r) => r.json()),

    autoSave: true,
    onSaved: (template, { trigger }) => {
      if (trigger === 'manual') router.push(`/templates/${template.id}`)
    },
  },
})

// Opening a template is imperative — your app decides which one.
await editor.load('tpl_123')
History

Browse, preview and restore past versions

A history control in the header steps back through past states, previews one on the canvas with its own banner, and restores it behind a confirmation. Four methods against your own storage.

Undo that outlives the session, without building the UI for it.

  • Four-method provider — list, get, create, restore
  • Content on a listed version is a per-entry hint: hydrate the recent ones, make the rest a round-trip
  • No atomic restore endpoint? Compose it from get plus save
  • The confirmation before a restore discards unsaved work is the editor’s job, not yours
  • Automatic versions belong to whoever implements save — the side that knows what storage costs
  • Pass create: false and the control disappears rather than greying out
Version-history reference
const editor = await init({
  container: '#editor',
  versionHistory: {
    list: (templateId) =>
      fetch(`/api/templates/${templateId}/versions`).then((r) => r.json()),

    // A listed version can omit content as a cache hint — the editor
    // calls get() the first time it's opened, then caches the result.
    get: (templateId, versionId) =>
      fetch(`/api/templates/${templateId}/versions/${versionId}`)
        .then((r) => r.json())
        .then((v) => v.content),

    create: (templateId, content) =>
      fetch(`/api/templates/${templateId}/versions`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content }),
      }).then((r) => r.json()),

    restore: (templateId, versionId) =>
      fetch(`/api/templates/${templateId}/versions/${versionId}/restore`, {
        method: 'POST',
      }).then((r) => r.json()),
  },
})
Review

Threaded review, anchored to blocks

A review panel with threads and replies, a count badge on every commented block, and resolve/reopen. Five methods, plus a top-level user — because without an author the feature reports itself unavailable rather than writing an anonymous comment.

Stakeholder review inside the editor, on your storage and your identities.

  • Five-method provider — list, create, update, delete, setResolved
  • user.id decides what a session may edit or delete
  • setResolved takes the target state, not a toggle, so two clicks can’t invert it
  • Comments anchor to a block, or to the template as a whole
  • An optional subscribe carries a realtime transport if you have one — and everything works without it
  • Pass create: false for a read-only review pass
Comments reference
const editor = await init({
  container: '#editor',
  // A top-level key, not part of the provider — presence features will
  // want the same answer. No user, and the panel reports itself
  // unavailable rather than writing an anonymous comment.
  user: { id: currentUser.id, name: currentUser.name },
  comments: {
    list: (templateId) =>
      fetch(`/api/templates/${templateId}/comments`).then((r) => r.json()),

    create: (templateId, input) =>
      fetch(`/api/templates/${templateId}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      }).then((r) => r.json()),

    update: (templateId, commentId, patch) =>
      fetch(`/api/templates/${templateId}/comments/${commentId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(patch),
      }).then((r) => r.json()),

    delete: (templateId, commentId) =>
      fetch(`/api/templates/${templateId}/comments/${commentId}`, {
        method: 'DELETE',
      }).then(() => undefined),

    // Takes the target state, not a toggle — two clicks in flight can't
    // leave a thread inverted.
    setResolved: (templateId, commentId, resolved) =>
      fetch(`/api/templates/${templateId}/comments/${commentId}/resolve`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ resolved }),
      }).then((r) => r.json()),
  },
})
Reuse

Saved blocks, in your storage

Users pick a group of blocks, name it, and drop it into any other template. The editor ships the whole experience — pick session, searchable library, live preview, insert at position. You implement four methods against your own API.

A block library your users fill themselves, on your backend.

  • Four-method provider — list, create, update, delete
  • Pass false instead of a function and the editor hides the control
  • Per-entry flags lock individual entries as read-only
  • Free-text categories, derived from whatever the entries carry
  • Search and category filters run in the editor, not your API
  • Bundled browser-local provider for demos — one line, no backend
Saved-blocks reference
import { createLocalStorageSavedBlocksProvider } from '@templatical/editor'

const editor = await init({
  container: '#editor',
  // Stores entries in localStorage — no backend, good for demos.
  savedBlocks: createLocalStorageSavedBlocksProvider(),
})
Delivery

Test sends through your own infrastructure

A user mails themselves the template they are editing — and it leaves from your ESP, your domain, your reputation. The editor owns the trigger, the dialog, the preview, and the sending states. You implement one method.

A real inbox check before anything reaches a campaign, with no vendor in the path.

  • One send method is the entire integration
  • Omit the key and the feature is absent — no button, none of its code downloaded
  • The dialog previews exactly what is being sent, desktop or mobile
  • Display conditions are honoured, so the preview never shows content the recipient won’t get
  • Restrict the recipient list to reshape the field — free text, read-only, or a picker
  • Throw with a message and it shows inline; the dialog stays open to retry
Test-email reference
const editor = await init({
  container: '#editor',
  testEmail: {
    // The whole integration. A Test button appears in the header, and
    // the address the user picks is handed to you.
    send: async ({ recipient, content }) => {
      const res = await fetch('/api/test-email', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ recipient, content }),
      })

      // Your message reaches the user verbatim — write it for them.
      if (res.status === 429) throw new Error('Too many test emails — try again in a minute.')
      if (!res.ok) throw new Error('Could not send the test email.')
    },
  },
})
Media

A media library, in your own storage

The editor owns the picker — browse on image fields, video thumbnails and custom-block fields, drag-and-drop upload, crop, folders, search. You own storage. `list` is the only required method; the other nine are yours to enable or withhold, one at a time.

A media library your users browse and fill, entirely on your own storage.

  • Ten-member provider — `list` is the only one that can’t be `false`
  • Browse triggers on image fields, video thumbnails and custom-block image fields
  • Dropping a file calls `create` directly — the modal never opens, and its listing is untouched
  • Pass `false` on any mutation and the editor hides that control instead of disabling it
  • Folders come back as a flat list — the UI trees them via `parentId`
  • Bundled browser-local provider for demos — one line, no backend
  • `onRequestMedia` is a separate seam, not this store — set both and the callback wins
Media reference
import { createLocalStorageMediaProvider } from '@templatical/editor'

const editor = await init({
  container: '#editor',
  // Stores entries in localStorage — no backend, good for demos. Folders,
  // replace, import, usage, frequently-used and quota are all false.
  media: createLocalStorageMediaProvider(),
})
Output

JSON in, MJML out

Templates are portable JSON you store wherever you like. Output is MJML, rendered by a package you install — in the browser, on your server, in a queue worker. No hosted render service sits in the path.

Own the output. Send through any provider, for as long as you like.

  • MJML is an open standard with implementations in several languages
  • Render in the browser, on your server, or in a background job
  • Custom blocks resolve through a callback you supply
  • Nothing calls home — no render API, no per-render pricing
  • The renderer is MIT-licensed and installed separately
  • No `render` key needed for `toMjml()` — add one for `toHtml()`, or to move the conversion to your backend
How rendering works
const editor = await init({ container: '#editor' })

// Loads the renderer on first call — custom blocks resolve automatically.
const mjml = await editor.toMjml()

// Compile MJML to HTML with whichever MJML library you prefer.
const { html } = mjml2html(mjml)
The essentials

Everything else you expect — done right.

Drop-in mount, framework-agnostic, every locale you need. Plus the polish — dark mode, undo/redo, responsive preview.

Blocks out of the box
Twelve block types ready to drag in — title, paragraph, image, button, section, divider, spacer, social icons, menu, table, video, and raw HTML — plus any custom types you register.
Drop-in framework integration
One init() call to mount, one to unmount. First-class examples for React, Vue, Svelte, Angular, and vanilla JS.
Dark mode
First-class dark mode with auto-detect or manual toggle. Both themes are designed, not an afterthought.
Internationalization
Seven locales built in — English, German, Portuguese (BR), Spanish, Catalan, French, and Dutch — across the editor and the media library. Drop in a file for any other language.
Undo / Redo
Full history stack. Debounced to group rapid changes into sensible undo steps.
Responsive preview
Toggle desktop, tablet, and mobile viewports to see how every email renders on every device.
Painless migration

Already in another editor? Bring your templates with you.

One importer per source format — hosted editors plus raw HTML and MJML. Free, open-source, MIT, no manual rebuilding and no vendor lock-in.

  • Import saved editor documents directly, no re-export step
  • Convert raw HTML emails — Mailchimp, SendGrid, hand-coded
  • Automatic block mapping and style preservation
  • Every converter reports what needs a manual look

BeeFreeUnlayerStripoTopolChamaileonEasy Email ProHTMLMJML

See all importers
Get started

Pick your starting point.

Install the SDK

Add the package, mount with one init() call, ship. First-class examples for every major framework.

Migrate your templates

Already in a hosted editor — or sitting on a folder of HTML emails? Import them with automatic block mapping, no manual rebuild.

  • One skill for the template and the integrationAgent Skill
  • Custom blocks with API-backed dataExtensibility
  • Merge tags with pluggable syntaxPersonalization
  • Display conditionsTargeting
  • Loops and conditionals inside the copyDynamic content
  • Previews with real data, resolved by your backendPreview
  • Theming and brand defaultsBranding
  • Drop into any page — host CSS can't interfereIsolation
  • Built-in template lintingLinting
  • Bring your own media browserAssets
  • Build templates without the editorHeadless
  • Saving and loading, against your own storagePersistence
  • Browse, preview and restore past versionsHistory
  • Threaded review, anchored to blocksReview
  • Saved blocks, in your storageReuse
  • Test sends through your own infrastructureDelivery
  • A media library, in your own storageMedia
  • JSON in, MJML outOutput

Arrow keys to move · Enter to jump · Esc to close