diff --git a/scripts/checkNoExternalHosts.js b/scripts/checkNoExternalHosts.js index d45747e..92bd4d4 100644 --- a/scripts/checkNoExternalHosts.js +++ b/scripts/checkNoExternalHosts.js @@ -43,8 +43,15 @@ const CODE = new Set(['.js', '.jsx', '.mjs', '.cjs']) // A URL, or a bare dotted hostname with a real TLD. The TLD length floor is what // keeps `emailConfig.model` and `foo.js` out of it — a two-plus-letter final // label after at least one dot, with no path characters, is a host. +// The `(?![-\w])` after the TLD is not redundant with `\b`: `\b` matches between +// `l` and `-`, so `auth.email-verify` — an engagement TEMPLATE KEY, and one the +// plan names (§4.6.1) — was read as the host `auth.email` with a stray suffix. +// A real hostname's TLD is the last label, so a `-` or a word character following +// it means the match is a truncation of a longer identifier rather than a +// destination. Everything a host IS followed by (a quote, `/`, `:`, `?`) still +// matches. const URL_LITERAL = /\b(?:https?|smtps?):\/\/[^\s'"`]+/ -const HOSTNAME_LITERAL = /\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|net|org|io|dev|co|email|mail|cloud|app|us|eu)\b/i +const HOSTNAME_LITERAL = /\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|net|org|io|dev|co|email|mail|cloud|app|us|eu)(?![-\w])/i // Hosts that are not destinations: the loopback family, and the RFC 2606 names // reserved for documentation. A placeholder in an admin form's help text is the diff --git a/server/db/schema.sql b/server/db/schema.sql index 0b6763a..59ee52b 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1847,3 +1847,47 @@ CREATE TABLE IF NOT EXISTS engagement_sends ( -- an index range scan rather than a table scan: it runs once per rule per event. INDEX idx_engs_rule_window (rule_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- §4.4. The mail (and, from Phase 7, in-app) bodies an operator can edit, stored +-- as a validated block array rather than as raw HTML: `blocks` goes through the +-- same validate-then-sanitize gate the CMS pages do, against the `email.*` +-- registry (src/emailBlocks/). Storing operator HTML would hand the renderer an +-- injection surface and give up the prop schemas. +-- +-- Three columns carry the whole "ship a better default without stealing an +-- operator's work" mechanism (§4.6.1 property 3). `seed_key` says which shipped +-- template a row came from, `seed_version` which revision of it, and `customized` +-- whether a person has since edited it. The seeder updates a row whose version is +-- behind ONLY while `customized = 0`; a customized row is left exactly as it is +-- and the newer default is surfaced in the admin list instead. Same posture +-- `settingsJson` takes: never overwrite what someone chose. +-- +-- `trigger_id` has no foreign key for the reason `engagement_rules.trigger_id` +-- has none -- a trigger is declared in code, so the set of them is whatever +-- registered on this boot. NULL means a reusable template not tied to one +-- trigger, which is what every transactional seed is: `mailer` renders them by +-- key, no rule involved. +CREATE TABLE IF NOT EXISTS engagement_templates ( + id INT AUTO_INCREMENT PRIMARY KEY, + `key` VARCHAR(96) NOT NULL UNIQUE, + name VARCHAR(160) NOT NULL, + trigger_id VARCHAR(96) NULL, + trigger_version INT NULL, + channel VARCHAR(32) NOT NULL, + subject VARCHAR(300) NULL, + blocks MEDIUMTEXT NOT NULL, + text_body MEDIUMTEXT NULL, + status ENUM('draft','published') NOT NULL DEFAULT 'draft', + -- Editable, NOT deletable -- the pages.protected flag, for the same reason: + -- the system breaks without a password-reset body. + protected TINYINT(1) NOT NULL DEFAULT 0, + seed_key VARCHAR(96) NULL, + seed_version INT NULL, + customized TINYINT(1) NOT NULL DEFAULT 0, + updated_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_engt_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_engt_trigger (trigger_id, channel, status), + INDEX idx_engt_seed (seed_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/db/seed.js b/server/db/seed.js index 4f8201a..41c92ee 100644 --- a/server/db/seed.js +++ b/server/db/seed.js @@ -4,6 +4,7 @@ const settingsDb = require('../src/model/settings/settings.db') const wikiDb = require('../src/model/wiki/wiki.db') const users = require('../src/model/users/users.model') const { ensureSchema, close } = require('../src/utils/db') +const { seedTemplates } = require('../src/engagement/templates') const brand = require('../src/config/brand') const log = require('../src/utils/logger')('seed') @@ -74,6 +75,12 @@ async function seedDefaults() { // migration of pages seeded before the wiki upgrade). await wikiDb.assignCategoryBySlug(slug, categorySlug) } + // The shipped mail bodies (ENGAGEMENT.md §4.6.1). Idempotent, and it never + // overwrites a row an operator has edited — `customized = 1` is checked in the + // UPDATE's own WHERE, not in a read-then-write. Never throws: a template that + // failed to seed costs the shipped default, which `renderByKey` falls back to + // anyway, and must not stop a boot. + await seedTemplates() log.info('settings and wiki defaults ensured') } diff --git a/server/src/blocks/sanitizeBlocks.js b/server/src/blocks/sanitizeBlocks.js index c9d91d6..91d3ef6 100644 --- a/server/src/blocks/sanitizeBlocks.js +++ b/server/src/blocks/sanitizeBlocks.js @@ -4,44 +4,58 @@ // normalizer (e.g. rich_text runs its html through the allowlist), stamping the // registry `version`, defaulting `visible` to true, and recursing one level into // container slots. Returns a new array; never mutates the input. +// +// Parameterized by a registry lookup for the same reason validateBlocks is +// (engagement Phase 5a): the `email.*` family is a separate registry and must get +// the same validate-then-sanitize order, not a second implementation of it. const { getBlock } = require('./registry') -function sanitizeBlocks(blocks) { - if (!Array.isArray(blocks)) return [] - return blocks.map(sanitizeOne) -} +/** + * Build a blocks sanitizer bound to one registry. + * @param {(type: string) => object|null} lookup registry `getBlock` + * @returns {(blocks: unknown) => object[]} + */ +function makeSanitizeBlocks(lookup) { + function sanitizeOne(block) { + const def = lookup(block.type) + if (!def) return block // unreachable after validation, but stay defensive -function sanitizeOne(block) { - const def = getBlock(block.type) - if (!def) return block // unreachable after validation, but stay defensive + let props = block.props && typeof block.props === 'object' ? { ...block.props } : {} - let props = block.props && typeof block.props === 'object' ? { ...block.props } : {} + // Recurse into container slots first (leaf sub-blocks get sanitized too). + if (def.container) { + for (const slot of def.containerSlots) { + if (Array.isArray(props[slot])) props[slot] = props[slot].map(sanitizeOne) + } + } - // Recurse into container slots first (leaf sub-blocks get sanitized too). - if (def.container) { - for (const slot of def.containerSlots) { - if (Array.isArray(props[slot])) props[slot] = props[slot].map(sanitizeOne) + // Apply the block's own normalizer last (operates on its scalar props). + if (def.sanitize) { + try { + props = def.sanitize(props) + } catch { + // Leave props as-is; validation already passed, a sanitize throw shouldn't + // block the save. + } + } + + return { + id: block.id, + type: block.type, + version: Number.isInteger(block.version) ? block.version : def.version, + visible: block.visible !== false, + props, } } - // Apply the block's own normalizer last (operates on its scalar props). - if (def.sanitize) { - try { - props = def.sanitize(props) - } catch { - // Leave props as-is; validation already passed, a sanitize throw shouldn't - // block the save. - } - } - - return { - id: block.id, - type: block.type, - version: Number.isInteger(block.version) ? block.version : def.version, - visible: block.visible !== false, - props, + return function sanitizeBlocks(blocks) { + if (!Array.isArray(blocks)) return [] + return blocks.map(sanitizeOne) } } -module.exports = { sanitizeBlocks } +// The page-registry binding — the export every existing caller already uses. +const sanitizeBlocks = makeSanitizeBlocks(getBlock) + +module.exports = { sanitizeBlocks, makeSanitizeBlocks } diff --git a/server/src/blocks/validateBlocks.js b/server/src/blocks/validateBlocks.js index efd3acd..5675e9c 100644 --- a/server/src/blocks/validateBlocks.js +++ b/server/src/blocks/validateBlocks.js @@ -1,4 +1,4 @@ -// Server-side validation for a page's `blocks` array, run on every save before +// Server-side validation for a stored `blocks` array, run on every save before // persisting. The admin UI validates client-side too, but that can be bypassed // by a direct API call, so this is the authoritative gate: it enforces the block // envelope (reserved keys only), that every `type` is a registered block, that @@ -9,6 +9,14 @@ // Returns { valid, errors } — a flat list of human-readable error strings, each // prefixed with the path to the offending block (e.g. `blocks[2].props.text`). // It never throws on bad input; callers turn a non-empty `errors` into a 400. +// +// **The walk is parameterized by a registry lookup, and the page registry is one +// binding of it** (engagement Phase 5a). The `email.*` family is a SEPARATE +// registry — its entries carry renderers instead of a cache policy, and a +// CMS page must not validate with an email block inside it — but the envelope, +// the id uniqueness, the schema dispatch and the nesting cap are the same rules +// for both. Sharing the walk is what keeps them the same rules rather than two +// copies that drift. const { getBlock, RESERVED_KEYS } = require('./registry') @@ -18,114 +26,129 @@ const MAX_SUBBLOCKS = 50 // sub-blocks per container slot const ID_RE = /^[A-Za-z0-9_-]{1,40}$/ /** - * Validate a stored blocks array against the registry. - * @param {unknown} blocks - * @returns {{ valid: boolean, errors: string[] }} + * Build a blocks validator bound to one registry. + * + * @param {(type: string) => object|null} lookup registry `getBlock` + * @param {{ maxBlocks?: number, maxSubBlocks?: number }} [limits] + * @returns {(blocks: unknown) => { valid: boolean, errors: string[] }} */ -function validateBlocks(blocks) { - const errors = [] - if (!Array.isArray(blocks)) { - return { valid: false, errors: ['blocks must be an array'] } - } - if (blocks.length > MAX_BLOCKS) { - errors.push(`blocks may not exceed ${MAX_BLOCKS} top-level entries`) - } - const seenIds = new Set() - blocks.forEach((block, i) => { - validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false }) - }) - return { valid: errors.length === 0, errors } -} +function makeValidateBlocks(lookup, limits = {}) { + const maxBlocks = limits.maxBlocks || MAX_BLOCKS + const maxSubBlocks = limits.maxSubBlocks || MAX_SUBBLOCKS -// Envelope: only the reserved keys, nothing smuggled at the top level. -function checkEnvelope(block, path, errors) { - for (const key of Object.keys(block)) { - if (!RESERVED_KEYS.includes(key)) { - errors.push(`${path}.${key} is not an allowed top-level key`) + // Envelope: only the reserved keys, nothing smuggled at the top level. + function checkEnvelope(block, path, errors) { + for (const key of Object.keys(block)) { + if (!RESERVED_KEYS.includes(key)) { + errors.push(`${path}.${key} is not an allowed top-level key`) + } } } -} -// id — stable, unique across the whole page (top-level and nested share one -// namespace since ids are the future join point for revision history). -function checkId(block, path, seenIds, errors) { - if (typeof block.id !== 'string' || !ID_RE.test(block.id)) { - errors.push(`${path}.id must be a short id string`) - } else if (seenIds.has(block.id)) { - errors.push(`${path}.id duplicates another block id (${block.id})`) - } else { - seenIds.add(block.id) - } -} - -// Per-block prop schema from the registry (skipped when props isn't an object — -// that's already reported separately). -function checkPropSchema(def, props, path, errors) { - if (!def.schema || !props || typeof props !== 'object') return - let schemaErrors = [] - try { - schemaErrors = def.schema(props) || [] - } catch (err) { - schemaErrors = [`schema threw: ${err.message}`] - } - for (const e of schemaErrors) errors.push(`${path}.props.${e}`) -} - -// Nesting: only container blocks may hold sub-blocks, capped at one level. -function checkNesting(def, props, path, seenIds, errors, nested) { - if (nested) { - errors.push(`${path} is a container and may not be nested inside another container`) - return - } - for (const slot of def.containerSlots) { - const sub = props ? props[slot] : undefined - if (sub === undefined) continue // an empty slot is allowed - if (!Array.isArray(sub)) { - errors.push(`${path}.props.${slot} must be an array of blocks`) - continue + // id — stable, unique across the whole document (top-level and nested share one + // namespace since ids are the future join point for revision history). + function checkId(block, path, seenIds, errors) { + if (typeof block.id !== 'string' || !ID_RE.test(block.id)) { + errors.push(`${path}.id must be a short id string`) + } else if (seenIds.has(block.id)) { + errors.push(`${path}.id duplicates another block id (${block.id})`) + } else { + seenIds.add(block.id) } - if (sub.length > MAX_SUBBLOCKS) { - errors.push(`${path}.props.${slot} may not exceed ${MAX_SUBBLOCKS} blocks`) + } + + // Per-block prop schema from the registry (skipped when props isn't an object — + // that's already reported separately). + function checkPropSchema(def, props, path, errors) { + if (!def.schema || !props || typeof props !== 'object') return + let schemaErrors = [] + try { + schemaErrors = def.schema(props) || [] + } catch (err) { + schemaErrors = [`schema threw: ${err.message}`] } - sub.forEach((child, j) => { - validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true }) + for (const e of schemaErrors) errors.push(`${path}.props.${e}`) + } + + // Nesting: only container blocks may hold sub-blocks, capped at one level. + function checkNesting(def, props, path, seenIds, errors, nested) { + if (nested) { + errors.push(`${path} is a container and may not be nested inside another container`) + return + } + for (const slot of def.containerSlots) { + const sub = props ? props[slot] : undefined + if (sub === undefined) continue // an empty slot is allowed + if (!Array.isArray(sub)) { + errors.push(`${path}.props.${slot} must be an array of blocks`) + continue + } + if (sub.length > maxSubBlocks) { + errors.push(`${path}.props.${slot} may not exceed ${maxSubBlocks} blocks`) + } + sub.forEach((child, j) => { + validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true }) + }) + } + } + + /** + * Validate one block envelope in place. `nested` = true when validating a + * sub-block inside a container slot, which forbids further nesting. + */ + function validateBlock(block, path, seenIds, errors, { nested }) { + if (block === null || typeof block !== 'object' || Array.isArray(block)) { + errors.push(`${path} must be an object`) + return + } + + checkEnvelope(block, path, errors) + checkId(block, path, seenIds, errors) + + // visible — optional in input, but if present must be a boolean. + if (block.visible !== undefined && typeof block.visible !== 'boolean') { + errors.push(`${path}.visible must be a boolean`) + } + + // props — always an object bag. + const props = block.props + if (props === null || typeof props !== 'object' || Array.isArray(props)) { + errors.push(`${path}.props must be an object`) + } + + // type — must resolve to a registered block. + const def = typeof block.type === 'string' ? lookup(block.type) : null + if (!def) { + errors.push(`${path}.type is not a registered block type (${String(block.type)})`) + return // can't validate props or nesting without a definition + } + + checkPropSchema(def, props, path, errors) + if (def.container) checkNesting(def, props, path, seenIds, errors, nested) + } + + /** + * Validate a stored blocks array against the bound registry. + * @param {unknown} blocks + * @returns {{ valid: boolean, errors: string[] }} + */ + return function validateBlocks(blocks) { + const errors = [] + if (!Array.isArray(blocks)) { + return { valid: false, errors: ['blocks must be an array'] } + } + if (blocks.length > maxBlocks) { + errors.push(`blocks may not exceed ${maxBlocks} top-level entries`) + } + const seenIds = new Set() + blocks.forEach((block, i) => { + validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false }) }) + return { valid: errors.length === 0, errors } } } -/** - * Validate one block envelope in place. `nested` = true when validating a - * sub-block inside a container slot, which forbids further nesting. - */ -function validateBlock(block, path, seenIds, errors, { nested }) { - if (block === null || typeof block !== 'object' || Array.isArray(block)) { - errors.push(`${path} must be an object`) - return - } +// The page-registry binding — the export every existing caller already uses. +const validateBlocks = makeValidateBlocks(getBlock) - checkEnvelope(block, path, errors) - checkId(block, path, seenIds, errors) - - // visible — optional in input, but if present must be a boolean. - if (block.visible !== undefined && typeof block.visible !== 'boolean') { - errors.push(`${path}.visible must be a boolean`) - } - - // props — always an object bag. - const props = block.props - if (props === null || typeof props !== 'object' || Array.isArray(props)) { - errors.push(`${path}.props must be an object`) - } - - // type — must resolve to a registered block. - const def = typeof block.type === 'string' ? getBlock(block.type) : null - if (!def) { - errors.push(`${path}.type is not a registered block type (${String(block.type)})`) - return // can't validate props or nesting without a definition - } - - checkPropSchema(def, props, path, errors) - if (def.container) checkNesting(def, props, path, seenIds, errors, nested) -} - -module.exports = { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS } +module.exports = { validateBlocks, makeValidateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS } diff --git a/server/src/emailBlocks/index.js b/server/src/emailBlocks/index.js new file mode 100644 index 0000000..fef1d06 --- /dev/null +++ b/server/src/emailBlocks/index.js @@ -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, +} diff --git a/server/src/emailBlocks/interpolate.js b/server/src/emailBlocks/interpolate.js new file mode 100644 index 0000000..f5afb46 --- /dev/null +++ b/server/src/emailBlocks/interpolate.js @@ -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} values + * @param {{ escape?: boolean, missing?: Set }} [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 } diff --git a/server/src/emailBlocks/registry.js b/server/src/emailBlocks/registry.js new file mode 100644 index 0000000..a58b9a8 --- /dev/null +++ b/server/src/emailBlocks/registry.js @@ -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) => '…', // 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, +} diff --git a/server/src/emailBlocks/render.js b/server/src/emailBlocks/render.js new file mode 100644 index 0000000..53bdae3 --- /dev/null +++ b/server/src/emailBlocks/render.js @@ -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: `
` layout and a `