feat(email-editor): persist easy-email JSON state for instant restore on reload

Phase 2.5 — close the load/save loop so the editor isn't broken by a page
refresh.

Problem: easy-email doesn't ship an MJML→JSON parser, so loading an
existing MJML template into the editor canvas isn't possible. First-time
load = empty canvas. Without this fix, every page reload would also reset
to empty (even after saving), making the editor useless past one session.

Solution: persist easy-email's raw JSON tree (editor state) as a third
companion file alongside .mjml + .html. Editor reads .json on load when
present, falls back to empty otherwise.

Three files per template now:
  gift-email-fr.mjml   — MJML source (rendered by send-worker → already done)
  gift-email-fr.html   — compiled HTML (cached output, sent to recipients)
  gift-email-fr.json   — easy-email editor state (UI restoration only)

Backend (lib/campaigns.js):
- New templateJsonPath() helper + EDITABLE_TEMPLATES checks
- GET /campaigns/templates/:name returns { format, mjml, html, json }
  when format=mjml (json null until first easy-email save)
- PUT /campaigns/templates/:name now accepts body.json alongside body.mjml
  (writes both .mjml + .html [compiled] + .json [editor state])
- Backup hook extended to also backup .json before overwrite

Editor (EmailEditorApp.tsx):
- On load: prefer data.json → parse and seed initialValues. If json
  missing but mjml present, show explanatory error banner + empty canvas
  (user reconstructs once; that save fixes future loads).
- On save: send BOTH mjml (compiled via JsonToMjml) AND raw values
  object as json. Hub persists all three artifacts.

First UX flow on next user visit:
  1. Open editor → empty canvas + banner "MJML exists but no JSON
     editor-state yet; reconstruct once to save a JSON snapshot"
  2. User drag-drops blocks to rebuild the template visually
  3. Click save → MJML + HTML + JSON all persist
  4. Subsequent reloads load from JSON instantly with full editor state

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-22 06:04:48 -04:00
parent f9971e9113
commit bb88a27b90
2 changed files with 54 additions and 19 deletions

View File

