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) ──