feat(email-editor): Phase 1 — scaffold easy-email microservice for visual template editing

GrapesJS-mjml proved broken on our content (plugin v1.0.8 incompatible
with MJML v5 — canvas stays empty on load). Pivot to easy-email, a
mature OSS WYSIWYG email editor (MJML-based, MIT license, 4k stars).

Architecture: standalone React+Vite microservice deployed at
editor.gigafibre.ca, iframed from the ops UI's
/campaigns/templates/:name page. Talks to the hub's existing REST
endpoints (/campaigns/templates/*) for load + save. The hub stays the
source of truth — easy-email is purely the editing UI.

Scaffold delivered in this commit (Phase 1):

- services/email-editor/ — new top-level service directory
- package.json: React 18 + easy-email-{core,editor,extensions} 4.16.x
  + Vite 5 + TypeScript 5
- vite.config.ts: standard dev/build config, port 5173 in dev
- tsconfig.json: strict-false to keep iteration fast
- index.html: loads easy-email CSS bundles from unpkg (extensions, editor,
  arco theme)
- src/main.tsx: React entry, mounts EmailEditorApp on #root
- src/EmailEditorApp.tsx:
  • Reads template name from ?name=... URL param (defaults gift-email-fr)
  • GET ${VITE_HUB_URL}/campaigns/templates/:name on mount
  • Renders <EmailEditorProvider> + <StandardLayout> with our merge tags
    map (firstname, amount, gift_url, description, expiry, etc.) so the
    Variables panel shows our Mustache placeholders
  • On save: JsonToMjml() converts easy-email's JSON → MJML, PUT to hub
    → hub compiles to HTML and persists both files
  • postMessage({type: 'email-editor:saved', ...}) to parent window so
    the iframing ops UI knows to refresh
- Dockerfile: multi-stage (Vite build → nginx alpine serve). SPA fallback
  in nginx config so all routes return index.html.
- docker-compose.yml: container behind Traefik at editor.gigafibre.ca
  with Let's Encrypt TLS via the shared proxy network.
- README.md documents the arch, URL params, postMessage protocol, dev
  workflow, and the Phase 1 limitation (no MJML→JSON importer — editor
  starts from empty page until Phase 3).
- .gitignore: standard node/vite/dist exclusions.

Build verified locally: 83 modules transformed, ~2.8 MB bundle (840 KB
gzipped) — large but acceptable since easy-email packages the full
email builder + drag-drop canvas.

Phase 2 (next): Docker deploy on prod + replace GrapesJS in the ops UI
TemplateEditorPage with an iframe pointing here.
Phase 3 (later): MJML → easy-email JSON parser so existing templates
auto-import into the canvas instead of starting blank.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-21 23:52:31 -04:00
parent 2fe8d3f50e
commit 0b6377fa58
11 changed files with 3686 additions and 0 deletions

5
services/email-editor/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.vite/
.DS_Store
*.log

View File

@ -0,0 +1,52 @@
# Multi-stage Dockerfile for the email-editor microservice.
# Stage 1: Vite build into dist/
# Stage 2: nginx serving the static files
# ── Stage 1: build ──
FROM node:20-alpine AS builder
WORKDIR /app
# Install only what's needed for build (no native deps required)
COPY package.json package-lock.json* ./
RUN npm install --silent
COPY tsconfig.json vite.config.ts index.html ./
COPY src ./src
# Inline the prod hub URL at build time. Override via --build-arg on docker
# build if running against a different hub (e.g. staging).
ARG VITE_HUB_URL=https://msg.gigafibre.ca
ENV VITE_HUB_URL=$VITE_HUB_URL
RUN npm run build
# ── Stage 2: nginx serve ──
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
# Drop the default nginx config and inject a minimal SPA-friendly one
# (all routes serve index.html — easy-email is a SPA, query params drive
# which template to load).
RUN rm /etc/nginx/conf.d/default.conf
COPY <<EOF /etc/nginx/conf.d/default.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Long cache for hashed assets (vite outputs them with content hash in name)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback — every request returns index.html so the React app handles
# routing client-side based on ?name=... query
location / {
try_files \$uri \$uri/ /index.html;
}
}
EOF
EXPOSE 80

View File

@ -0,0 +1,63 @@
# TARGO Email Editor
Standalone email template editor microservice — React + Vite + [easy-email](https://github.com/zalify/easy-email)
(OSS WYSIWYG email builder, MJML-based).
Embedded as an iframe in the ops UI's `/campaigns/templates/:name` page.
Talks to the hub's `/campaigns/templates/*` REST endpoints for load/save.
## Architecture
```
ops UI (Vue) → iframe → editor.gigafibre.ca → REST → msg.gigafibre.ca (hub)
└─ writes .mjml + .html
to /opt/targo-hub/templates/
```
## URL params
- `?name=gift-email-fr` — which template to load (defaults to gift-email-fr)
## postMessage protocol (to parent window)
Emitted on save success:
```js
{ type: 'email-editor:saved', template: 'gift-email-fr', ts: 1700000000000 }
```
The parent ops UI listens via:
```js
window.addEventListener('message', (e) => {
if (e.data.type === 'email-editor:saved') {
// refresh preview / show toast
}
})
```
## Local dev
```bash
npm install
npm run dev # http://localhost:5173?name=gift-email-fr
```
Set `VITE_HUB_URL` env to point at a non-prod hub if needed.
## Build + deploy
```bash
docker compose build
docker compose up -d
```
Traefik auto-provisions HTTPS at `editor.gigafibre.ca` (Let's Encrypt via
the `letsencrypt` resolver shared with the rest of our stack).
## Known limitations (Phase 1)
- Existing MJML templates from the hub are NOT auto-imported into the
easy-email JSON tree (no MJML → JSON parser in easy-email out of the box).
The editor starts from an empty page. User rebuilds visually with the
hub's compiled HTML as visual reference.
- TODO Phase 3: integrate an MJML → easy-email-JSON parser (likely fork or
reverse-engineer JsonToMjml).

View File

@ -0,0 +1,28 @@
services:
email-editor:
build:
context: .
args:
# Override at build time if pointing at staging:
# docker compose build --build-arg VITE_HUB_URL=https://staging-msg.gigafibre.ca
VITE_HUB_URL: https://msg.gigafibre.ca
container_name: email-editor
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
# Public route — same Authentik forwardAuth pattern as ops UI could be
# added here too, but for now the editor is iframed from the (already
# authenticated) ops UI so external auth is layered through the parent.
# Leaving it open means anyone with the URL can edit templates — fine
# for the iframe-only use case; harden later if exposed standalone.
- "traefik.http.routers.email-editor.rule=Host(`editor.gigafibre.ca`)"
- "traefik.http.routers.email-editor.entrypoints=websecure"
- "traefik.http.routers.email-editor.tls.certresolver=letsencrypt"
- "traefik.http.services.email-editor.loadbalancer.server.port=80"
networks:
proxy:
external: true

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TARGO email editor</title>
<!-- easy-email's required styles. Order matters: extensions first, then editor. -->
<link rel="stylesheet" href="https://unpkg.com/easy-email-editor@4.16.6/lib/style.css" />
<link rel="stylesheet" href="https://unpkg.com/easy-email-extensions@4.16.5/lib/style.css" />
<link rel="stylesheet" href="https://unpkg.com/@arco-themes/react-easy-email-theme/css/arco.css" />
</head>
<body style="margin:0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3219
services/email-editor/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
{
"name": "targo-email-editor",
"version": "1.0.0",
"description": "Standalone email editor microservice — easy-email (React) embedded via iframe in the ops UI. Talks to the targo-hub /campaigns/templates/* endpoints to load and save campaign templates.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"easy-email-core": "^4.16.5",
"easy-email-editor": "^4.16.6",
"easy-email-extensions": "^4.16.5",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}

View File

@ -0,0 +1,225 @@
import React, { useEffect, useState, useCallback } from 'react'
import { EmailEditorProvider, EmailEditor, IEmailTemplate } from 'easy-email-editor'
import { ExtensionProps, StandardLayout } from 'easy-email-extensions'
import { BasicType, AdvancedType, JsonToMjml } from 'easy-email-core'
// ─────────────────────────────────────────────────────────────────────────────
// Targo email editor — wraps easy-email-editor with our hub integration:
//
// 1. On mount: read ?name=<template-name> from URL, GET its MJML from the hub
// 2. Render easy-email with the loaded MJML
// 3. On save (Cmd-S or button): convert easy-email JSON → MJML, PUT to hub
// 4. postMessage to parent window so the wrapping ops UI knows we saved
//
// Hub URL is read from VITE_HUB_URL env (defaults to msg.gigafibre.ca in prod).
// The hub does the MJML → HTML compilation server-side; we just send the MJML.
// ─────────────────────────────────────────────────────────────────────────────
const HUB_URL = (import.meta as any).env?.VITE_HUB_URL || 'https://msg.gigafibre.ca'
// Merge tags exposed to the editor's "Variables" panel. These map to the
// Mustache variables the hub renders at send time.
const MERGE_TAGS = {
firstname: '{{firstname}}',
lastname: '{{lastname}}',
email: '{{email}}',
amount: '{{amount}}',
gift_url: '{{gift_url}}',
description: '{{description}}',
expiry: '{{expiry}}',
commitment_months: '{{commitment_months}}',
year: '{{year}}',
}
// Minimal initial template returned when the hub has no content yet (rare —
// since we always pre-create gift-email-fr.mjml). Kept defensive.
function emptyTemplate(): IEmailTemplate {
return {
subject: 'Une offre exclusive de TARGO',
subTitle: 'Comme toi, on aime les connexions stables et les relations durables.',
content: {
type: BasicType.PAGE,
data: {
value: {
breakpoint: '480px',
headAttributes: '',
'font-size': '16px',
'line-height': '1.5',
'font-family': "'Plus Jakarta Sans', Helvetica, Arial, sans-serif",
},
},
attributes: {
'background-color': '#F5FAF7',
width: '600px',
},
children: [],
} as any,
}
}
export function EmailEditorApp() {
const [templateName, setTemplateName] = useState<string>('')
const [initialValues, setInitialValues] = useState<IEmailTemplate | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
// Read template name from URL and fetch its MJML content from the hub on mount
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const name = params.get('name') || 'gift-email-fr'
setTemplateName(name)
;(async () => {
try {
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.`)
} catch (e: any) {
setError(`Could not load template "${name}": ${e.message}`)
setInitialValues(emptyTemplate())
} finally {
setLoading(false)
}
})()
}, [])
// Save → convert easy-email's JSON tree to MJML, PUT to hub
const onSave = useCallback(async (values: IEmailTemplate) => {
if (!templateName) return
setSaving(true)
setError(null)
try {
const mjmlSource = JsonToMjml({
data: values.content as any,
mode: 'production',
context: values.content as any,
})
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(templateName)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mjml: mjmlSource }),
})
if (!res.ok) {
const errBody = await res.json().catch(() => ({}))
throw new Error(errBody.error || `Hub returned ${res.status}`)
}
// Notify parent window (the ops UI iframing us) that we saved
if (window.parent !== window) {
window.parent.postMessage(
{ type: 'email-editor:saved', template: templateName, ts: Date.now() },
'*',
)
}
// Visual confirmation (toast handled by easy-email's own UI)
} catch (e: any) {
setError(`Save failed: ${e.message}`)
} finally {
setSaving(false)
}
}, [templateName])
if (loading) {
return <div style={{ padding: 32, textAlign: 'center' }}>Loading template</div>
}
if (!initialValues) {
return <div style={{ padding: 32, color: 'red' }}>{error}</div>
}
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Top bar — shows template name + save state + parent communication */}
<div style={{
padding: '8px 16px',
background: '#1B2E24',
color: '#fff',
fontSize: 14,
display: 'flex',
alignItems: 'center',
gap: 12,
}}>
<strong>TARGO Email Editor</strong>
<span style={{ opacity: 0.7 }}>· {templateName}</span>
{saving && <span style={{ color: '#00C853' }}>· Saving</span>}
{error && (
<span style={{ color: '#fbbf24', fontSize: 12, marginLeft: 'auto', maxWidth: '60%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{error}
</span>
)}
</div>
{/* Editor */}
<div style={{ flex: 1, overflow: 'hidden' }}>
<EmailEditorProvider
data={initialValues}
height="100%"
autoComplete
dashed={false}
mergeTags={MERGE_TAGS}
mergeTagGenerate={(tag: string) => `{{${tag}}}`}
onSubmit={onSave}
>
{() => (
<StandardLayout
showSourceCode
categories={DEFAULT_CATEGORIES}
/>
)}
</EmailEditorProvider>
</div>
</div>
)
}
// Block categories shown in the left sidebar — same set easy-email uses by
// default, organized for email composition.
const DEFAULT_CATEGORIES: ExtensionProps['categories'] = [
{
label: 'Content',
active: true,
blocks: [
{ type: AdvancedType.TEXT },
{ type: AdvancedType.BUTTON },
{ type: AdvancedType.IMAGE },
{ type: AdvancedType.DIVIDER },
{ type: AdvancedType.SPACER },
{ type: AdvancedType.HERO },
{ type: AdvancedType.WRAPPER },
],
},
{
label: 'Layout',
active: true,
displayType: 'column',
blocks: [
{
title: '1 column',
payload: [['100%']],
},
{
title: '2 columns',
payload: [['50%', '50%']],
},
{
title: '3 columns',
payload: [['33%', '33%', '33%']],
},
{
title: '4 columns',
payload: [['25%', '25%', '25%', '25%']],
},
],
},
]

View File

@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { EmailEditorApp } from './EmailEditorApp'
// Entry point — mounts the easy-email editor app on #root.
// Template name comes from URL query: ?name=gift-email-fr
// In production the page lives at editor.gigafibre.ca and is iframed from
// the ops UI's /campaigns/templates/:name route.
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<EmailEditorApp />
</React.StrictMode>,
)

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Vite config — served at editor.gigafibre.ca behind Traefik in prod.
// In dev: `npm run dev` exposes http://localhost:5173.
// Base path is '/' since this is a standalone microservice (own domain), not
// a sub-path of another app.
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: '0.0.0.0', // accessible from outside container in dev
},
build: {
outDir: 'dist',
assetsDir: 'assets',
sourcemap: false,
},
})