@ -75,19 +75,25 @@ export function EmailEditorApp() {
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(name)}`) const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(name)}`)
if (!res.ok) throw new Error(`Hub returned ${res.status}`) if (!res.ok) throw new Error(`Hub returned ${res.status}`)
const data = await res.json() const data = await res.json()
// The hub's getTemplate returns { name, format, mjml?, html } // Three sources of truth, in priority order:
// For MJML templates we'd ideally parse the MJML into easy-email's JSON // 1. .json file → easy-email JSON tree (fast, full restore)
// tree — but easy-email doesn't ship an MJML importer. Workaround: we // 2. .mjml file → MJML source (no auto-importer, start blank)
// start the editor with an EMPTY page and let the user rebuild visually // 3. nothing → empty page
// OR stash the existing MJML as the editor's content. For first iteration, if (data.json) {
// empty + the user reconstructs is honest about the limitation. try {
// The compiled HTML preview remains available so they can see what const parsed = typeof data.json === 'string' ? JSON.parse(data.json) : data.json
// they're rebuilding TOWARD. setInitialValues(parsed as IEmailTemplate)
// TODO Phase 3: integrate mjml-react-email or similar MJML→JSON parser } catch (e: any) {
setInitialValues(emptyTemplate()) setError(`Stored JSON is invalid (${e.message}) — starting blank`)
setError(`Note: existing template MJML (${data.format}, ${(data.mjml || '').length}b) ` + setInitialValues(emptyTemplate())
`not auto-imported — easy-email starts from an empty page. ` + }
`Use the hub's compiled HTML as a visual reference and rebuild here.`) } else if (data.mjml) {
setError(`Existing MJML (${(data.mjml || '').length}b) cannot be auto-imported into easy-email. ` +
`Reconstructing this template once with the drag-drop blocks here will save an editable JSON snapshot for next time.`)
setInitialValues(emptyTemplate())
} else {
setInitialValues(emptyTemplate())
}
} catch (e: any) { } catch (e: any) {
setError(`Could not load template "${name}": ${e.message}`) setError(`Could not load template "${name}": ${e.message}`)
setInitialValues(emptyTemplate()) setInitialValues(emptyTemplate())
@ -108,10 +114,13 @@ export function EmailEditorApp() {
mode: 'production', mode: 'production',
context: values.content as any, context: values.content as any,
}) })
// Send BOTH the compiled MJML (for send-worker) AND the raw easy-email
// JSON tree (for next-load restore). Hub persists .mjml + .html + .json
// — the JSON file is the canonical editing source going forward.
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(templateName)}`, { const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(templateName)}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mjml: mjmlSource }), body: JSON.stringify({ mjml: mjmlSource, json: values }),
}) })
if (!res.ok) { if (!res.ok) {
const errBody = await res.json().catch(() => ({})) const errBody = await res.json().catch(() => ({}))

View File

@ -685,6 +685,16 @@ function templateMjmlPath (name) {
return path.join(TEMPLATES_DIR, name + '.mjml') return path.join(TEMPLATES_DIR, name + '.mjml')
} }
// Companion .json — easy-email's raw editor state (JSON tree). When present,
// the editor uses it as the source of truth on load (instant restore of all
// blocks/styling). Generated client-side by easy-email; we just persist it.
function templateJsonPath (name) {
if (!EDITABLE_TEMPLATES.includes(name)) {
throw new Error(`template not editable: ${name}`)
}
return path.join(TEMPLATES_DIR, name + '.json')
}
// Return 'mjml' if the template has a .mjml companion file on disk (source // Return 'mjml' if the template has a .mjml companion file on disk (source
// of truth = MJML, .html is the auto-compiled output). Otherwise 'html'. // of truth = MJML, .html is the auto-compiled output). Otherwise 'html'.
function templateFormat (name) { function templateFormat (name) {
@ -1020,11 +1030,17 @@ async function handle (req, res, method, path) {
try { try {
const format = templateFormat(name) const format = templateFormat(name)
if (format === 'mjml') { if (format === 'mjml') {
// Source of truth = .mjml. Also return compiled .html as a preview. // Source of truth = .mjml. Also return compiled .html (preview) and
// .json (easy-email editor state) when they exist.
const mjml = fs.readFileSync(templateMjmlPath(name), 'utf8') const mjml = fs.readFileSync(templateMjmlPath(name), 'utf8')
let html = '' let html = ''
try { html = fs.readFileSync(templatePath(name), 'utf8') } catch {} try { html = fs.readFileSync(templatePath(name), 'utf8') } catch {}
return json(res, 200, { name, format, mjml, html }) let editorJson = null
try {
const jsonStr = fs.readFileSync(templateJsonPath(name), 'utf8')
editorJson = JSON.parse(jsonStr)
} catch {}
return json(res, 200, { name, format, mjml, html, json: editorJson })
} }
const html = fs.readFileSync(templatePath(name), 'utf8') const html = fs.readFileSync(templatePath(name), 'utf8')
return json(res, 200, { name, format: 'html', html }) return json(res, 200, { name, format: 'html', html })
@ -1051,6 +1067,7 @@ async function handle (req, res, method, path) {
if (typeof body.mjml === 'string' && body.mjml) { if (typeof body.mjml === 'string' && body.mjml) {
const mjmlPath = templateMjmlPath(name) const mjmlPath = templateMjmlPath(name)
const htmlPath = templatePath(name) const htmlPath = templatePath(name)
const jsonPath = templateJsonPath(name)
// Compile MJML → HTML (async in mjml v5) // Compile MJML → HTML (async in mjml v5)
const r = await mjml2html(body.mjml, { validationLevel: 'soft' }) const r = await mjml2html(body.mjml, { validationLevel: 'soft' })
if (r.errors?.length) { if (r.errors?.length) {
@ -1059,18 +1076,27 @@ async function handle (req, res, method, path) {
details: r.errors.map(e => e.formattedMessage || e.message), details: r.errors.map(e => e.formattedMessage || e.message),
}) })
} }
// Backup both files before overwriting // Backup all 3 companion files before overwriting
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
try { try {
if (fs.existsSync(mjmlPath)) fs.copyFileSync(mjmlPath, mjmlPath.replace(/\.mjml$/, `.bak-${ts}.mjml`)) if (fs.existsSync(mjmlPath)) fs.copyFileSync(mjmlPath, mjmlPath.replace(/\.mjml$/, `.bak-${ts}.mjml`))
if (fs.existsSync(htmlPath)) fs.copyFileSync(htmlPath, htmlPath.replace(/\.html$/, `.bak-${ts}.html`)) if (fs.existsSync(htmlPath)) fs.copyFileSync(htmlPath, htmlPath.replace(/\.html$/, `.bak-${ts}.html`))
if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
} catch (e) { log('template backup failed:', e.message) } } catch (e) { log('template backup failed:', e.message) }
fs.writeFileSync(mjmlPath, body.mjml, 'utf8') fs.writeFileSync(mjmlPath, body.mjml, 'utf8')
fs.writeFileSync(htmlPath, r.html, 'utf8') fs.writeFileSync(htmlPath, r.html, 'utf8')
log(`template ${name} updated (mjml: ${body.mjml.length}b → html: ${r.html.length}b)`) // Optional editor-state snapshot — only written when client sends it
// (the React editor does, but the legacy HTML editor doesn't).
let json_size = 0
if (body.json) {
const jsonStr = typeof body.json === 'string' ? body.json : JSON.stringify(body.json)
fs.writeFileSync(jsonPath, jsonStr, 'utf8')
json_size = jsonStr.length
}
log(`template ${name} updated (mjml: ${body.mjml.length}b → html: ${r.html.length}b${json_size ? ', json: ' + json_size + 'b' : ''})`)
return json(res, 200, { return json(res, 200, {
name, format: 'mjml', saved: true, name, format: 'mjml', saved: true,
mjml_size: body.mjml.length, html_size: r.html.length, mjml_size: body.mjml.length, html_size: r.html.length, json_size,
}) })
} }
// ── HTML save path (legacy) ── // ── HTML save path (legacy) ──