|
@@ -21,6 +21,11 @@ export { escapeHtml }
|
|
|
* rule written on the element it applies to, a 600px column, no external
|
|
* rule written on the element it applies to, a 600px column, no external
|
|
|
* stylesheet, no web font, no flexbox, no grid, no position.
|
|
* stylesheet, no web font, no flexbox, no grid, no position.
|
|
|
*
|
|
*
|
|
|
|
|
+ * The shape is a card: a bar in the workshop's colour, a white sheet with
|
|
|
|
|
+ * the words on it, and the workshop's contact lines quietly underneath. The
|
|
|
|
|
+ * card is the renderer's, not a block, so every template a workshop makes
|
|
|
|
|
+ * sits on the same paper and only decides what is written on it.
|
|
|
|
|
+ *
|
|
|
* The workshop never types HTML, so nothing here has to survive a paste from
|
|
* The workshop never types HTML, so nothing here has to survive a paste from
|
|
|
* Word. Every string that came from a person is escaped on the way in, and
|
|
* Word. Every string that came from a person is escaped on the way in, and
|
|
|
* the theme is checked again here even though the schema already did: this
|
|
* the theme is checked again here even though the schema already did: this
|
|
@@ -29,6 +34,9 @@ export { escapeHtml }
|
|
|
|
|
|
|
|
const WIDTH = 600
|
|
const WIDTH = 600
|
|
|
|
|
|
|
|
|
|
+/** The hairline everything inside the card is ruled with. */
|
|
|
|
|
+const RULE = '#e6e8ec'
|
|
|
|
|
+
|
|
|
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
|
|
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
|
|
|
|
|
|
|
|
/** A colour that is a colour, or the default; never anything that could close an attribute. */
|
|
/** A colour that is a colour, or the default; never anything that could close an attribute. */
|
|
@@ -50,6 +58,7 @@ function safeTheme(theme: EmailTheme) {
|
|
|
panel: color(theme.panelColor, d.panelColor),
|
|
panel: color(theme.panelColor, d.panelColor),
|
|
|
font: EMAIL_FONTS[theme.fontFamily] ?? EMAIL_FONTS[d.fontFamily],
|
|
font: EMAIL_FONTS[theme.fontFamily] ?? EMAIL_FONTS[d.fontFamily],
|
|
|
radius,
|
|
radius,
|
|
|
|
|
+ topBar: theme.topBar !== false,
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -74,6 +83,9 @@ function wordsHtml(
|
|
|
return richToHtml(words.rich, { font: t.font, linkColor: t.primary, ...look })
|
|
return richToHtml(words.rich, { font: t.font, linkColor: t.primary, ...look })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** The summary rows that carry money, drawn heavier than the rest. */
|
|
|
|
|
+const EMPHASISED_ROWS = new Set(['total', 'balance'])
|
|
|
|
|
+
|
|
|
function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
|
|
function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
|
|
|
const base = `font-family:${t.font};`
|
|
const base = `font-family:${t.font};`
|
|
|
const row = (cell: string) => tableRow(cell, marked ? block.id : undefined)
|
|
const row = (cell: string) => tableRow(cell, marked ? block.id : undefined)
|
|
@@ -87,31 +99,37 @@ function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
|
|
|
const logo = block.logoUrl
|
|
const logo = block.logoUrl
|
|
|
? `<img src="${escapeHtml(block.logoUrl)}" alt="${escapeHtml(block.workshopName)}" width="${width}" style="display:inline-block;border:0;max-width:${width}px;height:auto;vertical-align:middle;" />`
|
|
? `<img src="${escapeHtml(block.logoUrl)}" alt="${escapeHtml(block.workshopName)}" width="${width}" style="display:inline-block;border:0;max-width:${width}px;height:auto;vertical-align:middle;" />`
|
|
|
: block.workshopName
|
|
: block.workshopName
|
|
|
- ? `<span style="${base}font-size:20px;font-weight:700;color:${t.text};">${escapeHtml(block.workshopName)}</span>`
|
|
|
|
|
|
|
+ ? `<span style="${base}font-size:19px;font-weight:700;letter-spacing:-0.2px;color:${t.text};">${escapeHtml(block.workshopName)}</span>`
|
|
|
: ''
|
|
: ''
|
|
|
if (!logo) return ''
|
|
if (!logo) return ''
|
|
|
// The td's align attribute is what Outlook reads; the style is for
|
|
// The td's align attribute is what Outlook reads; the style is for
|
|
|
// everyone else. The image is inline so the alignment applies to it.
|
|
// everyone else. The image is inline so the alignment applies to it.
|
|
|
- return row(
|
|
|
|
|
- `<td align="${block.align}" style="padding:0 0 20px 0;text-align:${block.align};">${logo}</td>`
|
|
|
|
|
|
|
+ // A rule under the letterhead, and room after it, so the mark and the
|
|
|
|
|
+ // heading are not two bold lines fighting over the top of the card.
|
|
|
|
|
+ // The block can take the rule away; the room stays.
|
|
|
|
|
+ const rule = block.rule ? `border-bottom:1px solid ${RULE};` : ''
|
|
|
|
|
+ return (
|
|
|
|
|
+ row(
|
|
|
|
|
+ `<td align="${block.align}" style="padding:0 0 20px 0;text-align:${block.align};${rule}">${logo}</td>`
|
|
|
|
|
+ ) + `<tr><td style="height:24px;line-height:24px;font-size:0;"> </td></tr>`
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
case 'heading':
|
|
case 'heading':
|
|
|
return row(
|
|
return row(
|
|
|
- `<td style="${base}font-size:20px;font-weight:700;color:${t.text};padding:0 0 12px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 20, lineHeight: 1.3 })}</td>`
|
|
|
|
|
|
|
+ `<td style="${base}font-size:22px;line-height:1.3;font-weight:700;letter-spacing:-0.2px;color:${t.text};padding:0 0 14px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 22, lineHeight: 1.3 })}</td>`
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
case 'paragraph':
|
|
case 'paragraph':
|
|
|
return row(
|
|
return row(
|
|
|
- `<td style="${base}font-size:15px;line-height:1.5;color:${t.text};padding:0 0 14px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: 1.5 })}</td>`
|
|
|
|
|
|
|
+ `<td style="${base}font-size:15px;line-height:1.6;color:${t.text};padding:0 0 16px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: 1.6 })}</td>`
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
case 'callout':
|
|
case 'callout':
|
|
|
return row(
|
|
return row(
|
|
|
- `<td style="padding:0 0 18px 0;">` +
|
|
|
|
|
- `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${t.panel};border-left:4px solid ${t.primary};border-radius:4px;">` +
|
|
|
|
|
- `<tr><td style="${base}font-size:15px;line-height:1.5;color:${t.text};padding:14px 16px;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: 1.5 })}</td></tr>` +
|
|
|
|
|
|
|
+ `<td style="padding:2px 0 20px 0;">` +
|
|
|
|
|
+ `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" bgcolor="${t.background}" style="background:${t.background};border-left:4px solid ${t.primary};border-radius:6px;">` +
|
|
|
|
|
+ `<tr><td style="${base}font-size:15px;line-height:1.6;color:${t.text};padding:16px 18px;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: 1.6 })}</td></tr>` +
|
|
|
`</table></td>`
|
|
`</table></td>`
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -121,51 +139,62 @@ function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
|
|
|
// A table around the anchor, because Outlook ignores padding on one.
|
|
// A table around the anchor, because Outlook ignores padding on one.
|
|
|
const side = block.align === 'center' ? 'center' : block.align === 'right' ? 'right' : 'left'
|
|
const side = block.align === 'center' ? 'center' : block.align === 'right' ? 'right' : 'left'
|
|
|
return row(
|
|
return row(
|
|
|
- `<td align="${side}" style="padding:6px 0 20px 0;text-align:${side};">` +
|
|
|
|
|
|
|
+ `<td align="${side}" style="padding:6px 0 24px 0;text-align:${side};">` +
|
|
|
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="${side}" style="display:inline-table;"><tr>` +
|
|
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="${side}" style="display:inline-table;"><tr>` +
|
|
|
`<td align="center" bgcolor="${t.primary}" style="border-radius:${t.radius}px;">` +
|
|
`<td align="center" bgcolor="${t.primary}" style="border-radius:${t.radius}px;">` +
|
|
|
- `<a href="${escapeHtml(href)}" style="${base}display:inline-block;padding:12px 22px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:${t.radius}px;">${escapeHtml(block.label)}</a>` +
|
|
|
|
|
|
|
+ `<a href="${escapeHtml(href)}" style="${base}display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:${t.radius}px;">${escapeHtml(block.label)}</a>` +
|
|
|
`</td></tr></table></td>`
|
|
`</td></tr></table></td>`
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
case 'document_summary': {
|
|
case 'document_summary': {
|
|
|
|
|
+ // A receipt: labels down the left, figures on the right, a rule
|
|
|
|
|
+ // between lines, and the money set heavier so it is what the eye
|
|
|
|
|
+ // lands on. The balance is in the workshop's colour, because it is
|
|
|
|
|
+ // the one number the customer has to act on.
|
|
|
|
|
+ const last = block.rows.length - 1
|
|
|
const rows = block.rows
|
|
const rows = block.rows
|
|
|
- .map(
|
|
|
|
|
- (line) =>
|
|
|
|
|
|
|
+ .map((line, i) => {
|
|
|
|
|
+ const money = EMPHASISED_ROWS.has(line.key)
|
|
|
|
|
+ const rule = i < last ? `border-bottom:1px solid ${RULE};` : ''
|
|
|
|
|
+ const valueColor = line.key === 'balance' ? t.primary : t.text
|
|
|
|
|
+ const valueSize = money ? 16 : 14
|
|
|
|
|
+ const weight = money ? 700 : 600
|
|
|
|
|
+ return (
|
|
|
`<tr>` +
|
|
`<tr>` +
|
|
|
- `<td style="${base}font-size:13px;color:${t.muted};padding:4px 16px 4px 0;white-space:nowrap;">${escapeHtml(line.label)}</td>` +
|
|
|
|
|
- `<td style="${base}font-size:14px;font-weight:600;color:${t.text};padding:4px 0;">${escapeHtml(line.value)}</td>` +
|
|
|
|
|
|
|
+ `<td style="${base}font-size:13px;color:${t.muted};padding:10px 16px 10px 0;white-space:nowrap;${rule}">${escapeHtml(line.label)}</td>` +
|
|
|
|
|
+ `<td align="right" style="${base}font-size:${valueSize}px;font-weight:${weight};color:${valueColor};padding:10px 0;text-align:right;${rule}">${escapeHtml(line.value)}</td>` +
|
|
|
`</tr>`
|
|
`</tr>`
|
|
|
- )
|
|
|
|
|
|
|
+ )
|
|
|
|
|
+ })
|
|
|
.join('')
|
|
.join('')
|
|
|
return row(
|
|
return row(
|
|
|
- `<td style="padding:0 0 18px 0;">` +
|
|
|
|
|
- `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${t.panel};border:1px solid #e5e7eb;border-radius:6px;">` +
|
|
|
|
|
- `<tr><td style="padding:14px 16px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0">${rows}</table></td></tr>` +
|
|
|
|
|
|
|
+ `<td style="padding:2px 0 22px 0;">` +
|
|
|
|
|
+ `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" bgcolor="${t.background}" style="background:${t.background};border-radius:8px;">` +
|
|
|
|
|
+ `<tr><td style="padding:6px 20px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table></td></tr>` +
|
|
|
`</table></td>`
|
|
`</table></td>`
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
case 'attachment_note':
|
|
case 'attachment_note':
|
|
|
return row(
|
|
return row(
|
|
|
- `<td style="${base}font-size:13px;line-height:1.5;color:${t.muted};padding:0 0 14px 0;">${wordsHtml(block, t, { color: t.muted, fontSize: 13, lineHeight: 1.5 })}</td>`
|
|
|
|
|
|
|
+ `<td style="${base}font-size:13px;line-height:1.5;color:${t.muted};padding:0 0 16px 0;">${wordsHtml(block, t, { color: t.muted, fontSize: 13, lineHeight: 1.5 })}</td>`
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
case 'image': {
|
|
case 'image': {
|
|
|
- const img = `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt)}" width="${block.width}" style="display:inline-block;border:0;max-width:100%;width:${block.width}px;height:auto;vertical-align:middle;" />`
|
|
|
|
|
|
|
+ const img = `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt)}" width="${block.width}" style="display:inline-block;border:0;max-width:100%;width:${block.width}px;height:auto;vertical-align:middle;border-radius:6px;" />`
|
|
|
const href = block.href ? safeHref(block.href) : null
|
|
const href = block.href ? safeHref(block.href) : null
|
|
|
const inner = href
|
|
const inner = href
|
|
|
? `<a href="${escapeHtml(href)}" style="text-decoration:none;">${img}</a>`
|
|
? `<a href="${escapeHtml(href)}" style="text-decoration:none;">${img}</a>`
|
|
|
: img
|
|
: img
|
|
|
return row(
|
|
return row(
|
|
|
- `<td align="${block.align}" style="padding:0 0 18px 0;text-align:${block.align};">${inner}</td>`
|
|
|
|
|
|
|
+ `<td align="${block.align}" style="padding:0 0 20px 0;text-align:${block.align};">${inner}</td>`
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
case 'divider':
|
|
case 'divider':
|
|
|
return row(
|
|
return row(
|
|
|
- `<td style="padding:4px 0 18px 0;"><div style="height:1px;line-height:1px;font-size:0;background:#e5e7eb;"> </div></td>`
|
|
|
|
|
|
|
+ `<td style="padding:6px 0 22px 0;"><div style="height:1px;line-height:1px;font-size:0;background:${RULE};"> </div></td>`
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
case 'spacer':
|
|
case 'spacer':
|
|
@@ -174,17 +203,31 @@ function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
case 'contact_footer':
|
|
case 'contact_footer':
|
|
|
- return row(
|
|
|
|
|
- `<td style="${base}font-size:13px;line-height:1.5;color:${t.muted};padding:8px 0 0 0;">` +
|
|
|
|
|
- block.lines.map((line) => paragraphHtml(line)).join('<br />') +
|
|
|
|
|
- `</td>`
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ return row(contactFooterCell(block.lines, t, 'left', '8px 0 0 0'))
|
|
|
|
|
|
|
|
default:
|
|
default:
|
|
|
return ''
|
|
return ''
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * The workshop's contact lines. The name is set a shade darker than the
|
|
|
|
|
+ * rest so the footer still says who wrote, without shouting.
|
|
|
|
|
+ */
|
|
|
|
|
+function contactFooterCell(
|
|
|
|
|
+ lines: string[],
|
|
|
|
|
+ t: SafeTheme,
|
|
|
|
|
+ align: 'left' | 'center',
|
|
|
|
|
+ padding: string
|
|
|
|
|
+): string {
|
|
|
|
|
+ const [name, ...rest] = lines
|
|
|
|
|
+ const nameHtml = name
|
|
|
|
|
+ ? `<span style="font-weight:600;color:${t.text};">${paragraphHtml(name)}</span>`
|
|
|
|
|
+ : ''
|
|
|
|
|
+ const body = [nameHtml, ...rest.map((line) => paragraphHtml(line))].filter(Boolean).join('<br />')
|
|
|
|
|
+ return `<td align="${align}" style="font-family:${t.font};font-size:12.5px;line-height:1.6;color:${t.muted};padding:${padding};text-align:${align};">${body}</td>`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/** A row of the mail; in a preview it also says which block it is. */
|
|
/** A row of the mail; in a preview it also says which block it is. */
|
|
|
function tableRow(cell: string, blockId?: string): string {
|
|
function tableRow(cell: string, blockId?: string): string {
|
|
|
const mark = blockId ? ` data-block="${escapeHtml(blockId)}"` : ''
|
|
const mark = blockId ? ` data-block="${escapeHtml(blockId)}"` : ''
|
|
@@ -204,6 +247,18 @@ const PREVIEW_STYLE =
|
|
|
'[data-block].is-selected>td{outline-color:#2563eb}' +
|
|
'[data-block].is-selected>td{outline-color:#2563eb}' +
|
|
|
'</style>'
|
|
'</style>'
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * The line an inbox shows after the subject. Hidden in the mail itself;
|
|
|
|
|
+ * without it the client shows whatever text comes first, which is the
|
|
|
|
|
+ * workshop's name again.
|
|
|
|
|
+ */
|
|
|
|
|
+function preheaderHtml(text: string | undefined): string {
|
|
|
|
|
+ if (!text) return ''
|
|
|
|
|
+ // The filler keeps the client from pulling the body text in after it.
|
|
|
|
|
+ const filler = '‌ '.repeat(40)
|
|
|
|
|
+ return `<div style="display:none;max-height:0;overflow:hidden;font-size:1px;line-height:1px;color:transparent;opacity:0;mso-hide:all;">${escapeHtml(text)}${filler}</div>`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export interface RenderHtmlOptions {
|
|
export interface RenderHtmlOptions {
|
|
|
/**
|
|
/**
|
|
|
* Mark every row with the id of the block it came from, for the designer's
|
|
* Mark every row with the id of the block it came from, for the designer's
|
|
@@ -215,17 +270,50 @@ export interface RenderHtmlOptions {
|
|
|
export function renderEmailHtml(spec: EmailSpec, options: RenderHtmlOptions = {}): string {
|
|
export function renderEmailHtml(spec: EmailSpec, options: RenderHtmlOptions = {}): string {
|
|
|
const marked = options.marked === true
|
|
const marked = options.marked === true
|
|
|
const t = safeTheme(spec.theme)
|
|
const t = safeTheme(spec.theme)
|
|
|
- const body = spec.blocks.map((block) => blockHtml(block, t, marked)).join('')
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // The contact lines go under the card when they close the mail, which is
|
|
|
|
|
+ // where a signature belongs; placed anywhere else they stay a block among
|
|
|
|
|
+ // blocks. A rule left standing at the card's foot when the footer moves
|
|
|
|
|
+ // out would underline nothing, so it goes with it.
|
|
|
|
|
+ let inside = spec.blocks
|
|
|
|
|
+ let footer: Extract<SpecBlock, { type: 'contact_footer' }> | null = null
|
|
|
|
|
+ const lastBlock = inside[inside.length - 1]
|
|
|
|
|
+ if (lastBlock?.type === 'contact_footer') {
|
|
|
|
|
+ footer = lastBlock
|
|
|
|
|
+ inside = inside.slice(0, -1)
|
|
|
|
|
+ while (inside.length && ['divider', 'spacer'].includes(inside[inside.length - 1].type)) {
|
|
|
|
|
+ inside = inside.slice(0, -1)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const body = inside.map((block) => blockHtml(block, t, marked)).join('')
|
|
|
|
|
+ const footerRow = footer
|
|
|
|
|
+ ? tableRow(
|
|
|
|
|
+ contactFooterCell(footer.lines, t, 'center', '22px 12px 0 12px'),
|
|
|
|
|
+ marked ? footer.id : undefined
|
|
|
|
|
+ )
|
|
|
|
|
+ : ''
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
`<!doctype html><html><head><meta charset="utf-8" />` +
|
|
`<!doctype html><html><head><meta charset="utf-8" />` +
|
|
|
`<meta name="viewport" content="width=device-width,initial-scale=1" />` +
|
|
`<meta name="viewport" content="width=device-width,initial-scale=1" />` +
|
|
|
`<title>${escapeHtml(spec.subject)}</title>${marked ? PREVIEW_STYLE : ''}</head>` +
|
|
`<title>${escapeHtml(spec.subject)}</title>${marked ? PREVIEW_STYLE : ''}</head>` +
|
|
|
`<body style="margin:0;padding:0;background:${t.background};">` +
|
|
`<body style="margin:0;padding:0;background:${t.background};">` +
|
|
|
- `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${t.background};">` +
|
|
|
|
|
- `<tr><td align="center" style="padding:24px 12px;">` +
|
|
|
|
|
|
|
+ preheaderHtml(spec.preheader) +
|
|
|
|
|
+ `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" bgcolor="${t.background}" style="background:${t.background};">` +
|
|
|
|
|
+ `<tr><td align="center" style="padding:32px 16px 40px 16px;">` +
|
|
|
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="${WIDTH}" style="width:100%;max-width:${WIDTH}px;">` +
|
|
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="${WIDTH}" style="width:100%;max-width:${WIDTH}px;">` +
|
|
|
|
|
+ // The bar: the workshop's colour, five pixels of it, along the top of
|
|
|
|
|
+ // the card. Enough to be the brand at a glance, not enough to compete
|
|
|
|
|
+ // with a logo underneath. Without it the card is edged all the way round.
|
|
|
|
|
+ (t.topBar
|
|
|
|
|
+ ? `<tr><td bgcolor="${t.primary}" style="background:${t.primary};height:5px;line-height:5px;font-size:0;border-radius:10px 10px 0 0;"> </td></tr>` +
|
|
|
|
|
+ `<tr><td bgcolor="${t.panel}" style="background:${t.panel};border:1px solid ${RULE};border-top:0;border-radius:0 0 10px 10px;padding:32px 36px 24px 36px;">`
|
|
|
|
|
+ : `<tr><td bgcolor="${t.panel}" style="background:${t.panel};border:1px solid ${RULE};border-radius:10px;padding:32px 36px 24px 36px;">`) +
|
|
|
|
|
+ `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">` +
|
|
|
body +
|
|
body +
|
|
|
|
|
+ `</table></td></tr>` +
|
|
|
|
|
+ footerRow +
|
|
|
`</table></td></tr></table></body></html>`
|
|
`</table></td></tr></table></body></html>`
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|