feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
Every subject and body moves out of `mailer.js` into `engagement_templates` rows an operator can edit. A relocation, not a regression: nothing that sends mail today starts depending on an operator authoring something first. - `email.*` block family in its own registry, sharing the page family's envelope walk and validate-then-sanitize order by binding rather than by copy. - A server-side renderer producing both parts of a multipart message; the text part is byte-identical to the literals this commit deletes. - Nine seeded templates, six of them wired now; the seeder's `customized = 0` guard lives in the UPDATE's own WHERE. - `renderByKey` falls back to the shipped seed when a row is missing or unusable, so no failure of the table can stop a password reset. Also fixes `check:hosts` reading the template key `auth.email-verify` as the hostname `auth.email`. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
26
server/src/emailBlocks/index.js
Normal file
26
server/src/emailBlocks/index.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Email block registry entrypoint. Requiring this module registers every
|
||||
// `email.*` block definition exactly once, then re-exports the registry API, the
|
||||
// renderer and the registry-bound validator/sanitizer. Anything that needs to
|
||||
// validate or render a mail template's blocks should require THIS module, not
|
||||
// ./registry or ./render directly, so the definitions are guaranteed loaded.
|
||||
//
|
||||
// Same shape as `blocks/index.js`, on purpose — the two families are siblings
|
||||
// (see ./registry.js for why they are not one registry).
|
||||
|
||||
const registry = require('./registry')
|
||||
const render = require('./render')
|
||||
const interpolate = require('./interpolate')
|
||||
|
||||
// ── Block definitions (self-register on require) ───────────────────────────
|
||||
require('./types/heading')
|
||||
require('./types/text')
|
||||
require('./types/button')
|
||||
require('./types/divider')
|
||||
require('./types/image')
|
||||
require('./types/itemList')
|
||||
|
||||
module.exports = {
|
||||
...registry,
|
||||
...render,
|
||||
...interpolate,
|
||||
}
|
||||
79
server/src/emailBlocks/interpolate.js
Normal file
79
server/src/emailBlocks/interpolate.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// ── Template variable interpolation ────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.6.2's security posture, as code: "variable interpolation is
|
||||
// HTML-escaped by default with no raw-HTML variable type in v1. A module supplies
|
||||
// data; it does not supply markup."
|
||||
//
|
||||
// The token grammar is deliberately the smallest thing that works: `{{ name }}`,
|
||||
// a bare declared variable name, and NOTHING else. No filters, no conditionals,
|
||||
// no loops, no dotted paths. Three reasons:
|
||||
//
|
||||
// - A template is operator-authored data rendered by the server. Every construct
|
||||
// added here is a construct an operator can get wrong and a construct someone
|
||||
// has to sandbox.
|
||||
// - §4.3 makes the trigger declaration the source of truth for what a template
|
||||
// may reference, and a save-time check names the offending variable. That check
|
||||
// can only be exact if a token is a name — `{{ user.profile.email }}` is not a
|
||||
// declared variable, it is an expression over one.
|
||||
// - Repetition is a BLOCK (`email.itemList`), not a template construct, so the
|
||||
// one place a template needs "for each" already has a typed, validated home.
|
||||
//
|
||||
// A token whose variable has no value at render time becomes the empty string and
|
||||
// is reported in `missing`. It does not become "undefined", which is the failure
|
||||
// §4.3's versioning paragraph is about — a renamed variable rendering as the word
|
||||
// undefined in a person's inbox.
|
||||
|
||||
// `{{ name }}` / `{{name}}`. Leading letter, then letters/digits/underscore —
|
||||
// the same shape §4.3's declarations use.
|
||||
const TOKEN_RE = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g
|
||||
|
||||
/** Escape text for interpolation into HTML. Same table as utils/htmlShell.js. */
|
||||
function htmlEscape(s) {
|
||||
return String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct variable name a string references, in first-appearance order.
|
||||
* This is what the save-time check (Phase 5b) walks to find undeclared variables.
|
||||
* @param {unknown} str
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function scanTokens(str) {
|
||||
if (typeof str !== 'string') return []
|
||||
const found = []
|
||||
for (const m of str.matchAll(TOKEN_RE)) {
|
||||
if (!found.includes(m[1])) found.push(m[1])
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute declared variables into a string.
|
||||
*
|
||||
* @param {unknown} str
|
||||
* @param {Record<string, unknown>} values
|
||||
* @param {{ escape?: boolean, missing?: Set<string> }} [opts]
|
||||
* `escape` (default true) HTML-escapes each value — pass false ONLY for the
|
||||
* plain-text part, where there is no markup to escape into and `&` in a
|
||||
* person's inbox is a bug. `missing` collects names with no value.
|
||||
* @returns {string}
|
||||
*/
|
||||
function interpolate(str, values, opts = {}) {
|
||||
if (typeof str !== 'string' || str === '') return ''
|
||||
const escape = opts.escape !== false
|
||||
const missing = opts.missing || null
|
||||
return str.replace(TOKEN_RE, (_match, name) => {
|
||||
const value = values ? values[name] : undefined
|
||||
if (value === undefined || value === null) {
|
||||
if (missing) missing.add(name)
|
||||
return ''
|
||||
}
|
||||
const asString = typeof value === 'string' ? value : String(value)
|
||||
return escape ? htmlEscape(asString) : asString
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { TOKEN_RE, htmlEscape, scanTokens, interpolate }
|
||||
120
server/src/emailBlocks/registry.js
Normal file
120
server/src/emailBlocks/registry.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// ── The `email.*` block registry ───────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.4. A sibling of `blocks/registry.js`, not an extension of it,
|
||||
// settled with the org lead at the start of Phase 5a. Three reasons, in order of
|
||||
// how much they cost if ignored:
|
||||
//
|
||||
// 1. **These blocks render on the SERVER.** Page blocks do not: `blocks/` carries
|
||||
// `schema` / `sanitize` / `cacheTTL` and the actual drawing happens in React
|
||||
// (`client/src/blocks/BlockRenderer.jsx`). Mail has no React — a message body
|
||||
// is a string this process produces — so an email definition carries `toHtml`
|
||||
// and `toText`. `registerBlock` freezes a fixed field set and would silently
|
||||
// DROP both.
|
||||
// 2. **One registry would be one namespace.** `blocks/validateBlocks.js`'s only
|
||||
// server consumer is `pages.model.js`; registering `email.heading` into that
|
||||
// Map makes a CMS page containing an email block validate and save, and the
|
||||
// client renderer has nothing to draw for it.
|
||||
// 3. The two entry shapes genuinely differ: `cacheTTL` and `container` mean
|
||||
// nothing to a mail body, and a renderer means nothing to a cached page block.
|
||||
//
|
||||
// What IS shared is everything that is the same rule for both, and it is shared by
|
||||
// binding rather than by copy: `propHelpers`, the envelope/id/nesting walk
|
||||
// (`makeValidateBlocks`) and the validate-then-sanitize order (`makeSanitizeBlocks`).
|
||||
// §4.4's "do not build a second editor" is honoured where it is about the editor —
|
||||
// Phase 5b drives these through the existing block/prop-panel machinery.
|
||||
//
|
||||
// A registered definition looks like:
|
||||
// {
|
||||
// type: 'email.heading',
|
||||
// version: 1,
|
||||
// schema: (props) => [], // error strings ([] = valid)
|
||||
// sanitize: (props) => props, // optional, run on save AFTER validation
|
||||
// toHtml: (props, ctx) => '<tr>…', // a table ROW; see render.js for the shell
|
||||
// toText: (props, ctx) => 'text', // '' means "contributes nothing"
|
||||
// }
|
||||
//
|
||||
// `ctx` is the render context (render.js): resolved brand values, an `interp`
|
||||
// that substitutes declared variables HTML-escaped, and `interpText` that does
|
||||
// the same without escaping for the plain-text part.
|
||||
|
||||
const registry = new Map()
|
||||
|
||||
// Same envelope as a page block — deliberately the same constant list, because
|
||||
// the shared validator enforces it and the two must not diverge.
|
||||
const { RESERVED_KEYS } = require('../blocks/registry')
|
||||
|
||||
/**
|
||||
* Register an email block definition. Throws on a missing type, a duplicate, or a
|
||||
* missing renderer — all three are programmer errors surfaced at boot.
|
||||
* @param {object} def
|
||||
* @returns {object} the normalized, frozen definition
|
||||
*/
|
||||
function registerEmailBlock(def) {
|
||||
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
|
||||
throw new Error('registerEmailBlock: a block definition needs a string `type`')
|
||||
}
|
||||
if (!def.type.startsWith('email.')) {
|
||||
// The prefix is not needed to disambiguate — this is its own Map — but a
|
||||
// stored blocks array should say what it is when someone reads the row.
|
||||
throw new Error(`registerEmailBlock: ${def.type} must be namespaced "email."`)
|
||||
}
|
||||
if (registry.has(def.type)) {
|
||||
throw new Error(`registerEmailBlock: block type already registered: ${def.type}`)
|
||||
}
|
||||
if (typeof def.toHtml !== 'function' || typeof def.toText !== 'function') {
|
||||
// §4.4: "Every block type gets a toText(props) alongside its renderer, so a
|
||||
// text part always exists." A block that can only produce HTML would make a
|
||||
// published template's text part depend on which blocks it happened to use.
|
||||
throw new Error(`registerEmailBlock: ${def.type} needs both toHtml and toText`)
|
||||
}
|
||||
if (def.schema != null && typeof def.schema !== 'function') {
|
||||
throw new Error(`registerEmailBlock: ${def.type}.schema must be a function`)
|
||||
}
|
||||
if (def.sanitize != null && typeof def.sanitize !== 'function') {
|
||||
throw new Error(`registerEmailBlock: ${def.type}.sanitize must be a function`)
|
||||
}
|
||||
const entry = Object.freeze({
|
||||
type: def.type,
|
||||
label: def.label || def.type,
|
||||
version: Number.isInteger(def.version) ? def.version : 1,
|
||||
schema: def.schema || null,
|
||||
sanitize: def.sanitize || null,
|
||||
toHtml: def.toHtml,
|
||||
toText: def.toText,
|
||||
// The shared walk reads these; email has no containers, and saying so here is
|
||||
// what lets `makeValidateBlocks` be the same function for both families.
|
||||
container: false,
|
||||
containerSlots: Object.freeze([]),
|
||||
})
|
||||
registry.set(entry.type, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** @returns {object|null} the definition for `type`, or null if unknown. */
|
||||
function getEmailBlock(type) {
|
||||
return registry.get(type) || null
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether `type` is a registered email block. */
|
||||
function hasEmailBlock(type) {
|
||||
return registry.has(type)
|
||||
}
|
||||
|
||||
/** @returns {object[]} all registered definitions (registration order). */
|
||||
function listEmailBlocks() {
|
||||
return [...registry.values()]
|
||||
}
|
||||
|
||||
/** Drop every registered block. Test-only. */
|
||||
function _resetRegistry() {
|
||||
registry.clear()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RESERVED_KEYS,
|
||||
registerEmailBlock,
|
||||
getEmailBlock,
|
||||
hasEmailBlock,
|
||||
listEmailBlocks,
|
||||
_resetRegistry,
|
||||
}
|
||||
196
server/src/emailBlocks/render.js
Normal file
196
server/src/emailBlocks/render.js
Normal file
@@ -0,0 +1,196 @@
|
||||
// ── Rendering a block array into a mail body ───────────────────────────────
|
||||
//
|
||||
// Pure and synchronous: everything that needs a database — the brand values, the
|
||||
// resolved theme, the site title — is resolved by `engagement/templates.js` and
|
||||
// arrives here as a plain object. That split is what lets the whole renderer be
|
||||
// tested without a MariaDB, and it is why the byte-comparison test for the five
|
||||
// transactional bodies (§5a acceptance) is a unit test rather than a live send.
|
||||
//
|
||||
// **The shell contributes structure and NO content.** No appended footer, no
|
||||
// injected logo, no "sent by" line. Two reasons, and the second is the load-bearing
|
||||
// one:
|
||||
//
|
||||
// - A person's mail must say what the operator wrote and nothing else. An
|
||||
// unsubscribe line is a variable inside the template (§4.6.1 lists
|
||||
// `unsubscribeUrl` for exactly the two templates that need one), so an operator
|
||||
// can move it, reword it, or see that a transactional mail correctly has none.
|
||||
// - **The HTML and text parts must say the same things.** A shell that put a
|
||||
// footer only in the HTML would make every message's two parts disagree, which
|
||||
// is a deliverability signal and, worse, means the text reader is told less
|
||||
// than the HTML reader. Every block produces both halves; nothing else does.
|
||||
//
|
||||
// The HTML is table-based and inline-styled throughout, which is not a stylistic
|
||||
// choice: `<div>` layout and a `<style>` block are the two things mail clients
|
||||
// most reliably break.
|
||||
|
||||
const { htmlEscape, interpolate } = require('./interpolate')
|
||||
const { getEmailBlock } = require('./registry')
|
||||
const { makeValidateBlocks } = require('../blocks/validateBlocks')
|
||||
const { makeSanitizeBlocks } = require('../blocks/sanitizeBlocks')
|
||||
const { isSafeUrl } = require('../blocks/propHelpers')
|
||||
|
||||
// Bound to the email registry — the same walk the page family gets, so the
|
||||
// envelope rules, id uniqueness and schema dispatch cannot drift between them.
|
||||
const validateEmailBlocks = makeValidateBlocks(getEmailBlock, { maxBlocks: 60 })
|
||||
const sanitizeEmailBlocks = makeSanitizeBlocks(getEmailBlock)
|
||||
|
||||
// A stack every mail client resolves. No webfont: a @font-face in mail is either
|
||||
// stripped or silently ignored, and the fallback is what the reader sees anyway.
|
||||
const FONT_STACK = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
|
||||
|
||||
/**
|
||||
* The mail palette — a light scaffold plus the deployment's accent.
|
||||
*
|
||||
* **Only the accent comes from the theme, and that is deliberate.** Every shipped
|
||||
* preset (`config/themePresets.js`) is a DARK palette, and mail is not a page: a
|
||||
* dark-background body is what §4.6.2 names as rendering "unreadable dark-on-dark
|
||||
* in about a third of inboxes", because a good share of clients invert or force a
|
||||
* background of their own. Deriving a light palette from a dark one would be a
|
||||
* guess at six colours; taking the one colour that carries the brand — the accent,
|
||||
* used for the button and for links — is exact. §4.6.1's property 2 holds either
|
||||
* way: no seeded template contains a hex code, so one prebuilt image running as
|
||||
* any shard mails in that shard's colour.
|
||||
*
|
||||
* @param {{ accent?: string }} [theme] resolved theme tokens
|
||||
*/
|
||||
function palette(theme = {}) {
|
||||
const accent = isHex(theme.accent) ? theme.accent : '#7f99bd'
|
||||
return Object.freeze({
|
||||
accent,
|
||||
onAccent: readableOn(accent),
|
||||
heading: '#151a20',
|
||||
text: '#33404d',
|
||||
muted: '#6b7885',
|
||||
rule: '#dfe4ea',
|
||||
page: '#f4f6f8',
|
||||
card: '#ffffff',
|
||||
fontStack: FONT_STACK,
|
||||
})
|
||||
}
|
||||
|
||||
function isHex(v) {
|
||||
return typeof v === 'string' && /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(v)
|
||||
}
|
||||
|
||||
/** Black or white text over `hex`, whichever a reader can actually read. */
|
||||
function readableOn(hex) {
|
||||
let h = hex.slice(1)
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
||||
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
|
||||
// Relative luminance (WCAG). 0.45 rather than 0.5: the accents here are mid-tone
|
||||
// and white-on-mid reads better than black-on-mid at button weight.
|
||||
const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
|
||||
const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
||||
return L > 0.45 ? '#151a20' : '#ffffff'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the render context every block's `toHtml` / `toText` receives.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {Record<string, unknown>} opts.values variable values
|
||||
* @param {object} [opts.theme] resolved theme tokens
|
||||
* @param {string} [opts.baseUrl] absolute site base, for relative urls
|
||||
* @param {Set<string>} [opts.missing] collects unresolved variable names
|
||||
*/
|
||||
function buildContext({ values = {}, theme = {}, baseUrl = '', missing = new Set() }) {
|
||||
const base = String(baseUrl || '').replace(/\/+$/, '')
|
||||
const ctx = {
|
||||
values,
|
||||
missing,
|
||||
palette: palette(theme),
|
||||
escape: htmlEscape,
|
||||
/** Interpolate + HTML-escape — for anything going into markup. */
|
||||
h: (s) => interpolate(s, values, { escape: true, missing }),
|
||||
/** Interpolate WITHOUT escaping — for the plain-text part only. */
|
||||
t: (s) => interpolate(s, values, { escape: false, missing }),
|
||||
/**
|
||||
* Interpolate a URL and re-check it. Returns the URL or null.
|
||||
*
|
||||
* A stored `{{resetUrl}}` says nothing about where it points; the value
|
||||
* arrives from a caller or a module at render time. Checking only the stored
|
||||
* literal would mean a variable carrying `javascript:` becomes an href.
|
||||
*/
|
||||
safeHref: (s) => {
|
||||
const url = interpolate(s, values, { escape: false, missing })
|
||||
return url && isSafeUrl(url) ? url : null
|
||||
},
|
||||
/** Same-origin path → absolute URL; http(s) unchanged; anything else null. */
|
||||
absolute: (url) => {
|
||||
if (!url) return null
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
if (url.startsWith('/')) return base ? `${base}${url}` : null
|
||||
return null
|
||||
},
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a blocks array into the two body parts.
|
||||
*
|
||||
* Blocks are joined by a blank line in text and stacked as table rows in HTML.
|
||||
* A block whose `toText` returns '' contributes nothing to the text part and does
|
||||
* not leave a doubled blank line behind it (`email.divider` is the case).
|
||||
*
|
||||
* @returns {{ html: string, text: string }} html is the ROWS, not a document
|
||||
*/
|
||||
function renderBlocks(blocks, ctx) {
|
||||
const rows = []
|
||||
const paras = []
|
||||
for (const block of Array.isArray(blocks) ? blocks : []) {
|
||||
if (block && block.visible === false) continue
|
||||
const def = block && typeof block.type === 'string' ? getEmailBlock(block.type) : null
|
||||
if (!def) continue // unreachable after validation; never emit an unknown block
|
||||
const props = block.props && typeof block.props === 'object' ? block.props : {}
|
||||
try {
|
||||
const html = def.toHtml(props, ctx)
|
||||
if (html) rows.push(html)
|
||||
const text = def.toText(props, ctx)
|
||||
if (text) paras.push(text)
|
||||
} catch {
|
||||
// One misbehaving block must not cost the whole message. Skipped in both
|
||||
// parts together, so the two never disagree about what the mail contains.
|
||||
}
|
||||
}
|
||||
return { html: rows.join(''), text: paras.join('\n\n') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap rendered rows in the mail document.
|
||||
* @param {string} rowsHtml
|
||||
* @param {object} ctx
|
||||
* @param {string} [title] the <title>, shown by a few webmail clients
|
||||
*/
|
||||
function renderDocument(rowsHtml, ctx, title = '') {
|
||||
const p = ctx.palette
|
||||
return (
|
||||
'<!doctype html><html><head><meta charset="utf-8" />' +
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1" />' +
|
||||
// Tells a client that inverts colours that this body already handles both,
|
||||
// so it leaves the palette alone instead of inverting the card to near-black.
|
||||
'<meta name="color-scheme" content="light" />' +
|
||||
'<meta name="supported-color-schemes" content="light" />' +
|
||||
`<title>${htmlEscape(title)}</title></head>` +
|
||||
`<body style="margin:0;padding:0;background:${p.page};">` +
|
||||
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${p.page};">` +
|
||||
'<tr><td align="center" style="padding:24px 12px;">' +
|
||||
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" ` +
|
||||
`style="width:100%;max-width:600px;background:${p.card};border:1px solid ${p.rule};border-radius:6px;">` +
|
||||
'<tr><td style="padding:28px 28px 16px 28px;">' +
|
||||
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">' +
|
||||
rowsHtml +
|
||||
'</table></td></tr></table></td></tr></table></body></html>'
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FONT_STACK,
|
||||
palette,
|
||||
readableOn,
|
||||
buildContext,
|
||||
renderBlocks,
|
||||
renderDocument,
|
||||
validateEmailBlocks,
|
||||
sanitizeEmailBlocks,
|
||||
}
|
||||
86
server/src/emailBlocks/types/button.js
Normal file
86
server/src/emailBlocks/types/button.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// email.button — the call to action, and the one block whose two renderings are
|
||||
// deliberately NOT the same content.
|
||||
//
|
||||
// **`textLead` is why the plain-text part is authored rather than derived.** In
|
||||
// HTML this is a button reading "Choose a new password"; in plain text a button
|
||||
// is nothing, and what a reader needs is the sentence that introduces the URL
|
||||
// ("Choose a new password here:") followed by the URL on its own line. Deriving
|
||||
// the second from the first produces either a bare URL with no lead-in or the
|
||||
// button's label used as a sentence. §4.4 calls the text part generated-by-default
|
||||
// and overridable; this block is the reason the default has to be good enough that
|
||||
// an operator rarely reaches for the override.
|
||||
//
|
||||
// **The href is re-checked AFTER interpolation.** `url` is nearly always a token
|
||||
// (`{{resetUrl}}`), so nothing about the stored value tells you where it points —
|
||||
// the value arrives at render time from a module or a caller. A substituted URL
|
||||
// that is not http/https/same-origin loses its href and renders as inert text
|
||||
// rather than as a link the reader would have no reason to distrust.
|
||||
const { registerEmailBlock } = require('../registry')
|
||||
const { requiredText, optionalText, onlyKeys, isSafeUrl } = require('../../blocks/propHelpers')
|
||||
const { scanTokens } = require('../interpolate')
|
||||
|
||||
const MAX_LABEL = 80
|
||||
const MAX_URL = 600
|
||||
const MAX_LEAD = 200
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.button',
|
||||
label: 'Button / link',
|
||||
version: 1,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['label', 'url', 'textLead'])
|
||||
const label = requiredText('label', props.label, MAX_LABEL)
|
||||
if (label) errors.push(label)
|
||||
const lead = optionalText('textLead', props.textLead, MAX_LEAD)
|
||||
if (lead) errors.push(lead)
|
||||
|
||||
const url = requiredText('url', props.url, MAX_URL)
|
||||
if (url) {
|
||||
errors.push(url)
|
||||
} else if (scanTokens(props.url).length === 0 && !isSafeUrl(props.url)) {
|
||||
// A literal url is checked here, at save. One built from variables cannot
|
||||
// be — see the header note; render.js checks the substituted value instead.
|
||||
errors.push('url must be a relative path, an http(s) URL, or a template variable')
|
||||
}
|
||||
return errors
|
||||
},
|
||||
toHtml(props, ctx) {
|
||||
// An EMPTY url and an UNSAFE one are different failures and get different
|
||||
// answers. Empty means the caller chose not to supply this link at all (an
|
||||
// unsubscribe line on a transactional mail), so the block disappears from both
|
||||
// parts. Unsafe means a value arrived that must not become an href — the label
|
||||
// still renders, inert, because dropping it silently would hide from the
|
||||
// reader that the mail was meant to offer them something.
|
||||
if (ctx.t(props.url).trim() === '') return ''
|
||||
const href = ctx.safeHref(props.url)
|
||||
const label = ctx.h(props.label)
|
||||
if (!href) {
|
||||
return (
|
||||
`<tr><td style="padding:4px 0 16px 0;font-family:${ctx.palette.fontStack};` +
|
||||
`font-size:15px;color:${ctx.palette.muted};">${label}</td></tr>`
|
||||
)
|
||||
}
|
||||
// Table-wrapped, inline-styled, with explicit padding on the anchor: the shape
|
||||
// that survives Outlook, which ignores padding on a <td> containing an <a>.
|
||||
return (
|
||||
'<tr><td style="padding:4px 0 20px 0;">' +
|
||||
'<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>' +
|
||||
`<td bgcolor="${ctx.palette.accent}" style="border-radius:4px;">` +
|
||||
`<a href="${ctx.escape(href)}" style="display:inline-block;padding:11px 22px;` +
|
||||
`font-family:${ctx.palette.fontStack};font-size:15px;font-weight:600;` +
|
||||
`color:${ctx.palette.onAccent};text-decoration:none;border-radius:4px;">${label}</a>` +
|
||||
'</td></tr></table>' +
|
||||
// The bare URL under the button, for the clients that strip anchors and for
|
||||
// the reader who wants to see where it goes before pressing it.
|
||||
`<div style="padding-top:10px;font-family:${ctx.palette.fontStack};font-size:12px;` +
|
||||
`line-height:1.5;color:${ctx.palette.muted};word-break:break-all;">${ctx.escape(href)}</div>` +
|
||||
'</td></tr>'
|
||||
)
|
||||
},
|
||||
toText(props, ctx) {
|
||||
const url = ctx.t(props.url).trim()
|
||||
if (url === '') return '' // see toHtml: no url, no block, in either part
|
||||
const lead = props.textLead ? ctx.t(props.textLead).trim() : ''
|
||||
return lead ? `${lead}\n${url}` : url
|
||||
},
|
||||
})
|
||||
29
server/src/emailBlocks/types/divider.js
Normal file
29
server/src/emailBlocks/types/divider.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// email.divider — a horizontal rule.
|
||||
//
|
||||
// **Its text form is the empty string, not a row of dashes.** A block whose only
|
||||
// job is visual separation has no plain-text equivalent, and render.js already
|
||||
// joins blocks with a blank line. Rendering `-----` would put a decoration in the
|
||||
// text part that the author never wrote and cannot remove without deleting the
|
||||
// rule from the HTML too. Returning '' is what the "'' means contributes nothing"
|
||||
// contract in registry.js exists for.
|
||||
const { registerEmailBlock } = require('../registry')
|
||||
const { onlyKeys } = require('../../blocks/propHelpers')
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.divider',
|
||||
label: 'Divider',
|
||||
version: 1,
|
||||
schema(props) {
|
||||
return onlyKeys(props, [])
|
||||
},
|
||||
toHtml(_props, ctx) {
|
||||
return (
|
||||
'<tr><td style="padding:8px 0 20px 0;">' +
|
||||
`<div style="height:1px;line-height:1px;font-size:0;background:${ctx.palette.rule};"> </div>` +
|
||||
'</td></tr>'
|
||||
)
|
||||
},
|
||||
toText() {
|
||||
return ''
|
||||
},
|
||||
})
|
||||
41
server/src/emailBlocks/types/heading.js
Normal file
41
server/src/emailBlocks/types/heading.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// email.heading — a section heading inside a mail body.
|
||||
//
|
||||
// `level` is a SIZE, not a tag hierarchy: mail clients do not build an outline
|
||||
// from an email and several strip heading tags outright, so this renders a styled
|
||||
// <div> at one of three sizes rather than h1/h2/h3. Keeping the prop named `level`
|
||||
// means the prop panel Phase 5b reuses reads the same as the page block's.
|
||||
const { registerEmailBlock } = require('../registry')
|
||||
const { oneOf, requiredText, onlyKeys } = require('../../blocks/propHelpers')
|
||||
|
||||
const LEVELS = ['h1', 'h2', 'h3']
|
||||
const MAX_TEXT = 200
|
||||
|
||||
const SIZES = { h1: '24px', h2: '19px', h3: '16px' }
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.heading',
|
||||
label: 'Heading',
|
||||
version: 1,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['level', 'text'])
|
||||
const level = oneOf('level', LEVELS)(props.level)
|
||||
if (level) errors.push(level)
|
||||
const text = requiredText('text', props.text, MAX_TEXT)
|
||||
if (text) errors.push(text)
|
||||
return errors
|
||||
},
|
||||
toHtml(props, ctx) {
|
||||
// Same "nothing in, nothing out" rule as email.text: a heading that is one
|
||||
// optional variable disappears rather than leaving its margin behind.
|
||||
if (ctx.t(props.text).trim() === '') return ''
|
||||
const size = SIZES[props.level] || SIZES.h2
|
||||
return (
|
||||
`<tr><td style="padding:0 0 12px 0;font-family:${ctx.palette.fontStack};` +
|
||||
`font-size:${size};line-height:1.3;font-weight:700;color:${ctx.palette.heading};">` +
|
||||
`${ctx.h(props.text)}</td></tr>`
|
||||
)
|
||||
},
|
||||
toText(props, ctx) {
|
||||
return ctx.t(props.text).trim()
|
||||
},
|
||||
})
|
||||
60
server/src/emailBlocks/types/image.js
Normal file
60
server/src/emailBlocks/types/image.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// email.image — an inline image.
|
||||
//
|
||||
// Two things differ from the page block of the same name, both because the reader
|
||||
// is in a mail client rather than on the site:
|
||||
//
|
||||
// - **The src is absolutized.** `brand_assets` stores `/uploads/…` and every page
|
||||
// renderer is same-origin, so a relative src has always been correct there. In
|
||||
// an inbox there is no origin to be relative to; render.js's `absolute()` turns
|
||||
// it into a URL against APP_BASE_URL / BRAND_URL, and an image that cannot be
|
||||
// absolutized is DROPPED rather than emitted broken.
|
||||
// - **`alt` is required.** Most mail clients block remote images by default, so
|
||||
// for a large share of readers the alt text IS the image. On a web page it is
|
||||
// an accessibility nicety; here it is the common case.
|
||||
const { registerEmailBlock } = require('../registry')
|
||||
const { requiredText, onlyKeys, isSafeUrl } = require('../../blocks/propHelpers')
|
||||
const { scanTokens } = require('../interpolate')
|
||||
|
||||
const MAX_URL = 600
|
||||
const MAX_ALT = 200
|
||||
const MAX_WIDTH = 560
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.image',
|
||||
label: 'Image',
|
||||
version: 1,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['url', 'alt', 'width'])
|
||||
const alt = requiredText('alt', props.alt, MAX_ALT)
|
||||
if (alt) errors.push(alt)
|
||||
const url = requiredText('url', props.url, MAX_URL)
|
||||
if (url) {
|
||||
errors.push(url)
|
||||
} else if (scanTokens(props.url).length === 0 && !isSafeUrl(props.url)) {
|
||||
errors.push('url must be a relative path, an http(s) URL, or a template variable')
|
||||
}
|
||||
if (props.width !== undefined) {
|
||||
if (!Number.isInteger(props.width) || props.width < 16 || props.width > MAX_WIDTH) {
|
||||
errors.push(`width must be a whole number between 16 and ${MAX_WIDTH}`)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
},
|
||||
toHtml(props, ctx) {
|
||||
const src = ctx.absolute(ctx.safeHref(props.url))
|
||||
if (!src) return '' // unresolvable: no broken image in someone's inbox
|
||||
const width = props.width ? ` width="${props.width}"` : ''
|
||||
const style = props.width
|
||||
? `max-width:100%;width:${props.width}px;height:auto;display:block;border:0;`
|
||||
: 'max-width:100%;height:auto;display:block;border:0;'
|
||||
return (
|
||||
`<tr><td style="padding:0 0 16px 0;">` +
|
||||
`<img src="${ctx.escape(src)}" alt="${ctx.h(props.alt)}"${width} style="${style}" /></td></tr>`
|
||||
)
|
||||
},
|
||||
toText(props, ctx) {
|
||||
// The alt text alone, with no [image] decoration: it was written to stand in
|
||||
// for the picture, and in the text part standing in for it is all it does.
|
||||
return ctx.t(props.alt)
|
||||
},
|
||||
})
|
||||
105
server/src/emailBlocks/types/itemList.js
Normal file
105
server/src/emailBlocks/types/itemList.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// email.itemList — the one repeating block, and the reason the token grammar in
|
||||
// interpolate.js needs no loop construct.
|
||||
//
|
||||
// It renders an ARRAY variable rather than an inline list: the prop is the NAME of
|
||||
// a declared variable (`items`), and the value arrives at render time. §4.6.1's
|
||||
// two generic templates — `notify.event` and `notify.digest` — are generic because
|
||||
// of this block: their variables are structural (`title`, `intro`, `items[]`), so
|
||||
// a trigger from any module renders through them with no authoring at all.
|
||||
//
|
||||
// **The item shape is `{ heading, excerpt?, url? }`, matching what
|
||||
// `teamNotify`/`teamDigestWorker` already build**, so Phase 6's migration onto the
|
||||
// engine is a rewiring rather than a reshaping of every producer.
|
||||
//
|
||||
// A non-array value, or an empty one, renders `emptyText` if there is one and
|
||||
// nothing at all otherwise. That is the same fail-soft posture `settingsJson`
|
||||
// takes: a stored value that is unusable is treated as absent, never as an error —
|
||||
// a digest whose item query returned nothing must still be a sendable mail.
|
||||
const { registerEmailBlock } = require('../registry')
|
||||
const { requiredText, optionalText, onlyKeys } = require('../../blocks/propHelpers')
|
||||
|
||||
const MAX_NAME = 64
|
||||
const MAX_EMPTY = 200
|
||||
const MAX_ITEMS = 100
|
||||
const NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/
|
||||
|
||||
/** Coerce whatever the caller passed into a bounded array of item objects. */
|
||||
function itemsOf(value) {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.slice(0, MAX_ITEMS)
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return { heading: item }
|
||||
if (!item || typeof item !== 'object') return null
|
||||
return {
|
||||
heading: item.heading == null ? '' : String(item.heading),
|
||||
excerpt: item.excerpt == null ? '' : String(item.excerpt),
|
||||
url: item.url == null ? '' : String(item.url),
|
||||
}
|
||||
})
|
||||
.filter((item) => item && item.heading !== '')
|
||||
}
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.itemList',
|
||||
label: 'Item list',
|
||||
version: 1,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['variable', 'emptyText'])
|
||||
const variable = requiredText('variable', props.variable, MAX_NAME)
|
||||
if (variable) {
|
||||
errors.push(variable)
|
||||
} else if (!NAME_RE.test(props.variable)) {
|
||||
errors.push('variable must be the name of a declared list variable')
|
||||
}
|
||||
const empty = optionalText('emptyText', props.emptyText, MAX_EMPTY)
|
||||
if (empty) errors.push(empty)
|
||||
return errors
|
||||
},
|
||||
toHtml(props, ctx) {
|
||||
const items = itemsOf(ctx.values[props.variable])
|
||||
if (items.length === 0) {
|
||||
if (!props.emptyText) return ''
|
||||
return (
|
||||
`<tr><td style="padding:0 0 16px 0;font-family:${ctx.palette.fontStack};font-size:14px;` +
|
||||
`line-height:1.55;color:${ctx.palette.muted};">${ctx.h(props.emptyText)}</td></tr>`
|
||||
)
|
||||
}
|
||||
const rows = items
|
||||
.map((item) => {
|
||||
const href = ctx.absolute(ctx.safeHref(item.url))
|
||||
const heading = ctx.escape(item.heading)
|
||||
const title = href
|
||||
? `<a href="${ctx.escape(href)}" style="color:${ctx.palette.accent};text-decoration:none;font-weight:600;">${heading}</a>`
|
||||
: `<span style="font-weight:600;color:${ctx.palette.heading};">${heading}</span>`
|
||||
const excerpt = item.excerpt
|
||||
? `<div style="padding-top:4px;font-size:14px;color:${ctx.palette.muted};">${ctx.escape(item.excerpt)}</div>`
|
||||
: ''
|
||||
return (
|
||||
`<tr><td style="padding:0 0 14px 0;border-left:3px solid ${ctx.palette.rule};padding-left:12px;` +
|
||||
`font-family:${ctx.palette.fontStack};font-size:15px;line-height:1.5;color:${ctx.palette.text};">` +
|
||||
`${title}${excerpt}</td></tr>`
|
||||
)
|
||||
})
|
||||
.join('')
|
||||
return (
|
||||
'<tr><td style="padding:0 0 8px 0;">' +
|
||||
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table>` +
|
||||
'</td></tr>'
|
||||
)
|
||||
},
|
||||
toText(props, ctx) {
|
||||
const items = itemsOf(ctx.values[props.variable])
|
||||
if (items.length === 0) return props.emptyText ? ctx.t(props.emptyText) : ''
|
||||
// Heading flush left, excerpt and url indented two spaces, one blank line
|
||||
// between items — the shape `mailer.sendTeamNotification` builds today.
|
||||
return items
|
||||
.map((item) => {
|
||||
const lines = [item.heading]
|
||||
if (item.excerpt) lines.push(` ${item.excerpt}`)
|
||||
if (item.url) lines.push(` ${ctx.absolute(item.url) || item.url}`)
|
||||
return lines.join('\n')
|
||||
})
|
||||
.join('\n\n')
|
||||
},
|
||||
})
|
||||
69
server/src/emailBlocks/types/text.js
Normal file
69
server/src/emailBlocks/types/text.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// email.text — a run of plain-text paragraphs.
|
||||
//
|
||||
// **There is no rich-text email block, and that is the §4.6.2 posture rather than
|
||||
// an omission.** The page family has `rich_text` because a page author is trusted
|
||||
// staff writing into a surface the site's own CSS controls. A mail body is
|
||||
// different in both halves: the markup an operator writes here is re-rendered by
|
||||
// thirty mail clients with thirty different subsets of HTML, and the VALUES
|
||||
// interpolated into it come from modules and from game data. §4.6.2 settles the
|
||||
// second half — "a module supplies data; it does not supply markup" — and the
|
||||
// first is why even the operator's own markup earns nothing here: a <div> an
|
||||
// author typed is a layout bug in Outlook, while `email.heading` / `email.button`
|
||||
// are shapes this renderer knows how to make survive.
|
||||
//
|
||||
// So: blank line separates paragraphs, single newline is a line break, and every
|
||||
// character is escaped on the way into HTML.
|
||||
const { registerEmailBlock } = require('../registry')
|
||||
const { requiredText, onlyKeys } = require('../../blocks/propHelpers')
|
||||
|
||||
const MAX_TEXT = 4000
|
||||
|
||||
/** Split on blank lines; each paragraph keeps its internal single newlines. */
|
||||
function paragraphs(s) {
|
||||
return String(s)
|
||||
.split(/\n[ \t]*\n/)
|
||||
.map((p) => p.replace(/^\n+|\n+$/g, ''))
|
||||
.filter((p) => p !== '')
|
||||
}
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.text',
|
||||
label: 'Paragraph',
|
||||
version: 1,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['text', 'muted'])
|
||||
const text = requiredText('text', props.text, MAX_TEXT)
|
||||
if (text) errors.push(text)
|
||||
if (props.muted !== undefined && typeof props.muted !== 'boolean') {
|
||||
errors.push('muted must be a boolean')
|
||||
}
|
||||
return errors
|
||||
},
|
||||
toHtml(props, ctx) {
|
||||
const color = props.muted ? ctx.palette.muted : ctx.palette.text
|
||||
const size = props.muted ? '13px' : '15px'
|
||||
// Interpolate FIRST, then split: a variable carrying a blank line becomes two
|
||||
// paragraphs, which is what the contact-message mail needs (a player's typed
|
||||
// message arrives as one variable and reads as they wrote it).
|
||||
const body = ctx.h(props.text)
|
||||
const parts = paragraphs(body)
|
||||
// A block whose whole content is one optional variable renders NOTHING when
|
||||
// that variable is absent, rather than an empty paragraph with its margin.
|
||||
// This is what stands in for a conditional: `{{moreNote}}` on its own line is
|
||||
// a line the caller can choose not to supply, and the template stays
|
||||
// logic-free (interpolate.js).
|
||||
if (parts.length === 0) return ''
|
||||
const html = parts
|
||||
.map((p) => `<p style="margin:0 0 12px 0;">${p.replace(/\n/g, '<br />')}</p>`)
|
||||
.join('')
|
||||
return (
|
||||
`<tr><td style="padding:0;font-family:${ctx.palette.fontStack};font-size:${size};` +
|
||||
`line-height:1.55;color:${color};">${html}</td></tr>`
|
||||
)
|
||||
},
|
||||
toText(props, ctx) {
|
||||
// Trimmed to match toHtml's "nothing in, nothing out": the two parts must
|
||||
// agree about whether this block contributed anything at all.
|
||||
return ctx.t(props.text).replace(/^\s+|\s+$/g, '')
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user