feat(ops/campaigns): pivot template editor to Unlayer (vue-email-editor)

After honest acknowledgment that easy-email-standard is abandoned and
limited (Chrome-only, no responsive preview, no AMP, no Unsplash, no
file manager), pivoted to Unlayer's vue-email-editor — a Vue 3 native
component giving all the features the user listed for free (internal
use; a small "Powered by Unlayer" badge shows in the sidebar but NOT
in sent emails).

Why drop MJML alongside:
  • MJML was our SERVER-SIDE compilation step because we hand-wrote
    templates. With a visual editor that outputs email-safe HTML
    directly (responsive media queries, Outlook MSO fallbacks, AMP
    where used), the compilation step is redundant.
  • One fewer dependency on the hub (mjml package no longer needed).
  • One fewer file format to persist (.mjml dropped, only .html
    canonical + .json design).

Storage simplification:
  Before: .mjml (source) + .html (compiled) + .json (editor state)
  After:  .html (canonical) + .json (Unlayer design tree)

The hub's send-worker reads .html as before — no changes to send
logic.

Architecture wins:
  • Vue 3 native — zero iframe friction, no postMessage choreography
  • No separate microservice — easy-email container decommissioned
    (docker compose down, code kept under /opt/email-editor/ in case
    of rollback)
  • DNS editor.gigafibre.ca retained but unused — can be removed via
    Cloudflare API cleanup later
  • The editor's mergeTags option exposes our {{firstname}}, {{amount}},
    {{gift_url}}, etc. in Unlayer's native "Merge tags" panel — same
    pattern, more polished UI
  • Features now native: responsive preview (mobile/tablet/desktop
    breakpoints), Unsplash search, file manager, dark mode, design
    history, undo/redo, layers panel, content blocks library

Frontend (TemplateEditorPage.vue):
  • Imports EmailEditor from vue-email-editor
  • onReady() callback: fetch template + loadDesign() to restore canvas
  • saveTemplate(): exportHtml() → PUT { html, design } to hub
  • Top bar kept: template selector, saved chip, preview, test-send,
    save button
  • Removed: iframe-related glue (postMessage listener, iframeKey,
    EDITOR_BASE constant, Cmd-S handling that lived in the iframe)

API client (apps/ops/src/api/campaigns.js):
  • saveTemplate() now accepts opts.design (Unlayer JSON tree) alongside
    content. Legacy opts.format='mjml' still works for backward compat.

Hub (services/targo-hub/lib/campaigns.js):
  • GET /campaigns/templates/:name unconditionally returns
    { name, format, html, design } (+ mjml when format=mjml for
    legacy templates). The design field is null when no .json file
    exists yet.
  • PUT /campaigns/templates/:name HTML save path now accepts
    body.design alongside body.html and persists both with backups.
  • MJML save path (legacy) preserved for any callers using the old
    contract.

Container decommissioned on prod: email-editor container stopped +
removed. The Vue editor lives inside the ops SPA, served from
erp.gigafibre.ca/ops as a normal route.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-22 06:14:06 -04:00
parent bb88a27b90
commit a11fe5a115
5 changed files with 195 additions and 102 deletions

View File

@ -22,6 +22,7 @@
"sip.js": "^0.21.2",
"vue": "^3.4.21",
"vue-chartjs": "^5.3.3",
"vue-email-editor": "^2.2.0",
"vue-router": "^4.3.0",
"vuedraggable": "^4.1.0"
},
@ -2909,6 +2910,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/@unlayer/types": {
"version": "1.413.0",
"resolved": "https://registry.npmjs.org/@unlayer/types/-/types-1.413.0.tgz",
"integrity": "sha512-pOE9lKvP7ofnmfWZN+PTizw2GrwZNtePiMH3Yl8OSt/nYQL52X7N4SHd7dDd2c7ecJwWVWo8MfPY8QTon+44lw==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-2.3.4.tgz",
@ -9606,6 +9613,17 @@
}
}
},
"node_modules/vue-email-editor": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/vue-email-editor/-/vue-email-editor-2.2.0.tgz",
"integrity": "sha512-aEXm0OHjZgeQqGsssfukqJm7kubfGBOPo9ddwGHMXLbzegJDZ0ou2h7NmRvPR+XaoRGYHdZXf9p7zVae5ACgWA==",
"dependencies": {
"@unlayer/types": "^1.394.0"
},
"peerDependencies": {
"vue": "^3.2.13"
}
},
"node_modules/vue-eslint-parser": {
"version": "9.4.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",

View File

@ -24,6 +24,7 @@
"sip.js": "^0.21.2",
"vue": "^3.4.21",
"vue-chartjs": "^5.3.3",
"vue-email-editor": "^2.2.0",
"vue-router": "^4.3.0",
"vuedraggable": "^4.1.0"
},

