From bb88a27b90d9ecbdcb2e3121088f95f0d496d030 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 22 May 2026 06:04:48 -0400 Subject: [PATCH] feat(email-editor): persist easy-email JSON state for instant restore on reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/email-editor/src/EmailEditorApp.tsx | 37 ++++++++++++-------- services/targo-hub/lib/campaigns.js | 36 ++++++++++++++++--- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/services/email-editor/src/EmailEditorApp.tsx b/services/email-editor/src/EmailEditorApp.tsx index 333a508..6ac9a75 100644 --- a/services/email-editor/src/EmailEditorApp.tsx +++ b/services/email-editor/src/EmailEditorApp.tsx @@ -75,19 +75,25 @@ export function EmailEditorApp() { const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(name)}`) if (!res.ok) throw new Error(`Hub returned ${res.status}`) const data = await res.json() - // The hub's getTemplate returns { name, format, mjml?, html } - // For MJML templates we'd ideally parse the MJML into easy-email's JSON - // tree — but easy-email doesn't ship an MJML importer. Workaround: we - // start the editor with an EMPTY page and let the user rebuild visually - // OR stash the existing MJML as the editor's content. For first iteration, - // empty + the user reconstructs is honest about the limitation. - // The compiled HTML preview remains available so they can see what - // they're rebuilding TOWARD. - // TODO Phase 3: integrate mjml-react-email or similar MJML→JSON parser - setInitialValues(emptyTemplate()) - setError(`Note: existing template MJML (${data.format}, ${(data.mjml || '').length}b) ` + - `not auto-imported — easy-email starts from an empty page. ` + - `Use the hub's compiled HTML as a visual reference and rebuild here.`) + // Three sources of truth, in priority order: + // 1. .json file → easy-email JSON tree (fast, full restore) + // 2. .mjml file → MJML source (no auto-importer, start blank) + // 3. nothing → empty page + if (data.json) { + try { + const parsed = typeof data.json === 'string' ? JSON.parse(data.json) : data.json + setInitialValues(parsed as IEmailTemplate) + } catch (e: any) { + setError(`Stored JSON is invalid (${e.message}) — starting blank`) + setInitialValues(emptyTemplate()) + } + } 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) { setError(`Could not load template "${name}": ${e.message}`) setInitialValues(emptyTemplate()) @@ -108,10 +114,13 @@ export function EmailEditorApp() { mode: 'production', 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)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mjml: mjmlSource }), + body: JSON.stringify({ mjml: mjmlSource, json: values }), }) if (!res.ok) { const errBody = await res.json().catch(() => ({})) diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index 8823949..90c640b 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -685,6 +685,16 @@ function templateMjmlPath (name) { 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 // of truth = MJML, .html is the auto-compiled output). Otherwise 'html'. function templateFormat (name) { @@ -1020,11 +1030,17 @@ async function handle (req, res, method, path) { try { const format = templateFormat(name) 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') let html = '' 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') 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) { const mjmlPath = templateMjmlPath(name) const htmlPath = templatePath(name) + const jsonPath = templateJsonPath(name) // Compile MJML → HTML (async in mjml v5) const r = await mjml2html(body.mjml, { validationLevel: 'soft' }) if (r.errors?.length) { @@ -1059,18 +1076,27 @@ async function handle (req, res, method, path) { 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) try { 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(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`)) } catch (e) { log('template backup failed:', e.message) } fs.writeFileSync(mjmlPath, body.mjml, '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, { 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) ──