import mjml2html from 'mjml'
function getPath(data, path) {
return String(path || '').split('.').filter(Boolean).reduce((value, key) => value?.[key], data)
}
export function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''')
}
export function interpolate(input, data) {
return String(input ?? '').replace(/{{\s*([\w.[\]-]+)\s*}}/g, (_, path) => {
const normalized = path.replace(/\[(\d+)\]/g, '.$1')
const value = getPath(data, normalized)
return value == null ? '' : String(value)
})
}
function safeUrl(input, data) {
const url = interpolate(input, data).trim()
return /^(https?:|mailto:)/i.test(url) ? url : '#'
}
function safeHex(value, fallback) {
const color = String(value || '').trim()
return /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(color) ? color : fallback
}
function safeNumber(value, fallback, min, max) {
const number = Number(value)
if (!Number.isFinite(number)) return fallback
return Math.min(max, Math.max(min, number))
}
function safeAlign(value) {
return ['left', 'center', 'right'].includes(value) ? value : 'left'
}
function safeFont(value) {
const font = String(value || '')
const allowed = ['Arial', 'Helvetica', 'Verdana', 'Georgia', 'Times New Roman', 'system-ui']
return allowed.includes(font) ? font : 'Arial'
}
function safeBackgroundMode(value) {
return ['none', 'watermark', 'full'].includes(value) ? value : 'none'
}
function safeDensityMode(value) {
return ['auto', 'comfortable', 'compact'].includes(value) ? value : 'auto'
}
function resolveDensity(template, mode) {
if (mode !== 'auto') return mode
const blocks = Array.isArray(template?.blocks) ? template.blocks : []
const tableCount = blocks.filter(block => block?.type === 'table').length
return blocks.length >= 20 || tableCount >= 4 ? 'compact' : 'comfortable'
}
function themeOf(template) {
const theme = template?.theme || {}
const reportDensityMode = safeDensityMode(theme.reportDensity)
const reportDensity = resolveDensity(template, reportDensityMode)
const baseFontSize = safeNumber(theme.baseFontSize, 15, 10, 22)
const headingSize = safeNumber(theme.headingSize, 23, 14, 42)
return {
backgroundColor: safeHex(theme.backgroundColor, '#f8fafc'),
surfaceColor: safeHex(theme.surfaceColor, '#ffffff'),
textColor: safeHex(theme.textColor, '#172033'),
mutedColor: safeHex(theme.mutedColor, '#64748b'),
accentColor: safeHex(theme.accentColor, '#111827'),
buttonTextColor: safeHex(theme.buttonTextColor, '#ffffff'),
borderColor: safeHex(theme.borderColor, '#dbe2ea'),
borderRadius: safeNumber(theme.borderRadius, 12, 0, 32),
baseFontSize,
bodyFontSize: Math.max(10, baseFontSize - (reportDensity === 'compact' ? 2 : 0)),
headingSize,
renderedHeadingSize: Math.max(14, headingSize - (reportDensity === 'compact' ? 4 : 0)),
lineHeight: safeNumber(theme.lineHeight, 1.45, 1.1, 1.9),
blockGap: safeNumber(theme.blockGap, 10, 0, 32),
cardPadding: safeNumber(theme.cardPadding, 12, 4, 32),
tableCellPadding: safeNumber(theme.tableCellPadding, 8, 2, 20),
pagePadding: safeNumber(theme.pagePadding, 18, 0, 80),
pageMarginTop: safeNumber(theme.pageMarginTop, 12, 0, 40),
pageMarginRight: safeNumber(theme.pageMarginRight, 12, 0, 40),
pageMarginBottom: safeNumber(theme.pageMarginBottom, 12, 0, 40),
pageMarginLeft: safeNumber(theme.pageMarginLeft, 12, 0, 40),
fontFamily: safeFont(theme.fontFamily),
pageBackgroundImage: safeUrl(theme.pageBackgroundImage, {}),
pageBackgroundMode: safeBackgroundMode(theme.pageBackgroundMode),
pageBackgroundOpacity: safeNumber(theme.pageBackgroundOpacity, 0.08, 0, 0.35),
decorativeBorder: theme.decorativeBorder === true,
borderImage: safeUrl(theme.borderImage, {}),
reportDensityMode,
reportDensity
}
}
function safeFit(value) {
return ['contain', 'cover', 'fill'].includes(value) ? value : 'contain'
}
function safeVariant(block) {
const explicit = String(block?.props?.variant || '').toLowerCase()
if (['logo', 'hero', 'image'].includes(explicit)) return explicit
const marker = `${block?.id || ''} ${block?.props?.alt || ''}`.toLowerCase()
if (marker.includes('logo') || marker.includes('mark')) return 'logo'
if (marker.includes('hero') || marker.includes('banner')) return 'hero'
return 'image'
}
function reportBlockStyle(block, theme) {
const style = block?.style || {}
const compact = theme.reportDensity === 'compact'
const forceBreakBefore = style.forcePageBreak === true
const breakBefore = (style.pageBreakBefore === true && (!compact || forceBreakBefore)) ? 'page' : 'auto'
const keepWithNext = style.keepWithNext === true || (compact && block?.type === 'heading')
const breakAfter = style.pageBreakAfter === true ? 'page' : (keepWithNext ? 'avoid-page' : 'auto')
const breakInside = style.allowPageBreak === true ? 'auto' : 'avoid-page'
const rules = [
`background:${safeHex(style.backgroundColor, 'transparent')}`,
`color:${safeHex(style.textColor, theme.textColor)}`,
`border-color:${safeHex(style.borderColor, theme.borderColor)}`,
`border-width:${safeNumber(style.borderWidth, 0, 0, 8)}px`,
'border-style:solid',
`border-radius:${safeNumber(style.borderRadius, theme.borderRadius, 0, 32)}px`,
`padding:${safeNumber(style.padding, 0, 0, 48)}px`,
`text-align:${safeAlign(style.align)}`,
`break-before:${breakBefore}`,
`break-after:${breakAfter}`,
`break-inside:${breakInside}`,
`page-break-inside:${breakInside === 'avoid-page' ? 'avoid' : 'auto'}`
]
return rules.join(';')
}
function imageStyle(block, theme) {
const style = block?.style || {}
const variant = safeVariant(block)
const defaults = variant === 'logo'
? { maxWidth: 110, maxHeight: 72, fit: 'contain' }
: variant === 'hero'
? { maxWidth: 999, maxHeight: 260, fit: 'cover' }
: { maxWidth: 999, maxHeight: 340, fit: 'contain' }
const maxWidth = safeNumber(style.maxWidth ?? block?.props?.maxWidth, defaults.maxWidth, 24, 999)
const maxHeight = safeNumber(style.maxHeight ?? block?.props?.maxHeight, defaults.maxHeight, 24, 900)
const width = style.width ?? block?.props?.width
const widthRule = width == null || width === '' ? 'width:auto' : `width:${safeNumber(width, maxWidth, 24, 999)}px`
const fit = safeFit(style.fit ?? block?.props?.fit ?? defaults.fit)
const radius = safeNumber(style.borderRadius, theme.borderRadius, 0, 32)
return [
widthRule,
`max-width:min(100%,${maxWidth}px)`,
`max-height:${maxHeight}px`,
`object-fit:${fit}`,
`border-radius:${radius}px`,
'display:inline-block'
].join(';')
}
function wrapReportBlock(block, theme, html) {
const variant = block?.type === 'image' ? ` image-${safeVariant(block)}` : ''
return `
${html}
`
}
function renderTable(block, data, theme) {
const rows = getPath(data, block.props?.rowsPath) || []
const columns = Array.isArray(block.props?.columns) ? block.props.columns : []
if (!Array.isArray(rows)) return ''
const allowBreak = block?.style?.allowPageBreak === true
const compact = theme.reportDensity === 'compact' || block?.style?.compact === true
const classes = ['table-wrap', allowBreak ? 'allow-break' : '', compact ? 'compact-table' : ''].filter(Boolean).join(' ')
return `${columns.map(c => `| ${escapeHtml(c.label)} | `).join('')}
${rows.map(row => `${columns.map(c => `| ${escapeHtml(getPath(row, c.key))} | `).join('')}
`).join('')}
`
}
function blockHtml(block, data, theme) {
const p = block.props || {}
let html = ''
switch (block.type) {
case 'heading':
html = `${escapeHtml(interpolate(p.text, data))}
`
break
case 'text':
html = `${escapeHtml(interpolate(p.text, data))}
`
break
case 'kpi':
html = `${escapeHtml(interpolate(p.label, data))}${escapeHtml(interpolate(p.value, data))}
`
break
case 'button':
html = `${escapeHtml(interpolate(p.text, data))}
`
break
case 'image':
html = `
`
break
case 'divider':
html = '
'
break
case 'spacer':
html = ``
break
case 'table':
html = renderTable(block, data, theme)
break
default:
return ''
}
return wrapReportBlock(block, theme, html)
}
function renderReportBlocks(blocks, data, theme) {
const output = []
for (let index = 0; index < blocks.length; index += 1) {
const block = blocks[index]
if (theme.reportDensity === 'compact' && block?.type === 'kpi') {
const group = [block]
while (index + 1 < blocks.length && blocks[index + 1]?.type === 'kpi') {
group.push(blocks[index + 1])
index += 1
}
if (group.length > 1) {
output.push(`${group.map(item => blockHtml(item, data, theme)).join('')}
`)
continue
}
}
output.push(blockHtml(block, data, theme))
}
return output.join('\n')
}
export function renderReportHtml(template, data = {}) {
const blocks = template?.blocks || []
const theme = themeOf(template)
const hasBackground = theme.pageBackgroundImage !== '#' && theme.pageBackgroundMode !== 'none'
const backgroundImage = hasBackground ? `url("${escapeHtml(theme.pageBackgroundImage)}")` : 'none'
const borderEnabled = theme.decorativeBorder && theme.borderImage !== '#'
const borderImage = borderEnabled ? `url("${escapeHtml(theme.borderImage)}") 30 stretch` : 'none'
const backgroundSize = theme.pageBackgroundMode === 'full' ? 'cover' : '100% auto'
return `
${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 || {}
const padding = `${safeNumber(style.padding, 0, 0, 48)}px`
const align = safeAlign(style.align)
const color = safeHex(style.textColor, theme.textColor)
const backgroundColor = safeHex(style.backgroundColor, theme.surfaceColor)
const borderColor = safeHex(style.borderColor, theme.borderColor)
const borderWidth = safeNumber(style.borderWidth, 0, 0, 8)
const radius = safeNumber(style.borderRadius, theme.borderRadius, 0, 32)
const common = `padding="${padding}" align="${align}" color="${color}"`
switch (block.type) {
case 'heading':
return `${escapeHtml(interpolate(p.text, data))}`
case 'text':
return `${escapeHtml(interpolate(p.text, data))}`
case 'kpi':
return `${escapeHtml(interpolate(p.label, data))}${escapeHtml(interpolate(p.value, data))}`
case 'button':
return `${escapeHtml(interpolate(p.text, data))}`
case 'image':
return ``
case 'divider':
return ``
case 'table':
return emailTable(block, data, theme)
case 'spacer':
return ``
default:
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 = {}) {
return renderEmailMessage(template, data).html
}