View File

@ -107,10 +107,13 @@ export function getTemplate (name) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`)
}
// saveTemplate(name, content) — content interpreted as HTML by default.
// Pass { format: 'mjml' } to send as MJML source (hub compiles to HTML).
export function saveTemplate (name, content, { format = 'html' } = {}) {
// saveTemplate(name, content, opts) — content is HTML by default.
// Optional opts.design = Unlayer design JSON (persisted alongside HTML so the
// editor can re-load the visual state on next open).
// Legacy opts.format = 'mjml' still supported for older callers (sends mjml).
export function saveTemplate (name, content, { format = 'html', design = null } = {}) {
const body = format === 'mjml' ? { mjml: content } : { html: content }
if (design) body.design = design
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`, {
method: 'PUT',
body,

View File

@ -1,40 +1,38 @@
<template>
<q-page>
<!-- Top bar: thin wrapper. All editing happens inside the iframe; the
parent app's job is template selection + test-send + saved-toast. -->
<!-- Top bar: template selector + saved chip + quick actions. The Unlayer
editor below has its own toolbar for blocks/preview/etc. -->
<div class="row items-center q-px-md q-py-sm bg-white" style="border-bottom:1px solid #e5e7eb">
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
<div class="text-h6 q-mr-md">Éditeur de template</div>
<q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined
style="min-width:240px" @update:model-value="onSelectTemplate" />
<q-chip dense color="grey-2" text-color="grey-9" class="q-ml-sm" size="sm" icon="cloud_done"
v-if="lastSaved">
Sauvegardé · {{ lastSavedAt }}
<q-chip v-if="lastSavedTs" dense size="sm" color="grey-2" text-color="grey-9" class="q-ml-sm" icon="cloud_done">
Sauvegardé · {{ lastSavedLabel }}
</q-chip>
<q-space />
<q-btn flat color="primary" icon="visibility" label="Aperçu inbox" class="q-mr-sm"
@click="openPreview">
<q-tooltip>Voir le rendu compilé avec données de test (HTML de l'inbox)</q-tooltip>
<q-btn flat color="primary" icon="visibility" label="Aperçu inbox" class="q-mr-sm" @click="openPreview">
<q-tooltip>Voir le HTML rendu (substitué) tel que reçu par le destinataire</q-tooltip>
</q-btn>
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-mr-sm"
@click="testSendOpen = true">
<q-tooltip>Envoie un courriel réel à une adresse de test avec variables custom</q-tooltip>
</q-btn>
<q-btn flat dense icon="refresh" @click="reloadEditor">
<q-tooltip>Recharger l'éditeur (en cas de bug)</q-tooltip>
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-mr-sm" @click="testSendOpen = true">
<q-tooltip>Envoyer un courriel réel à une adresse de test</q-tooltip>
</q-btn>
<q-btn unelevated color="primary" icon="save" label="Enregistrer"
:loading="saving" @click="saveTemplate" />
</div>
<!-- The editor itself full-page iframe to the standalone email-editor
microservice at editor.gigafibre.ca. Communicates back via postMessage
when the user saves (we listen + show a toast + update lastSaved). -->
<iframe v-if="iframeUrl" :src="iframeUrl" :key="iframeKey"
style="width:100%; height:calc(100vh - 60px); border:0; display:block;"
ref="editorIframe" />
<!-- Unlayer editor Vue 3 native, no iframe, full features:
responsive preview, AMP blocks, Unsplash, file manager, dark mode,
layers/structure panel, design tokens, etc. -->
<EmailEditor
ref="editor"
:min-height="'calc(100vh - 60px)'"
:options="editorOptions"
@load="onEditorLoad"
@ready="onEditorReady"
/>
<!-- Aperçu dialog renders the LATEST saved compiled HTML in a clean
viewer. Useful before sending a test, to see what the inbox version
actually looks like with merge tags substituted. -->
<!-- Aperçu dialog renders the latest saved HTML with sample vars -->
<q-dialog v-model="previewOpen" maximized persistent>
<q-card class="bg-grey-2">
<q-toolbar class="bg-white" style="border-bottom:1px solid #e5e7eb">
@ -49,7 +47,7 @@
</q-card>
</q-dialog>
<!-- Test-send dialog (unchanged from previous version) -->
<!-- Test-send dialog -->
<q-dialog v-model="testSendOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw">
<q-card-section class="row items-center bg-orange-1 text-orange-9">
@ -59,8 +57,7 @@
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense
type="email" autofocus class="q-mb-md" />
<q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense type="email" autofocus class="q-mb-md" />
<q-input v-model="testSendForm.subject" label="Sujet" outlined dense class="q-mb-md" />
<div class="text-subtitle2 q-mb-sm">Variables</div>
<div class="row q-col-gutter-sm">
@ -75,8 +72,8 @@
</q-card-section>
<q-card-section class="bg-grey-2">
<div class="text-caption text-grey-8">
Sauvegarde l'éditeur d'abord pour tester la dernière version.
Envoyé via Mailjet depuis <code>TARGO &lt;support@targointernet.com&gt;</code>.
Utilise la dernière version sauvegardée (sauvegarde dans l'éditeur d'abord pour tester les modifs récentes).
</div>
</q-card-section>
<q-card-actions align="right">
@ -90,31 +87,58 @@
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { listTemplates, previewTemplate, testSendTemplate } from 'src/api/campaigns'
import { EmailEditor } from 'vue-email-editor'
import { listTemplates, getTemplate, saveTemplate as saveTemplateApi,
previewTemplate, testSendTemplate } from 'src/api/campaigns'
const route = useRoute()
const router = useRouter()
const $q = useQuasar()
// Editor iframe standalone microservice at editor.gigafibre.ca.
// URL params drive which template loads. iframeKey is bumped to force a
// fresh load when the user switches templates (otherwise the iframe
// caches the previous template's state).
const EDITOR_BASE = 'https://editor.gigafibre.ca'
// Editor ref + Unlayer configuration
const editor = ref(null)
const editorReady = ref(false)
const saving = ref(false)
const currentName = ref(route.params.name || 'gift-email-fr')
const iframeKey = ref(0)
const editorIframe = ref(null)
const iframeUrl = computed(() =>
`${EDITOR_BASE}/?name=${encodeURIComponent(currentName.value)}`
)
// Unlayer editor options:
// - mergeTags: shown in the "Merge tags" panel, drag-droppable into text
// - features: Unsplash, file manager, etc. are all on by default
// - tools: which block types to expose
// - appearance: light theme matching our ops UI
const editorOptions = {
appearance: {
theme: 'modern_light',
panels: {
tools: { dock: 'left' },
},
},
mergeTags: {
firstname: { name: 'Prénom', value: '{{firstname}}' },
lastname: { name: 'Nom', value: '{{lastname}}' },
email: { name: 'Courriel', value: '{{email}}' },
amount: { name: 'Montant', value: '{{amount}}' },
gift_url: { name: 'Lien cadeau', value: '{{gift_url}}' },
description: { name: 'Adresse service', value: '{{description}}' },
expiry: { name: "Date d'expiry", value: '{{expiry}}' },
commitment_months: { name: 'Engagement (mois)', value: '{{commitment_months}}' },
year: { name: 'Année', value: '{{year}}' },
},
// Display mode: 'email' (default, with mobile preview), 'web' for landing pages
displayMode: 'email',
// Locale for built-in strings
locale: 'fr-CA',
// Use Unlayer's free CDN. For paid users this would carry a projectId.
// Without a projectId the "Powered by Unlayer" badge shows in the sidebar.
}
// Template list (selector at the top)
const templates = ref([])
const templateOptions = computed(() => templates.value.map(t => ({
label: `${t.name}.${t.format || 'html'} (${Math.round(t.size / 1024)} KB)`,
label: `${t.name} (${Math.round(t.size / 1024)} KB)`,
value: t.name,
})))
@ -122,42 +146,79 @@ async function loadAvailableTemplates () {
templates.value = await listTemplates()
}
function onSelectTemplate (name) {
currentName.value = name
iframeKey.value++ // force iframe reload with new ?name=
router.replace({ path: `/campaigns/templates/${name}` })
}
function reloadEditor () { iframeKey.value++ }
// Listen for postMessage from the editor iframe
const lastSaved = ref(null)
const lastSavedAt = computed(() => {
if (!lastSaved.value) return ''
const diff = Math.floor((Date.now() - lastSaved.value) / 1000)
if (diff < 60) return `il y a ${diff}s`
if (diff < 3600) return `il y a ${Math.floor(diff / 60)}min`
return new Date(lastSaved.value).toLocaleTimeString('fr-CA')
})
function handleMessage (event) {
// Only accept messages from our editor domain
if (event.origin !== EDITOR_BASE) return
if (event.data?.type === 'email-editor:saved') {
lastSaved.value = event.data.ts || Date.now()
$q.notify({ type: 'positive', message: 'Template enregistré ✓', timeout: 2500 })
// Load template into the Unlayer canvas on editor ready + on switch
async function loadTemplateIntoEditor (name) {
if (!editorReady.value || !editor.value) return
try {
const data = await getTemplate(name)
// Priority: Unlayer's own design JSON > nothing (start blank)
// (MJML format from the previous editor isn't auto-importable into Unlayer.
// The .html stored from legacy MJML compile is still valid email HTML
// but Unlayer can't ingest arbitrary HTML the user reconstructs once
// and the design JSON saved by the next "Enregistrer" fixes future loads.)
if (data.design) {
const design = typeof data.design === 'string' ? JSON.parse(data.design) : data.design
editor.value.loadDesign(design)
} else {
// No design yet load a sensible blank starter so the canvas isn't fully empty
editor.value.loadBlank({ backgroundColor: '#F5FAF7' })
$q.notify({
type: 'info',
message: `Pas encore de design pour "${name}" — canvas vide. ` +
`Construis visuellement puis enregistre pour persister.`,
timeout: 7000,
})
}
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur de chargement: ' + e.message })
}
}
onMounted(async () => {
await loadAvailableTemplates()
window.addEventListener('message', handleMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleMessage)
function onEditorLoad () {
// Fired once when the editor IFRAME loads (before the editor inside is ready)
}
async function onEditorReady () {
// Fired when the editor INSIDE the iframe is ready to accept loadDesign()
editorReady.value = true
await loadTemplateIntoEditor(currentName.value)
}
function onSelectTemplate (name) {
currentName.value = name
router.replace({ path: `/campaigns/templates/${name}` })
loadTemplateIntoEditor(name)
}
// Save: export HTML + design JSON, POST to hub
const lastSavedTs = ref(null)
const lastSavedLabel = computed(() => {
if (!lastSavedTs.value) return ''
const diff = Math.floor((Date.now() - lastSavedTs.value) / 1000)
if (diff < 60) return `il y a ${diff}s`
if (diff < 3600) return `il y a ${Math.floor(diff / 60)}min`
return new Date(lastSavedTs.value).toLocaleTimeString('fr-CA')
})
// Preview dialog (compiled HTML from hub)
function saveTemplate () {
if (!editor.value) return
saving.value = true
// exportHtml uses a callback (legacy Unlayer API) wrap in a Promise
editor.value.exportHtml(async (data) => {
try {
const { html, design } = data
await saveTemplateApi(currentName.value, html, { design })
lastSavedTs.value = Date.now()
$q.notify({ type: 'positive', message: 'Template enregistré ✓', timeout: 2500 })
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
saving.value = false
}
})
}
// Preview dialog
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewHtmlContent = ref('')
@ -175,16 +236,14 @@ async function openPreview () {
}
}
// Test-send dialog
// Test-send dialog
const testSendOpen = ref(false)
const testSending = ref(false)
const testSendForm = ref({
to: 'louis@targo.ca',
subject: '[TEST] Aperçu du courriel TARGO',
vars: {
firstname: 'Louis',
lastname: 'Test',
amount: '60 $',
firstname: 'Louis', lastname: 'Test', amount: '60 $',
commitment_months: '3',
gift_url: 'https://gft.link/TEST123',
description: '123 Rue de Test, Ste-Clotilde',
@ -208,4 +267,6 @@ async function doTestSend () {
testSending.value = false
}
}
onMounted(loadAvailableTemplates)
</script>

View File

@ -685,9 +685,10 @@ 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.
// Companion .json — visual editor's design tree (now Unlayer's design JSON,
// previously was easy-email's tree — same file, different content). When
// present, the editor uses it as the source of truth on load. Generated
// client-side by the editor on save; we just persist it as-is.
function templateJsonPath (name) {
if (!EDITABLE_TEMPLATES.includes(name)) {
throw new Error(`template not editable: ${name}`)
@ -1029,21 +1030,20 @@ async function handle (req, res, method, path) {
const name = tplGet[1]
try {
const format = templateFormat(name)
if (format === 'mjml') {
// 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 {}
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 })
}
// Always return: name, format, html (canonical, what gets sent), and
// design (Unlayer/visual-editor JSON tree if present, for re-edit).
// For legacy MJML templates the .mjml is also returned for reference.
const html = fs.readFileSync(templatePath(name), 'utf8')
return json(res, 200, { name, format: 'html', html })
let design = null
try {
const jsonStr = fs.readFileSync(templateJsonPath(name), 'utf8')
design = JSON.parse(jsonStr)
} catch {}
const out = { name, format, html, design }
if (format === 'mjml') {
try { out.mjml = fs.readFileSync(templateMjmlPath(name), 'utf8') } catch {}
}
return json(res, 200, out)
} catch (e) {
return json(res, 404, { error: 'template not found', detail: e.message })
}
@ -1099,17 +1099,27 @@ async function handle (req, res, method, path) {
mjml_size: body.mjml.length, html_size: r.html.length, json_size,
})
}
// ── HTML save path (legacy) ──
// ── HTML save path (now the PRIMARY flow — Unlayer outputs HTML directly) ──
const p = templatePath(name)
const jsonPath = templateJsonPath(name)
try {
if (fs.existsSync(p)) {
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
fs.copyFileSync(p, p.replace(/\.html$/, `.bak-${ts}.html`))
}
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
if (fs.existsSync(p)) fs.copyFileSync(p, p.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(p, body.html, 'utf8')
log(`template ${name} updated by ops (${body.html.length} bytes, html mode)`)
return json(res, 200, { name, format: 'html', saved: true, size: body.html.length })
// Persist editor design JSON alongside (Unlayer's loadDesign() input)
let design_size = 0
if (body.design) {
const designStr = typeof body.design === 'string' ? body.design : JSON.stringify(body.design)
fs.writeFileSync(jsonPath, designStr, 'utf8')
design_size = designStr.length
}
log(`template ${name} updated (html: ${body.html.length}b${design_size ? ', design: ' + design_size + 'b' : ''})`)
return json(res, 200, {
name, format: 'html', saved: true,
size: body.html.length, design_size,
})
} catch (e) {
return json(res, 400, { error: e.message })
}