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`
|
||||
|
||||
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 { 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) => {
|
||||
|
||||
@ -202,6 +202,9 @@ function blockHtml(block, data, theme) {
|
||||
case 'divider':
|
||||
html = '<hr />'
|
||||
break
|
||||
case 'spacer':
|
||||
html = `<div style="height:${safeNumber(p.height, 24, 4, 120)}px"></div>`
|
||||
break
|
||||
case 'table':
|
||||
html = renderTable(block, data, theme)
|
||||
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>`
|
||||
}
|
||||
|
||||
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) {
|
||||
const p = block.props || {}
|
||||
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))}" />`
|
||||
case 'divider':
|
||||
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:
|
||||
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 = {}) {
|
||||
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 = `<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
|
||||
return renderEmailMessage(template, data).html
|
||||
}
|
||||
|
||||
@ -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/)
|
||||
})
|
||||
|
||||
145
src/App.tsx
145
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<BlockType, string> = {
|
||||
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<SaveState>('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<EmailRenderResult | null>(null)
|
||||
const [dataText, setDataText] = useState('{}')
|
||||
const [error, setError] = useState('')
|
||||
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) {
|
||||
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<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`, {
|
||||
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</button>
|
||||
</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>
|
||||
{active?.type === 'report' && <button className="button primary full large" onClick={() => void downloadPdf()}>Download draft PDF</button>}
|
||||
{active?.type === 'email' ? (
|
||||
<>
|
||||
<section className="email-delivery-card">
|
||||
<div className="theme-controls-heading">
|
||||
<div>
|
||||
<span className="eyebrow">EMAIL DELIVERY</span>
|
||||
<h3>Subject & inbox preview</h3>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@ -973,6 +1077,9 @@ export function App({ user }: Props) {
|
||||
"customer": { "name": "Example" }
|
||||
}
|
||||
}`}</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 className="auth-card">
|
||||
@ -1058,7 +1165,7 @@ export function App({ user }: Props) {
|
||||
<div className="block-picker">
|
||||
{(Object.keys(blockLabels) as BlockType[]).map(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]}
|
||||
</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' && (
|
||||
<>
|
||||
<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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.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 BlockType = 'heading' | 'text' | 'kpi' | 'button' | 'image' | 'divider' | 'table'
|
||||
export type BlockType = 'heading' | 'text' | 'kpi' | 'button' | 'image' | 'divider' | 'table' | 'spacer'
|
||||
|
||||
export type Block = {
|
||||
id: string
|
||||
@ -10,9 +10,29 @@ export type Block = {
|
||||
|
||||
export type TemplateDocument = {
|
||||
theme?: Record<string, unknown>
|
||||
email?: {
|
||||
subject?: string
|
||||
preheader?: string
|
||||
}
|
||||
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 = {
|
||||
id: string
|
||||
template_key: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user