feat: harden email rendering for delivery providers
This commit is contained in:
parent
e2274c1ecb
commit
7a36a0896c
@ -217,3 +217,57 @@ Reusable themes may also define these constrained tokens:
|
|||||||
- Palette: `backgroundColor`, `surfaceColor`, `textColor`, `mutedColor`, `accentColor`, `buttonTextColor`, `borderColor`
|
- Palette: `backgroundColor`, `surfaceColor`, `textColor`, `mutedColor`, `accentColor`, `buttonTextColor`, `borderColor`
|
||||||
|
|
||||||
These tokens are editable from Preview and remain part of the draft/published theme.
|
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": "<!doctype 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.
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import express from 'express'
|
|||||||
import { chromium } from 'playwright-core'
|
import { chromium } from 'playwright-core'
|
||||||
import { initDb, getPool } from './db.js'
|
import { initDb, getPool } from './db.js'
|
||||||
import { readSession, registerAuthRoutes, requireAuth, verifyMachineToken } from './auth.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 { getTemplateAsset, importTemplatePack, templatePackSpec } from './templatePack.js'
|
||||||
import { getThemeAsset, importThemeJson, importThemePack, themePackSpec } from './themePack.js'
|
import { getThemeAsset, importThemeJson, importThemePack, themePackSpec } from './themePack.js'
|
||||||
|
|
||||||
@ -188,10 +188,10 @@ async function publishedTemplateByKey(key) {
|
|||||||
return rows[0] || null
|
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 (format === 'email') {
|
||||||
if (type !== 'email') return res.status(400).json({ error: 'format_not_valid_for_template' })
|
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)
|
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 data = req.body?.data ?? {}
|
||||||
const format = req.body?.format || (template.type === 'email' ? 'email' : 'html')
|
const format = req.body?.format || (template.type === 'email' ? 'email' : 'html')
|
||||||
res.setHeader('Cache-Control', 'no-store')
|
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 data = req.body?.data ?? template.sample_data ?? {}
|
||||||
const format = req.body?.format || (template.type === 'email' ? 'email' : 'html')
|
const format = req.body?.format || (template.type === 'email' ? 'email' : 'html')
|
||||||
res.setHeader('Cache-Control', 'no-store')
|
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) => {
|
app.get('/api/api-keys', async (_req, res) => {
|
||||||
|
|||||||
@ -202,6 +202,9 @@ function blockHtml(block, data, theme) {
|
|||||||
case 'divider':
|
case 'divider':
|
||||||
html = '<hr />'
|
html = '<hr />'
|
||||||
break
|
break
|
||||||
|
case 'spacer':
|
||||||
|
html = `<div style="height:${safeNumber(p.height, 24, 4, 120)}px"></div>`
|
||||||
|
break
|
||||||
case 'table':
|
case 'table':
|
||||||
html = renderTable(block, data, theme)
|
html = renderTable(block, data, theme)
|
||||||
break
|
break
|
||||||
@ -247,6 +250,19 @@ export function renderReportHtml(template, data = {}) {
|
|||||||
</style></head><body><main class="document" data-density="${theme.reportDensity}">${renderReportBlocks(blocks, data, theme)}</main></body></html>`
|
</style></head><body><main class="document" data-density="${theme.reportDensity}">${renderReportBlocks(blocks, data, theme)}</main></body></html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 `<mj-table padding="0" font-size="${Math.max(10, theme.baseFontSize - 2)}px" line-height="${theme.lineHeight}">
|
||||||
|
<tr>${columns.map(column => `<th style="${headerStyle}">${escapeHtml(column.label)}</th>`).join('')}</tr>
|
||||||
|
${rows.map(row => `<tr>${columns.map(column => `<td style="${cellStyle}">${escapeHtml(getPath(row, column.key))}</td>`).join('')}</tr>`).join('')}
|
||||||
|
</mj-table>`
|
||||||
|
}
|
||||||
|
|
||||||
function emailBlock(block, data, theme) {
|
function emailBlock(block, data, theme) {
|
||||||
const p = block.props || {}
|
const p = block.props || {}
|
||||||
const style = block.style || {}
|
const style = block.style || {}
|
||||||
@ -272,17 +288,149 @@ function emailBlock(block, data, theme) {
|
|||||||
return `<mj-image padding="${padding}" border="${borderWidth}px solid ${borderColor}" border-radius="${radius}px" src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
return `<mj-image padding="${padding}" border="${borderWidth}px solid ${borderColor}" border-radius="${radius}px" src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
||||||
case 'divider':
|
case 'divider':
|
||||||
return `<mj-divider border-color="${borderColor}" border-width="${Math.max(1, borderWidth)}px" />`
|
return `<mj-divider border-color="${borderColor}" border-width="${Math.max(1, borderWidth)}px" />`
|
||||||
|
case 'table':
|
||||||
|
return emailTable(block, data, theme)
|
||||||
|
case 'spacer':
|
||||||
|
return `<mj-spacer height="${safeNumber(p.height, 24, 4, 120)}px" />`
|
||||||
default:
|
default:
|
||||||
return '<mj-text color="#64748b">This block is available in report output only.</mj-text>'
|
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 ? `<mj-preview>${escapeHtml(meta.preheader)}</mj-preview>` : ''
|
||||||
|
const mjml = `<mjml><mj-head>${preview}<mj-attributes><mj-all font-family="${theme.fontFamily}, sans-serif" /><mj-text font-size="${theme.baseFontSize}px" line-height="${theme.lineHeight}" color="${theme.textColor}" /></mj-attributes></mj-head><mj-body background-color="${theme.backgroundColor}"><mj-section background-color="${theme.surfaceColor}" padding="${theme.pagePadding}px"${backgroundUrl}><mj-column>${body}</mj-column></mj-section></mj-body></mjml>`
|
||||||
|
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 = {}) {
|
export function renderEmailHtml(template, data = {}) {
|
||||||
const theme = themeOf(template)
|
return renderEmailMessage(template, data).html
|
||||||
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 = `<mjml><mj-head><mj-attributes><mj-all font-family="${theme.fontFamily}, sans-serif" /><mj-text font-size="${theme.baseFontSize}px" line-height="${theme.lineHeight}" color="${theme.textColor}" /></mj-attributes></mj-head><mj-body background-color="${theme.backgroundColor}"><mj-section background-color="${theme.surfaceColor}" padding="${theme.pagePadding}px"${backgroundUrl}><mj-column>${body}</mj-column></mj-section></mj-body></mjml>`
|
|
||||||
const result = mjml2html(mjml, { validationLevel: 'soft' })
|
|
||||||
if (result.errors?.length) console.warn('MJML warnings', result.errors)
|
|
||||||
return result.html
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
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', () => {
|
test('interpolates nested values', () => {
|
||||||
assert.equal(interpolate('Hello {{customer.name}}', { customer: { name: 'Kauri' } }), 'Hello Kauri')
|
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, /font-size:31px/)
|
||||||
assert.match(html, /#123456/i)
|
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/)
|
||||||
|
})
|
||||||
|
|||||||
145
src/App.tsx
145
src/App.tsx
@ -9,7 +9,8 @@ import type {
|
|||||||
TemplateRecord,
|
TemplateRecord,
|
||||||
TemplateSummary,
|
TemplateSummary,
|
||||||
TemplateType,
|
TemplateType,
|
||||||
ThemeSummary
|
ThemeSummary,
|
||||||
|
EmailRenderResult
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
type Screen = 'templates' | 'editor' | 'preview' | 'data' | 'use'
|
type Screen = 'templates' | 'editor' | 'preview' | 'data' | 'use'
|
||||||
@ -51,7 +52,8 @@ const blockLabels: Record<BlockType, string> = {
|
|||||||
button: 'Button',
|
button: 'Button',
|
||||||
image: 'Image',
|
image: 'Image',
|
||||||
divider: 'Divider',
|
divider: 'Divider',
|
||||||
table: 'Table'
|
table: 'Table',
|
||||||
|
spacer: 'Spacer'
|
||||||
}
|
}
|
||||||
|
|
||||||
function flatten(value: unknown, prefix = ''): string[] {
|
function flatten(value: unknown, prefix = ''): string[] {
|
||||||
@ -67,6 +69,7 @@ function flatten(value: unknown, prefix = ''): string[] {
|
|||||||
|
|
||||||
function blockSummary(block: Block) {
|
function blockSummary(block: Block) {
|
||||||
if (block.type === 'divider') return 'Divider'
|
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 === 'table') return `Rows: ${String(block.props.rowsPath || 'items')}`
|
||||||
if (block.type === 'kpi') return `${String(block.props.label || 'Metric')} · ${String(block.props.value || '')}`
|
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])
|
return String(block.props.text || block.props.alt || blockLabels[block.type])
|
||||||
@ -91,6 +94,8 @@ export function App({ user }: Props) {
|
|||||||
const [saveState, setSaveState] = useState<SaveState>('saved')
|
const [saveState, setSaveState] = useState<SaveState>('saved')
|
||||||
const [previewHtml, setPreviewHtml] = useState('')
|
const [previewHtml, setPreviewHtml] = useState('')
|
||||||
const [previewDevice, setPreviewDevice] = useState<'mobile' | 'desktop'>('mobile')
|
const [previewDevice, setPreviewDevice] = useState<'mobile' | 'desktop'>('mobile')
|
||||||
|
const [emailPreviewMode, setEmailPreviewMode] = useState<'desktop' | 'mobile' | 'text' | 'warnings'>('mobile')
|
||||||
|
const [emailRender, setEmailRender] = useState<EmailRenderResult | null>(null)
|
||||||
const [dataText, setDataText] = useState('{}')
|
const [dataText, setDataText] = useState('{}')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [filter, setFilter] = useState<Filter>('all')
|
const [filter, setFilter] = useState<Filter>('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) {
|
function themeNumber(key: string, fallback: number) {
|
||||||
const value = Number(templateDoc.theme?.[key])
|
const value = Number(templateDoc.theme?.[key])
|
||||||
return Number.isFinite(value) ? value : fallback
|
return Number.isFinite(value) ? value : fallback
|
||||||
@ -325,10 +337,20 @@ export function App({ user }: Props) {
|
|||||||
if (!active) return
|
if (!active) return
|
||||||
await saveNow()
|
await saveNow()
|
||||||
const format = active.type === 'email' ? 'email' : 'html'
|
const format = active.type === 'email' ? 'email' : 'html'
|
||||||
|
if (active.type === 'email') {
|
||||||
|
const result = await api<EmailRenderResult>(`/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`, {
|
const result = await api<{ html: string }>(`/api/templates/${active.id}/render`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ format, data: sampleData })
|
body: JSON.stringify({ format, data: sampleData })
|
||||||
})
|
})
|
||||||
|
setEmailRender(null)
|
||||||
setPreviewHtml(result.html)
|
setPreviewHtml(result.html)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -378,7 +400,7 @@ export function App({ user }: Props) {
|
|||||||
function bindingField(block: Block) {
|
function bindingField(block: Block) {
|
||||||
if (block.type === 'kpi') return 'value'
|
if (block.type === 'kpi') return 'value'
|
||||||
if (block.type === 'image') return 'alt'
|
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'
|
return 'text'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -902,14 +924,96 @@ export function App({ user }: Props) {
|
|||||||
})}>Reset page setup</button>
|
})}>Reset page setup</button>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
<div className="segmented preview-segment">
|
{active?.type === 'email' ? (
|
||||||
<button className={previewDevice === 'mobile' ? 'active' : ''} onClick={() => setPreviewDevice('mobile')}>Phone</button>
|
<>
|
||||||
<button className={previewDevice === 'desktop' ? 'active' : ''} onClick={() => setPreviewDevice('desktop')}>Desktop</button>
|
<section className="email-delivery-card">
|
||||||
</div>
|
<div className="theme-controls-heading">
|
||||||
<div className={`preview-frame-wrap ${previewDevice}`}>
|
<div>
|
||||||
{previewHtml ? <iframe title="Template preview" srcDoc={previewHtml} sandbox="" /> : <div className="empty">Generating preview…</div>}
|
<span className="eyebrow">EMAIL DELIVERY</span>
|
||||||
</div>
|
<h3>Subject & inbox preview</h3>
|
||||||
{active?.type === 'report' && <button className="button primary full large" onClick={() => void downloadPdf()}>Download draft PDF</button>}
|
</div>
|
||||||
|
<span>{emailRender?.warnings.filter(item => item.severity === 'error').length ? 'Needs work' : 'Ready'}</span>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Subject
|
||||||
|
<input
|
||||||
|
value={templateDoc.email?.subject || ''}
|
||||||
|
onChange={event => updateEmailMeta({ subject: event.target.value })}
|
||||||
|
placeholder="Your {{customer.name}} update"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Preheader
|
||||||
|
<input
|
||||||
|
value={templateDoc.email?.preheader || ''}
|
||||||
|
onChange={event => updateEmailMeta({ preheader: event.target.value })}
|
||||||
|
placeholder="Short inbox preview text"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="email-size-row">
|
||||||
|
<span>Compiled HTML</span>
|
||||||
|
<strong>{Math.round(Number(emailRender?.metadata?.html_bytes || 0) / 1024)} KB</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="segmented preview-segment email-preview-tabs">
|
||||||
|
{[
|
||||||
|
['mobile', 'Mobile'],
|
||||||
|
['desktop', 'Desktop'],
|
||||||
|
['text', 'Plain text'],
|
||||||
|
['warnings', `Checks ${emailRender?.warnings.length ? `(${emailRender.warnings.length})` : ''}`]
|
||||||
|
].map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
className={emailPreviewMode === value ? 'active' : ''}
|
||||||
|
onClick={() => setEmailPreviewMode(value as 'desktop' | 'mobile' | 'text' | 'warnings')}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(emailPreviewMode === 'mobile' || emailPreviewMode === 'desktop') && (
|
||||||
|
<div className={`preview-frame-wrap ${emailPreviewMode}`}>
|
||||||
|
{previewHtml ? <iframe title="Email preview" srcDoc={previewHtml} sandbox="" /> : <div className="empty">Generating preview…</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{emailPreviewMode === 'text' && (
|
||||||
|
<pre className="plain-text-preview">{emailRender?.text || 'No plain-text output yet.'}</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{emailPreviewMode === 'warnings' && (
|
||||||
|
<section className="compatibility-panel">
|
||||||
|
{!emailRender?.warnings.length ? (
|
||||||
|
<div className="compatibility-clear">
|
||||||
|
<strong>✓ No compatibility warnings</strong>
|
||||||
|
<span>Template Studio found no common inbox risks in this render.</span>
|
||||||
|
</div>
|
||||||
|
) : emailRender.warnings.map((warning, index) => (
|
||||||
|
<article className={`compatibility-warning ${warning.severity}`} key={`${warning.code}-${warning.block_id || index}`}>
|
||||||
|
<span>{warning.severity === 'error' ? '!' : warning.severity === 'warning' ? '△' : 'i'}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{warning.code.replaceAll('_', ' ')}</strong>
|
||||||
|
<p>{warning.message}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="segmented preview-segment">
|
||||||
|
<button className={previewDevice === 'mobile' ? 'active' : ''} onClick={() => setPreviewDevice('mobile')}>Phone</button>
|
||||||
|
<button className={previewDevice === 'desktop' ? 'active' : ''} onClick={() => setPreviewDevice('desktop')}>Desktop</button>
|
||||||
|
</div>
|
||||||
|
<div className={`preview-frame-wrap ${previewDevice}`}>
|
||||||
|
{previewHtml ? <iframe title="Template preview" srcDoc={previewHtml} sandbox="" /> : <div className="empty">Generating preview…</div>}
|
||||||
|
</div>
|
||||||
|
<button className="button primary full large" onClick={() => void downloadPdf()}>Download draft PDF</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -973,6 +1077,9 @@ export function App({ user }: Props) {
|
|||||||
"customer": { "name": "Example" }
|
"customer": { "name": "Example" }
|
||||||
}
|
}
|
||||||
}`}</pre>
|
}`}</pre>
|
||||||
|
{active?.type === 'email' && (
|
||||||
|
<p className="integration-note">Email renders return provider-neutral <code>subject</code>, <code>html</code>, <code>text</code>, <code>preheader</code>, <code>warnings</code> and version metadata for SendGrid, SES, Postmark or Mailgun.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
@ -1058,7 +1165,7 @@ export function App({ user }: Props) {
|
|||||||
<div className="block-picker">
|
<div className="block-picker">
|
||||||
{(Object.keys(blockLabels) as BlockType[]).map(type => (
|
{(Object.keys(blockLabels) as BlockType[]).map(type => (
|
||||||
<button key={type} onClick={() => addBlock(type)}>
|
<button key={type} onClick={() => addBlock(type)}>
|
||||||
<span className="picker-icon">{type === 'heading' ? 'H' : type === 'text' ? 'T' : type === 'kpi' ? '#' : type === 'button' ? '↗' : type === 'image' ? '▧' : type === 'divider' ? '—' : '▦'}</span>
|
<span className="picker-icon">{type === 'heading' ? 'H' : type === 'text' ? 'T' : type === 'kpi' ? '#' : type === 'button' ? '↗' : type === 'image' ? '▧' : type === 'divider' ? '—' : type === 'spacer' ? '↕' : '▦'}</span>
|
||||||
{blockLabels[type]}
|
{blockLabels[type]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -1129,6 +1236,20 @@ export function App({ user }: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selected.type === 'spacer' && (
|
||||||
|
<label>
|
||||||
|
Height (px)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="4"
|
||||||
|
max="120"
|
||||||
|
step="4"
|
||||||
|
value={String(selected.props.height || 24)}
|
||||||
|
onChange={event => updateBlock(selected.id, { height: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
{selected.type === 'table' && (
|
{selected.type === 'table' && (
|
||||||
<>
|
<>
|
||||||
<label>Rows data path<input value={String(selected.props.rowsPath || '')} onChange={event => updateBlock(selected.id, { rowsPath: event.target.value })} placeholder="items" /></label>
|
<label>Rows data path<input value={String(selected.props.rowsPath || '')} onChange={event => updateBlock(selected.id, { rowsPath: event.target.value })} placeholder="items" /></label>
|
||||||
|
|||||||
119
src/refine.css
119
src/refine.css
@ -1078,3 +1078,122 @@ body{
|
|||||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.email-delivery-card{
|
||||||
|
margin:0 0 14px;
|
||||||
|
padding:14px;
|
||||||
|
border:1px solid var(--line);
|
||||||
|
border-radius:16px;
|
||||||
|
background:#fff;
|
||||||
|
box-shadow:0 3px 12px rgba(15,23,42,.025);
|
||||||
|
}
|
||||||
|
.email-delivery-card>label{
|
||||||
|
display:grid;
|
||||||
|
gap:5px;
|
||||||
|
margin-top:10px;
|
||||||
|
color:#59677a;
|
||||||
|
font-size:10px;
|
||||||
|
font-weight:800;
|
||||||
|
}
|
||||||
|
.email-delivery-card input{
|
||||||
|
width:100%;
|
||||||
|
border:1px solid #d6dde6;
|
||||||
|
border-radius:10px;
|
||||||
|
padding:10px 11px;
|
||||||
|
background:#f9fafb;
|
||||||
|
color:#263449;
|
||||||
|
font-size:12px;
|
||||||
|
}
|
||||||
|
.email-size-row{
|
||||||
|
display:flex;
|
||||||
|
justify-content:space-between;
|
||||||
|
gap:12px;
|
||||||
|
padding-top:10px;
|
||||||
|
margin-top:10px;
|
||||||
|
border-top:1px solid #edf1f5;
|
||||||
|
color:#8994a4;
|
||||||
|
font-size:9px;
|
||||||
|
}
|
||||||
|
.email-size-row strong{color:#344258}
|
||||||
|
.email-preview-tabs{
|
||||||
|
overflow-x:auto;
|
||||||
|
display:flex;
|
||||||
|
}
|
||||||
|
.email-preview-tabs button{flex:1 0 auto}
|
||||||
|
.plain-text-preview{
|
||||||
|
min-height:420px;
|
||||||
|
margin:0 0 14px;
|
||||||
|
padding:16px;
|
||||||
|
overflow:auto;
|
||||||
|
border:1px solid #dce3ea;
|
||||||
|
border-radius:14px;
|
||||||
|
background:#fcfcfd;
|
||||||
|
color:#283548;
|
||||||
|
white-space:pre-wrap;
|
||||||
|
word-break:break-word;
|
||||||
|
font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||||
|
}
|
||||||
|
.compatibility-panel{
|
||||||
|
display:grid;
|
||||||
|
gap:8px;
|
||||||
|
margin-bottom:14px;
|
||||||
|
}
|
||||||
|
.compatibility-warning{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:28px minmax(0,1fr);
|
||||||
|
gap:9px;
|
||||||
|
padding:11px;
|
||||||
|
border:1px solid #e0e6ed;
|
||||||
|
border-radius:12px;
|
||||||
|
background:#fff;
|
||||||
|
}
|
||||||
|
.compatibility-warning>span{
|
||||||
|
width:26px;
|
||||||
|
height:26px;
|
||||||
|
display:grid;
|
||||||
|
place-items:center;
|
||||||
|
border-radius:8px;
|
||||||
|
background:#eef3f7;
|
||||||
|
font-weight:900;
|
||||||
|
}
|
||||||
|
.compatibility-warning.warning{
|
||||||
|
border-color:#efdca6;
|
||||||
|
background:#fffaf0;
|
||||||
|
}
|
||||||
|
.compatibility-warning.warning>span{background:#f8e9bd;color:#7a5a00}
|
||||||
|
.compatibility-warning.error{
|
||||||
|
border-color:#f0b9c2;
|
||||||
|
background:#fff5f6;
|
||||||
|
}
|
||||||
|
.compatibility-warning.error>span{background:#f9dce1;color:#9f2238}
|
||||||
|
.compatibility-warning.info>span{color:#315fa8}
|
||||||
|
.compatibility-warning strong{
|
||||||
|
display:block;
|
||||||
|
margin-bottom:2px;
|
||||||
|
text-transform:capitalize;
|
||||||
|
font-size:10px;
|
||||||
|
}
|
||||||
|
.compatibility-warning p{
|
||||||
|
margin:0;
|
||||||
|
color:#697689;
|
||||||
|
font-size:10px;
|
||||||
|
line-height:1.4;
|
||||||
|
}
|
||||||
|
.compatibility-clear{
|
||||||
|
display:grid;
|
||||||
|
gap:3px;
|
||||||
|
padding:14px;
|
||||||
|
border:1px solid #cce8d7;
|
||||||
|
border-radius:12px;
|
||||||
|
background:#f1fbf5;
|
||||||
|
}
|
||||||
|
.compatibility-clear strong{color:#18794e;font-size:12px}
|
||||||
|
.compatibility-clear span{color:#668173;font-size:10px}
|
||||||
|
.integration-note{
|
||||||
|
margin:10px 0 0;
|
||||||
|
color:#7a8596;
|
||||||
|
font-size:10px;
|
||||||
|
line-height:1.5;
|
||||||
|
}
|
||||||
|
.integration-note code{color:#42516a}
|
||||||
|
|||||||
22
src/types.ts
22
src/types.ts
@ -1,5 +1,5 @@
|
|||||||
export type TemplateType = 'email' | 'report'
|
export type TemplateType = 'email' | 'report'
|
||||||
export type BlockType = 'heading' | 'text' | 'kpi' | 'button' | 'image' | 'divider' | 'table'
|
export type BlockType = 'heading' | 'text' | 'kpi' | 'button' | 'image' | 'divider' | 'table' | 'spacer'
|
||||||
|
|
||||||
export type Block = {
|
export type Block = {
|
||||||
id: string
|
id: string
|
||||||
@ -10,9 +10,29 @@ export type Block = {
|
|||||||
|
|
||||||
export type TemplateDocument = {
|
export type TemplateDocument = {
|
||||||
theme?: Record<string, unknown>
|
theme?: Record<string, unknown>
|
||||||
|
email?: {
|
||||||
|
subject?: string
|
||||||
|
preheader?: string
|
||||||
|
}
|
||||||
blocks: Block[]
|
blocks: Block[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EmailCompatibilityWarning = {
|
||||||
|
code: string
|
||||||
|
severity: 'info' | 'warning' | 'error'
|
||||||
|
message: string
|
||||||
|
block_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmailRenderResult = {
|
||||||
|
subject: string
|
||||||
|
preheader: string
|
||||||
|
html: string
|
||||||
|
text: string
|
||||||
|
warnings: EmailCompatibilityWarning[]
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export type TemplateRecord = {
|
export type TemplateRecord = {
|
||||||
id: string
|
id: string
|
||||||
template_key: string
|
template_key: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user