diff --git a/docs/template-pack.md b/docs/template-pack.md index ec5507f..1871980 100644 --- a/docs/template-pack.md +++ b/docs/template-pack.md @@ -217,3 +217,57 @@ Reusable themes may also define these constrained tokens: - Palette: `backgroundColor`, `surfaceColor`, `textColor`, `mutedColor`, `accentColor`, `buttonTextColor`, `borderColor` These tokens are editable from Preview and remain part of the draft/published theme. + + +# Production email output + +Email documents may define: + +```json +{ + "email": { + "subject": "Welcome {{customer.name}}", + "preheader": "Your account is ready" + }, + "blocks": [] +} +``` + +The email render API returns a provider-neutral payload: + +```json +{ + "subject": "Welcome Kauri", + "preheader": "Your account is ready", + "html": "...", + "text": "Welcome Kauri...", + "warnings": [], + "metadata": { + "key": "customer-welcome", + "version": 3, + "html_bytes": 18420 + } +} +``` + +This payload is suitable for transport providers such as SendGrid, Amazon SES, Postmark and Mailgun. Template Studio remains the canonical template renderer; providers only deliver the rendered subject/HTML/plain-text parts. + +## Email-safe blocks + +The email renderer supports heading, text, KPI, button, image, divider, spacer and table blocks. Tables are emitted through MJML's table component rather than browser CSS grid/flex layouts. + +Plain text is generated automatically from the same blocks, including table rows and button URLs. + +## Compatibility checks + +Each email render includes warnings for common risks including: + +- missing subject or preheader; +- missing image alt text; +- insecure HTTP image URLs; +- image-only email content; +- tables wider than six columns; +- decorative background image dependence; +- compiled HTML approaching common clipping limits. + +The editor exposes Mobile, Desktop, Plain text and Checks preview tabs. These checks are advisory and complement, rather than replace, real-client screenshot testing. diff --git a/server/index.js b/server/index.js index 456a527..ce5e2a3 100644 --- a/server/index.js +++ b/server/index.js @@ -3,7 +3,7 @@ import express from 'express' import { chromium } from 'playwright-core' import { initDb, getPool } from './db.js' import { readSession, registerAuthRoutes, requireAuth, verifyMachineToken } from './auth.js' -import { renderEmailHtml, renderReportHtml } from './templateEngine.js' +import { renderEmailMessage, renderReportHtml } from './templateEngine.js' import { getTemplateAsset, importTemplatePack, templatePackSpec } from './templatePack.js' import { getThemeAsset, importThemeJson, importThemePack, themePackSpec } from './themePack.js' @@ -188,10 +188,10 @@ async function publishedTemplateByKey(key) { return rows[0] || null } -async function sendRendered(res, type, document, data, format) { +async function sendRendered(res, type, document, data, format, metadata = {}) { if (format === 'email') { if (type !== 'email') return res.status(400).json({ error: 'format_not_valid_for_template' }) - return res.json({ html: renderEmailHtml(document, data) }) + return res.json(renderEmailMessage(document, data, metadata)) } const html = renderReportHtml(document, data) @@ -256,7 +256,12 @@ app.post('/api/v1/templates/:key/render', async (req, res) => { const data = req.body?.data ?? {} const format = req.body?.format || (template.type === 'email' ? 'email' : 'html') res.setHeader('Cache-Control', 'no-store') - await sendRendered(res, template.type, template.document, data, format) + await sendRendered(res, template.type, template.document, data, format, { + key: template.template_key, + name: template.name, + version: template.published_version, + published_at: template.published_at + }) }) /** @@ -468,7 +473,13 @@ app.post('/api/templates/:id/render', async (req, res) => { const data = req.body?.data ?? template.sample_data ?? {} const format = req.body?.format || (template.type === 'email' ? 'email' : 'html') res.setHeader('Cache-Control', 'no-store') - await sendRendered(res, template.type, template.draft_document, data, format) + await sendRendered(res, template.type, template.draft_document, data, format, { + id: template.id, + key: template.template_key, + name: template.name, + version: null, + draft: true + }) }) app.get('/api/api-keys', async (_req, res) => { diff --git a/server/templateEngine.js b/server/templateEngine.js index 2f51680..5fdab3b 100644 --- a/server/templateEngine.js +++ b/server/templateEngine.js @@ -202,6 +202,9 @@ function blockHtml(block, data, theme) { case 'divider': html = '
' break + case 'spacer': + html = `
` + break case 'table': html = renderTable(block, data, theme) break @@ -247,6 +250,19 @@ export function renderReportHtml(template, data = {}) {
${renderReportBlocks(blocks, data, theme)}
` } +function emailTable(block, data, theme) { + const rows = getPath(data, block.props?.rowsPath) || [] + const columns = Array.isArray(block.props?.columns) ? block.props.columns : [] + if (!Array.isArray(rows) || !columns.length) return '' + const cellPadding = safeNumber(block?.style?.cellPadding, theme.tableCellPadding, 2, 20) + const headerStyle = `padding:${cellPadding}px;border-bottom:1px solid ${theme.borderColor};background:${theme.backgroundColor};color:${theme.textColor};font-weight:700;text-align:left;` + const cellStyle = `padding:${cellPadding}px;border-bottom:1px solid ${theme.borderColor};color:${theme.textColor};text-align:left;vertical-align:top;` + return ` + ${columns.map(column => `${escapeHtml(column.label)}`).join('')} + ${rows.map(row => `${columns.map(column => `${escapeHtml(getPath(row, column.key))}`).join('')}`).join('')} + ` +} + function emailBlock(block, data, theme) { const p = block.props || {} const style = block.style || {} @@ -272,17 +288,149 @@ function emailBlock(block, data, theme) { return `` case 'divider': return `` + case 'table': + return emailTable(block, data, theme) + case 'spacer': + return `` default: - return 'This block is available in report output only.' + return '' + } +} + +function emailMeta(template, data) { + const email = template?.email || {} + return { + subject: interpolate(email.subject || '', data).trim(), + preheader: interpolate(email.preheader || '', data).trim() + } +} + +function textValue(value) { + return String(value ?? '').replace(/\s+/g, ' ').trim() +} + +function emailBlockText(block, data) { + const p = block?.props || {} + switch (block?.type) { + case 'heading': + case 'text': + return textValue(interpolate(p.text, data)) + case 'kpi': { + const label = textValue(interpolate(p.label, data)) + const value = textValue(interpolate(p.value, data)) + return [label, value].filter(Boolean).join(': ') + } + case 'button': { + const label = textValue(interpolate(p.text, data)) + const url = safeUrl(p.url, data) + return url === '#' ? label : [label, url].filter(Boolean).join(': ') + } + case 'image': { + const alt = textValue(interpolate(p.alt, data)) + const url = safeUrl(p.url, data) + return alt ? `[Image: ${alt}]${url === '#' ? '' : ` ${url}`}` : '' + } + case 'divider': + return '---' + case 'table': { + const rows = getPath(data, p.rowsPath) || [] + const columns = Array.isArray(p.columns) ? p.columns : [] + if (!Array.isArray(rows) || !columns.length) return '' + const header = columns.map(column => textValue(column.label)).join(' | ') + const body = rows.map(row => columns.map(column => textValue(getPath(row, column.key))).join(' | ')).join('\n') + return [header, body].filter(Boolean).join('\n') + } + case 'spacer': + return '' + default: + return '' + } +} + +export function renderEmailText(template, data = {}) { + return (template?.blocks || []) + .map(block => emailBlockText(block, data)) + .filter(Boolean) + .join('\n\n') + .trim() +} + +export function emailCompatibilityWarnings(template, data = {}, html = '') { + const warnings = [] + const blocks = Array.isArray(template?.blocks) ? template.blocks : [] + const meta = emailMeta(template, data) + const htmlBytes = Buffer.byteLength(String(html || ''), 'utf8') + + if (!meta.subject) warnings.push({ code: 'missing_subject', severity: 'error', message: 'Add an email subject before publishing.' }) + if (!meta.preheader) warnings.push({ code: 'missing_preheader', severity: 'warning', message: 'Add preheader text for stronger inbox previews.' }) + + const images = blocks.filter(block => block?.type === 'image') + for (const block of images) { + if (!textValue(interpolate(block?.props?.alt, data))) { + warnings.push({ code: 'missing_alt', severity: 'warning', block_id: block.id || null, message: 'An image is missing alt text.' }) + } + const url = interpolate(block?.props?.url, data).trim() + if (/^http:\/\//i.test(url)) { + warnings.push({ code: 'insecure_image_url', severity: 'warning', block_id: block.id || null, message: 'Use HTTPS for remote email images.' }) + } + } + + const tables = blocks.filter(block => block?.type === 'table') + for (const block of tables) { + const columns = Array.isArray(block?.props?.columns) ? block.props.columns : [] + if (columns.length > 6) { + warnings.push({ code: 'wide_table', severity: 'warning', block_id: block.id || null, message: `A table has ${columns.length} columns and may be hard to read on mobile.` }) + } + } + + const meaningfulTextBlocks = blocks.filter(block => ['heading', 'text', 'kpi', 'button', 'table'].includes(block?.type)) + if (images.length && meaningfulTextBlocks.length === 0) { + warnings.push({ code: 'image_only', severity: 'error', message: 'Avoid image-only emails; include real text content.' }) + } + + const theme = template?.theme || {} + if (theme.pageBackgroundImage && theme.pageBackgroundImage !== '#') { + warnings.push({ code: 'background_image', severity: 'info', message: 'Background images are decorative only; some inboxes may not display them.' }) + } + + if (htmlBytes >= 100 * 1024) { + warnings.push({ code: 'html_size_critical', severity: 'error', message: `Compiled HTML is ${Math.round(htmlBytes / 1024)} KB. Reduce content to avoid inbox clipping.` }) + } else if (htmlBytes >= 85 * 1024) { + warnings.push({ code: 'html_size_high', severity: 'warning', message: `Compiled HTML is ${Math.round(htmlBytes / 1024)} KB and is approaching common clipping limits.` }) + } + + return warnings +} + +export function renderEmailMessage(template, data = {}, metadata = {}) { + const theme = themeOf(template) + const meta = emailMeta(template, data) + const body = (template?.blocks || []).map(block => emailBlock(block, data, theme)).join('\n') + const backgroundUrl = theme.pageBackgroundImage === '#' ? '' : ` background-url="${escapeHtml(theme.pageBackgroundImage)}" background-size="cover" background-repeat="no-repeat"` + const preview = meta.preheader ? `${escapeHtml(meta.preheader)}` : '' + const mjml = `${preview}${body}` + const result = mjml2html(mjml, { validationLevel: 'soft' }) + const html = result.html + const text = renderEmailText(template, data) + const warnings = emailCompatibilityWarnings(template, data, html) + if (result.errors?.length) { + for (const error of result.errors) { + warnings.push({ code: 'mjml_warning', severity: 'warning', message: String(error.formattedMessage || error.message || error) }) + } + } + return { + subject: meta.subject, + preheader: meta.preheader, + html, + text, + warnings, + metadata: { + ...metadata, + html_bytes: Buffer.byteLength(html, 'utf8') + } } } export function renderEmailHtml(template, data = {}) { - const theme = themeOf(template) - const body = (template?.blocks || []).map(block => emailBlock(block, data, theme)).join('\n') - const backgroundUrl = theme.pageBackgroundImage === '#' ? '' : ` background-url="${escapeHtml(theme.pageBackgroundImage)}" background-size="cover" background-repeat="no-repeat"` - const mjml = `${body}` - const result = mjml2html(mjml, { validationLevel: 'soft' }) - if (result.errors?.length) console.warn('MJML warnings', result.errors) - return result.html + return renderEmailMessage(template, data).html } diff --git a/server/templateEngine.test.js b/server/templateEngine.test.js index 858fc9d..0c181f8 100644 --- a/server/templateEngine.test.js +++ b/server/templateEngine.test.js @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { interpolate, renderReportHtml, renderEmailHtml } from './templateEngine.js' +import { interpolate, renderReportHtml, renderEmailHtml, renderEmailMessage, renderEmailText, emailCompatibilityWarnings } from './templateEngine.js' test('interpolates nested values', () => { assert.equal(interpolate('Hello {{customer.name}}', { customer: { name: 'Kauri' } }), 'Hello Kauri') @@ -209,3 +209,79 @@ test('theme typography tokens drive email output', () => { assert.match(html, /font-size:31px/) assert.match(html, /#123456/i) }) + + +test('email message includes subject preheader html text and metadata', () => { + const message = renderEmailMessage({ + email: { subject: 'Hello {{customer.name}}', preheader: 'Your update is ready' }, + blocks: [ + { type: 'heading', props: { text: 'Hello {{customer.name}}' } }, + { type: 'button', props: { text: 'Open', url: 'https://example.com' } } + ] + }, { customer: { name: 'Kauri' } }, { key: 'welcome', version: 2 }) + + assert.equal(message.subject, 'Hello Kauri') + assert.equal(message.preheader, 'Your update is ready') + assert.match(message.html, /Your update is ready/) + assert.match(message.text, /Hello Kauri/) + assert.match(message.text, /https:\/\/example\.com/) + assert.equal(message.metadata.key, 'welcome') + assert.equal(message.metadata.version, 2) + assert.ok(message.metadata.html_bytes > 0) +}) + +test('email renderer produces inbox-safe table markup and plain text table', () => { + const template = { + email: { subject: 'Order update', preheader: 'Latest lines' }, + blocks: [{ + id: 'orders', + type: 'table', + props: { + rowsPath: 'orders', + columns: [ + { label: 'Order', key: 'order' }, + { label: 'Status', key: 'status' } + ] + } + }] + } + const data = { orders: [{ order: 'PO-1', status: 'Ready' }] } + const html = renderEmailHtml(template, data) + const text = renderEmailText(template, data) + assert.match(html, /PO-1/) + assert.match(html, /Ready/) + assert.match(text, /Order \| Status/) + assert.match(text, /PO-1 \| Ready/) +}) + +test('email compatibility checker catches common delivery risks', () => { + const template = { + email: { subject: '' }, + blocks: [ + { id: 'hero', type: 'image', props: { url: 'http://example.com/hero.png', alt: '' } }, + { + id: 'wide', + type: 'table', + props: { + rowsPath: 'rows', + columns: Array.from({ length: 7 }, (_, index) => ({ label: `C${index}`, key: `c${index}` })) + } + } + ] + } + const warnings = emailCompatibilityWarnings(template, { rows: [] }, '') + const codes = warnings.map(item => item.code) + assert.ok(codes.includes('missing_subject')) + assert.ok(codes.includes('missing_preheader')) + assert.ok(codes.includes('missing_alt')) + assert.ok(codes.includes('insecure_image_url')) + assert.ok(codes.includes('wide_table')) +}) + +test('email spacer renders safely', () => { + const html = renderEmailHtml({ + email: { subject: 'Spacer', preheader: 'Preview' }, + blocks: [{ type: 'spacer', props: { height: 32 } }] + }, {}) + assert.match(html, /height:32px/) +}) diff --git a/src/App.tsx b/src/App.tsx index 46bc489..4c5ba2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,8 @@ import type { TemplateRecord, TemplateSummary, TemplateType, - ThemeSummary + ThemeSummary, + EmailRenderResult } from './types' type Screen = 'templates' | 'editor' | 'preview' | 'data' | 'use' @@ -51,7 +52,8 @@ const blockLabels: Record = { button: 'Button', image: 'Image', divider: 'Divider', - table: 'Table' + table: 'Table', + spacer: 'Spacer' } function flatten(value: unknown, prefix = ''): string[] { @@ -67,6 +69,7 @@ function flatten(value: unknown, prefix = ''): string[] { function blockSummary(block: Block) { if (block.type === 'divider') return 'Divider' + if (block.type === 'spacer') return `Spacer · ${String(block.props.height || 24)}px` if (block.type === 'table') return `Rows: ${String(block.props.rowsPath || 'items')}` if (block.type === 'kpi') return `${String(block.props.label || 'Metric')} · ${String(block.props.value || '')}` return String(block.props.text || block.props.alt || blockLabels[block.type]) @@ -91,6 +94,8 @@ export function App({ user }: Props) { const [saveState, setSaveState] = useState('saved') const [previewHtml, setPreviewHtml] = useState('') const [previewDevice, setPreviewDevice] = useState<'mobile' | 'desktop'>('mobile') + const [emailPreviewMode, setEmailPreviewMode] = useState<'desktop' | 'mobile' | 'text' | 'warnings'>('mobile') + const [emailRender, setEmailRender] = useState(null) const [dataText, setDataText] = useState('{}') const [error, setError] = useState('') const [filter, setFilter] = useState('all') @@ -237,6 +242,13 @@ export function App({ user }: Props) { })) } + function updateEmailMeta(patch: { subject?: string; preheader?: string }) { + setTemplateDoc(current => ({ + ...current, + email: { ...(current.email || {}), ...patch } + })) + } + function themeNumber(key: string, fallback: number) { const value = Number(templateDoc.theme?.[key]) return Number.isFinite(value) ? value : fallback @@ -325,10 +337,20 @@ export function App({ user }: Props) { if (!active) return await saveNow() const format = active.type === 'email' ? 'email' : 'html' + if (active.type === 'email') { + const result = await api(`/api/templates/${active.id}/render`, { + method: 'POST', + body: JSON.stringify({ format, data: sampleData }) + }) + setEmailRender(result) + setPreviewHtml(result.html) + return + } const result = await api<{ html: string }>(`/api/templates/${active.id}/render`, { method: 'POST', body: JSON.stringify({ format, data: sampleData }) }) + setEmailRender(null) setPreviewHtml(result.html) } @@ -378,7 +400,7 @@ export function App({ user }: Props) { function bindingField(block: Block) { if (block.type === 'kpi') return 'value' if (block.type === 'image') return 'alt' - if (block.type === 'table' || block.type === 'divider') return null + if (block.type === 'table' || block.type === 'divider' || block.type === 'spacer') return null return 'text' } @@ -902,14 +924,96 @@ export function App({ user }: Props) { })}>Reset page setup )} -
- - -
-
- {previewHtml ?