feat: harden email rendering for delivery providers

This commit is contained in:
Admin 2026-09-23 07:10:43 +00:00
parent e2274c1ecb
commit 7a36a0896c
7 changed files with 576 additions and 27 deletions

View File

@ -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.

View File

@ -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) => {

View File

@ -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
}

View File

@ -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/)
})

View File

@ -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,6 +924,86 @@ export function App({ user }: Props) {
})}>Reset page setup</button>
</section>
)}
{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>
@ -909,7 +1011,9 @@ export function App({ user }: Props) {
<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>}
<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>

View File

@ -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}

View File

@ -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