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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
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, '')
|
||||
},
|
||||
})
|
||||
285
server/src/engagement/templateSeeds.js
Normal file
285
server/src/engagement/templateSeeds.js
Normal file
@@ -0,0 +1,285 @@
|
||||
// ── The shipped template set (§4.6.1) ──────────────────────────────────────
|
||||
//
|
||||
// "A fresh deployment mails correctly before anyone opens the editor." Every body
|
||||
// that used to be a template literal inside `utils/mailer.js` is a row here, so
|
||||
// Phase 5 is a RELOCATION rather than a regression: nothing that sends mail today
|
||||
// starts depending on an operator authoring something first.
|
||||
//
|
||||
// **Nine seeds, six of them wired in this phase.** The five transactional bodies
|
||||
// plus `auth.email-verify` (which §4.6.1 lists as "new — Phase 9" and which Phase
|
||||
// 1b in fact already shipped) are rendered by `mailer` from this moment. The three
|
||||
// notification seeds are seeded but not yet rendered by anything: `notify.digest`
|
||||
// and `notify.team-post` belong to `teamNotify`/`teamDigestWorker`, which Phase 6
|
||||
// rewrites onto the engine, and `inapp.event` to the channel Phase 7 builds.
|
||||
// Settled with the org lead: seed all nine now so those phases open something
|
||||
// rather than shipping seeds of their own — a seeder bump is the mechanism of last
|
||||
// resort (property 3 below), not a per-phase routine.
|
||||
//
|
||||
// **`seedVersion` is the whole "improve a default without stealing an operator's
|
||||
// work" mechanism.** Bump it when a body changes; the seeder updates rows where
|
||||
// `customized = 0` and skips rows where it is 1. Do NOT bump it for a comment.
|
||||
//
|
||||
// ── Two conventions the bodies follow, both of which are visible to operators ──
|
||||
//
|
||||
// **1. Presentational fragments are variables, because templates have no logic.**
|
||||
// `mailer` used to build ` for the account “Darrow”` with a ternary. A template
|
||||
// cannot, by design (interpolate.js: no conditionals). So the ternary stays at the
|
||||
// call site and its RESULT arrives as a variable — `forWhom` — whose `example`
|
||||
// shows exactly what it produces, leading space and quotes included. That is the
|
||||
// price of a logic-free template language, and it is paid here rather than by
|
||||
// giving operator-authored data a conditional to get wrong.
|
||||
//
|
||||
// **2. Ambient brand variables are supplied by the renderer, not by the caller.**
|
||||
// `siteName`, `siteUrl`, `logoUrl` and `year` are available to every template and
|
||||
// cannot be overridden by whatever a caller passes (`engagement/templates.js`).
|
||||
// §4.6.1 property 2: "no template contains a literal hex code or a logo URL", so
|
||||
// one prebuilt image running as any shard mails in that shard's identity.
|
||||
|
||||
// The ambient set, declared once so the editor's palette (Phase 5b) can offer them
|
||||
// on EVERY template rather than each seed having to list them.
|
||||
const AMBIENT_VARIABLES = Object.freeze([
|
||||
{ name: 'siteName', type: 'string', required: true, example: 'UOMysticmoon' },
|
||||
{ name: 'siteUrl', type: 'string', required: false, example: 'https://example.com' },
|
||||
{ name: 'logoUrl', type: 'string', required: false, example: 'https://example.com/brand/logo.png' },
|
||||
{ name: 'year', type: 'string', required: true, example: '2026' },
|
||||
])
|
||||
|
||||
// A tiny helper so the block arrays below read as content rather than as JSON.
|
||||
const text = (id, body, opts = {}) => ({
|
||||
id,
|
||||
type: 'email.text',
|
||||
props: opts.muted ? { text: body, muted: true } : { text: body },
|
||||
})
|
||||
const heading = (id, body, level = 'h1') => ({
|
||||
id,
|
||||
type: 'email.heading',
|
||||
props: { level, text: body },
|
||||
})
|
||||
const button = (id, label, url, textLead) => ({
|
||||
id,
|
||||
type: 'email.button',
|
||||
props: textLead ? { label, url, textLead } : { label, url },
|
||||
})
|
||||
const itemList = (id, variable, emptyText) => ({
|
||||
id,
|
||||
type: 'email.itemList',
|
||||
props: emptyText ? { variable, emptyText } : { variable },
|
||||
})
|
||||
const divider = (id) => ({ id, type: 'email.divider', props: {} })
|
||||
|
||||
const SEEDS = [
|
||||
// ── Transactional: protected = 1, editable but not deletable ─────────────
|
||||
{
|
||||
key: 'auth.password-reset',
|
||||
name: 'Password reset',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: 'Reset your {{siteName}} password',
|
||||
variables: [
|
||||
{ name: 'username', type: 'string', required: false, example: 'Darrow' },
|
||||
{ name: 'forWhom', type: 'string', required: false, example: ' for the account “Darrow”' },
|
||||
{ name: 'resetUrl', type: 'string', required: true, example: 'https://example.com/reset/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'We received a request to reset the password{{forWhom}} at {{siteName}}.'),
|
||||
button('cta', 'Choose a new password', '{{resetUrl}}', 'Choose a new password here:'),
|
||||
text(
|
||||
'p2',
|
||||
'This link is single-use and expires in about an hour. If you didn\'t request this, ' +
|
||||
'you can safely ignore this email — your password won\'t change.',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'auth.invite',
|
||||
name: 'Account invite',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: 'Your {{siteName}} invitation',
|
||||
variables: [
|
||||
{ name: 'acceptUrl', type: 'string', required: true, example: 'https://example.com/invite/abc123' },
|
||||
{ name: 'roleLabel', type: 'string', required: false, example: ' as moderator' },
|
||||
{ name: 'invitedBy', type: 'string', required: false, example: ' by Aldric' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'You have been invited{{invitedBy}} to join {{siteName}}{{roleLabel}}.'),
|
||||
button('cta', 'Accept your invitation', '{{acceptUrl}}', 'Accept your invitation and set up your account here:'),
|
||||
text('p2', 'This link is single-use and will expire. If you weren\'t expecting this, you can ignore it.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'auth.email-verify',
|
||||
name: 'Email address confirmation',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: 'Confirm your email address for {{siteName}}',
|
||||
variables: [
|
||||
{ name: 'username', type: 'string', required: false, example: 'Darrow' },
|
||||
{ name: 'forWhom', type: 'string', required: false, example: ' “Darrow”' },
|
||||
{ name: 'verifyUrl', type: 'string', required: true, example: 'https://example.com/verify/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'The {{siteName}} account{{forWhom}} asked to use this address for contact and account recovery.'),
|
||||
button('cta', 'Confirm this address', '{{verifyUrl}}', 'Confirm it here:'),
|
||||
text(
|
||||
'p2',
|
||||
'This link is single-use and expires in about a day. Until it is used, nothing changes — ' +
|
||||
'the account keeps whatever address it had.',
|
||||
),
|
||||
text(
|
||||
'p3',
|
||||
'If you did not ask for this, you can ignore this email. Someone may have mistyped their ' +
|
||||
'own address; no account of yours is affected and this link grants no access to anything.',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin.contact-message',
|
||||
name: 'Contact form message',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
// `fromLabel` and `fromName` are the SAME missing name with two different
|
||||
// fallbacks — 'a visitor' in the subject, 'unknown' in the body. That
|
||||
// divergence is inherited from the literal this replaces, and the template is
|
||||
// where it becomes visible and fixable: an operator who wants one word can now
|
||||
// edit the subject line instead of a source file.
|
||||
subject: '{{siteName}} contact from {{fromLabel}}',
|
||||
variables: [
|
||||
{ name: 'fromLabel', type: 'string', required: true, example: 'a visitor' },
|
||||
{ name: 'fromName', type: 'string', required: true, example: 'unknown' },
|
||||
{ name: 'fromEmail', type: 'string', required: true, example: 'ann@example.com' },
|
||||
{ name: 'message', type: 'string', required: true, example: 'Is the shard open to new players?' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'From: {{fromName}} <{{fromEmail}}>'),
|
||||
text('p2', '{{message}}'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin.test',
|
||||
name: 'Delivery test',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: '{{siteName}} email test',
|
||||
variables: [
|
||||
{ name: 'transport', type: 'string', required: true, example: 'smtp' },
|
||||
{ name: 'sentAt', type: 'string', required: false, example: '2026-08-29 18:04 UTC' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'This is a test message confirming {{transport}} email delivery is working.'),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Notification: protected = 0, replaceable ─────────────────────────────
|
||||
//
|
||||
// **`notify.event` and `notify.digest` are generic on purpose** (§4.6.1 property
|
||||
// 1): their variables are structural — `title`, `intro`, `items[]` — rather than
|
||||
// domain-specific, so a trigger from core or from any module renders through
|
||||
// them with NO authoring at all. This is what stops "add a trigger" from meaning
|
||||
// "and now write a template".
|
||||
{
|
||||
key: 'notify.event',
|
||||
name: 'Notification (single event)',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
subject: '{{title}}',
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
|
||||
{ name: 'intro', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
|
||||
{ name: 'items', type: 'list', required: false, example: [{ heading: 'The Silver Anvil', excerpt: 'Britain, Trammel (1119, 1794)' }] },
|
||||
{ name: 'actionUrl', type: 'string', required: false, example: 'https://example.com/houses' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
heading('h', '{{title}}'),
|
||||
text('intro', '{{intro}}'),
|
||||
itemList('items', 'items'),
|
||||
button('cta', 'Open {{siteName}}', '{{actionUrl}}'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'notify.digest',
|
||||
name: 'Notification digest',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
subject: '{{siteName}}: {{periodLabel}}',
|
||||
variables: [
|
||||
{ name: 'periodLabel', type: 'string', required: true, example: 'your daily summary' },
|
||||
{ name: 'intro', type: 'string', required: false, example: 'Here is what happened while you were away.' },
|
||||
{ name: 'items', type: 'list', required: false, example: [{ heading: 'New thread in Guild Hall', excerpt: 'Meeting moved to Friday', url: 'https://example.com/teams/1?thread=9' }] },
|
||||
// Precomputed for the same reason `forWhom` is: "and 3 more" needs a
|
||||
// conditional and a plural, and a template has neither.
|
||||
{ name: 'moreNote', type: 'string', required: false, example: 'and 3 more.' },
|
||||
{ name: 'scopeUrl', type: 'string', required: false, example: 'https://example.com/teams/1' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('intro', '{{intro}}'),
|
||||
itemList('items', 'items'),
|
||||
text('more', '{{moreNote}}', { muted: true }),
|
||||
button('cta', 'Open {{siteName}}', '{{scopeUrl}}'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'notify.team-post',
|
||||
name: 'Team post notification',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
subject: '{{teamName}}: {{threadTitle}}',
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil' },
|
||||
{ name: 'authorName', type: 'string', required: true, example: 'Aldric' },
|
||||
{ name: 'threadTitle', type: 'string', required: true, example: 'Meeting moved to Friday' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'We are pushing this week back a day so more people can make it.' },
|
||||
{ name: 'threadUrl', type: 'string', required: false, example: 'https://example.com/teams/1?thread=9' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', '{{authorName}} posted in {{teamName}}.'),
|
||||
heading('h', '{{threadTitle}}', 'h2'),
|
||||
text('excerpt', '{{excerpt}}', { muted: true }),
|
||||
button('cta', 'Read the thread', '{{threadUrl}}'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails for this team, use this link:'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'inapp.event',
|
||||
name: 'On-site notification',
|
||||
channel: 'inapp',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
// No subject: an inbox row has a title, and the title is a block. The column
|
||||
// is email's, and leaving it NULL is how a non-email template says so.
|
||||
subject: null,
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
|
||||
{ name: 'body', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
|
||||
{ name: 'url', type: 'string', required: false, example: 'https://example.com/houses' },
|
||||
],
|
||||
blocks: [
|
||||
heading('h', '{{title}}', 'h3'),
|
||||
text('body', '{{body}}'),
|
||||
button('cta', 'Open', '{{url}}'),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** @returns {object|null} the seed definition for `key`. */
|
||||
function seedByKey(key) {
|
||||
return SEEDS.find((s) => s.key === key) || null
|
||||
}
|
||||
|
||||
module.exports = { SEEDS, AMBIENT_VARIABLES, seedByKey }
|
||||
190
server/src/engagement/templates.js
Normal file
190
server/src/engagement/templates.js
Normal file
@@ -0,0 +1,190 @@
|
||||
// ── Templates: resolve, render, seed ───────────────────────────────────────
|
||||
//
|
||||
// The seam between a stored `engagement_templates` row and the two body parts a
|
||||
// transport sends. Everything that needs a database happens here; `emailBlocks/`
|
||||
// stays pure and synchronous below it.
|
||||
//
|
||||
// **A missing row renders the shipped default rather than nothing.** `renderByKey`
|
||||
// falls back to `templateSeeds.js` whenever the row is absent or its blocks will
|
||||
// not parse. This is not defensive padding — it is what makes it safe for
|
||||
// `mailer` to depend on the database for a password-reset body at all. Before the
|
||||
// first seed runs, after a restore that dropped the table, on a deployment whose
|
||||
// operator deleted a row by hand: the mail still goes out, in the shipped wording,
|
||||
// and the `protected` flag stops the last of those from being reachable through
|
||||
// the API. The same posture `settingsJson` and `resolveThemeTokens` take — a
|
||||
// stored value that is unusable is treated as absent, never as an error.
|
||||
|
||||
const templatesDb = require('../model/engagement/engagementTemplates.db')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const brand = require('../config/brand')
|
||||
const emailBlocks = require('../emailBlocks')
|
||||
const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('./templateSeeds')
|
||||
// The trigger registry lives with the module registries, not here — a trigger is
|
||||
// something a MODULE declares (see engagement/index.js's header).
|
||||
const { eventTrigger } = require('../modules/registries')
|
||||
const log = require('../utils/logger')('templates')
|
||||
|
||||
const baseUrl = () => (process.env.APP_BASE_URL || brand.url || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* The brand values every template may reference, resolved from the same places
|
||||
* the site's own chrome resolves them (§4.6.1 property 2).
|
||||
*
|
||||
* **They are merged OVER the caller's values, not under.** A caller supplies the
|
||||
* message; the deployment supplies its identity. Letting a caller pass its own
|
||||
* `siteName` would mean a module — or a bug — could send mail that claims to be
|
||||
* from somewhere else, which is precisely the thing a recipient cannot check.
|
||||
*
|
||||
* Never throws: a settings read that fails degrades to the BRAND_* env values, so
|
||||
* mail is branded slightly less specifically rather than not sent.
|
||||
*/
|
||||
async function ambient() {
|
||||
let name = brand.name
|
||||
let logo = brand.logo
|
||||
let theme = null
|
||||
try {
|
||||
name = await settings.getInstanceName()
|
||||
const shell = await settings.getShellBrand()
|
||||
logo = shell.logo || brand.logo
|
||||
theme = shell.theme
|
||||
} catch (err) {
|
||||
log.warn('brand resolution failed; falling back to BRAND_* env', { message: err.message })
|
||||
}
|
||||
const base = baseUrl()
|
||||
const absLogo = logo && logo.startsWith('/') ? `${base}${logo}` : logo || ''
|
||||
return {
|
||||
values: {
|
||||
siteName: name,
|
||||
siteUrl: base,
|
||||
logoUrl: absLogo,
|
||||
year: String(new Date().getUTCFullYear()),
|
||||
},
|
||||
// resolveThemeTokens speaks CSS custom properties; the renderer speaks colour
|
||||
// names. One mapping, here, rather than the renderer knowing about CSS.
|
||||
theme: { accent: theme ? theme['--accent'] : undefined },
|
||||
baseUrl: base,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which variables a template may reference — the input to Phase 5b's palette and
|
||||
* to its save-time "undeclared variable" refusal.
|
||||
*
|
||||
* Two sources, because a template has two possible origins. One tied to a trigger
|
||||
* reads §4.3's declaration, which is the authority for anything a module emits.
|
||||
* One with no trigger — every transactional seed is one; `mailer` renders them by
|
||||
* key with no rule involved — has no trigger to ask, so its shipped definition
|
||||
* carries the list. Ambient brand variables are appended to both.
|
||||
*
|
||||
* @param {{ trigger_id?: string|null, seed_key?: string|null }} template
|
||||
* @returns {Array<{name: string, type: string, required: boolean, example: unknown}>}
|
||||
*/
|
||||
function variablesFor(template) {
|
||||
const own = []
|
||||
if (template && template.trigger_id) {
|
||||
const declared = eventTrigger(template.trigger_id)
|
||||
if (declared && Array.isArray(declared.variables)) own.push(...declared.variables)
|
||||
} else if (template && template.seed_key) {
|
||||
const seed = seedByKey(template.seed_key)
|
||||
if (seed) own.push(...seed.variables)
|
||||
}
|
||||
const names = new Set(own.map((v) => v.name))
|
||||
return [...own, ...AMBIENT_VARIABLES.filter((v) => !names.has(v.name))]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one template into its two body parts.
|
||||
*
|
||||
* @param {object} template a row, or a seed definition
|
||||
* @param {Record<string, unknown>} values
|
||||
* @param {object} resolved the result of ambient()
|
||||
* @returns {{ subject: string, html: string, text: string, missing: string[] }}
|
||||
*/
|
||||
function renderTemplate(template, values, resolved) {
|
||||
const merged = { ...values, ...resolved.values }
|
||||
const missing = new Set()
|
||||
const ctx = emailBlocks.buildContext({
|
||||
values: merged,
|
||||
theme: resolved.theme,
|
||||
baseUrl: resolved.baseUrl,
|
||||
missing,
|
||||
})
|
||||
const rendered = emailBlocks.renderBlocks(template.blocks, ctx)
|
||||
const subject = template.subject ? ctx.t(template.subject) : ''
|
||||
// An authored `text_body` REPLACES the generated one (§4.4), and is interpolated
|
||||
// like any other authored string. It is a per-template override, not an addition.
|
||||
const text = template.text_body ? ctx.t(template.text_body) : rendered.text
|
||||
return {
|
||||
subject,
|
||||
html: emailBlocks.renderDocument(rendered.html, ctx, subject),
|
||||
text,
|
||||
missing: [...missing],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the template stored under `key`, falling back to its shipped default.
|
||||
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
|
||||
* null only when `key` names neither a row nor a seed.
|
||||
*/
|
||||
async function renderByKey(key, values = {}) {
|
||||
const resolved = await ambient()
|
||||
let template = null
|
||||
try {
|
||||
template = await templatesDb.getByKey(key)
|
||||
} catch (err) {
|
||||
log.warn('template read failed; using the shipped default', { key, message: err.message })
|
||||
}
|
||||
if (!template || !Array.isArray(template.blocks) || template.blocks.length === 0) {
|
||||
const seed = seedByKey(key)
|
||||
if (!seed) return null
|
||||
if (template) log.warn('stored template is unusable; using the shipped default', { key })
|
||||
template = { subject: seed.subject, blocks: seed.blocks, text_body: null }
|
||||
}
|
||||
return renderTemplate(template, values, resolved)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure every shipped template exists, and bring un-customized rows up to the
|
||||
* current seed. Idempotent: a second run reports nine skips and writes nothing.
|
||||
*
|
||||
* Never throws — it is called from `seedDefaults()` on the boot path, and a
|
||||
* template that failed to seed costs the shipped default (see the header note),
|
||||
* not the deployment.
|
||||
*/
|
||||
async function seedTemplates() {
|
||||
const counts = { inserted: 0, updated: 0, skipped: 0, invalid: 0 }
|
||||
for (const seed of SEEDS) {
|
||||
// Validated against the registry before it is stored, even though a seed is
|
||||
// code rather than input. The alternative is a shipped block array that no
|
||||
// renderer understands sitting in the table, which reads to an operator as
|
||||
// their deployment being broken; refusing to write it leaves `renderByKey`'s
|
||||
// fallback in charge and puts the reason in the boot log.
|
||||
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
|
||||
if (!valid) {
|
||||
log.error('shipped template is invalid and was not seeded', { key: seed.key, errors })
|
||||
counts.invalid += 1
|
||||
continue
|
||||
}
|
||||
try {
|
||||
counts[await templatesDb.seedOne(seed)] += 1
|
||||
} catch (err) {
|
||||
log.error('template seed failed', { key: seed.key, message: err.message })
|
||||
}
|
||||
}
|
||||
// The third arm of §4.6.1 property 3: a customized row is never touched, and the
|
||||
// fact that a better default now exists is surfaced instead of applied.
|
||||
let stale = []
|
||||
try {
|
||||
stale = await templatesDb.staleCustomized(SEEDS.map((s) => ({ key: s.key, seedVersion: s.seedVersion })))
|
||||
} catch {
|
||||
stale = []
|
||||
}
|
||||
if (stale.length) {
|
||||
log.info('customized templates have a newer shipped default', { keys: stale.map((t) => t.key) })
|
||||
}
|
||||
log.info('engagement templates ensured', counts)
|
||||
return { ...counts, stale: stale.map((t) => t.key) }
|
||||
}
|
||||
|
||||
module.exports = { ambient, variablesFor, renderTemplate, renderByKey, seedTemplates, baseUrl }
|
||||
149
server/src/model/engagement/engagementTemplates.db.js
Normal file
149
server/src/model/engagement/engagementTemplates.db.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// Same JSON-column caveat as engagementRules.db.js — `blocks` is a MEDIUMTEXT
|
||||
// holding JSON rather than a JSON column (it can be large and is never queried
|
||||
// into), so it is always a string on the way out and always parsed here.
|
||||
function parseJson(value, fallback) {
|
||||
if (value === null || value === undefined) return fallback
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// A stored `blocks` that will not parse degrades to an EMPTY array, not to an
|
||||
// error. Same posture `settingsJson` takes and the same one `resolveThemeTokens`
|
||||
// takes: a row hand-edited in the DB, or written by an older version of this code,
|
||||
// must not stop a password-reset mail from being attempted — the renderer produces
|
||||
// an empty body, the send log records it, and the operator is told in the admin
|
||||
// list rather than at 3am by a boot that will not come up.
|
||||
const hydrate = (row) =>
|
||||
row && {
|
||||
...row,
|
||||
blocks: parseJson(row.blocks, []),
|
||||
protected: Boolean(row.protected),
|
||||
customized: Boolean(row.customized),
|
||||
}
|
||||
|
||||
const list = async () =>
|
||||
(await query('SELECT * FROM engagement_templates ORDER BY channel, `key`')).map(hydrate)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_templates WHERE id = ?', [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
const getByKey = async (key) => {
|
||||
const [row] = await query('SELECT * FROM engagement_templates WHERE `key` = ?', [key])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/** Which of `keys` exist. Used to validate a rule's `template_keys` map. */
|
||||
const existingKeys = async (keys) => {
|
||||
if (!Array.isArray(keys) || keys.length === 0) return []
|
||||
const marks = keys.map(() => '?').join(',')
|
||||
const rows = await query(`SELECT \`key\` FROM engagement_templates WHERE \`key\` IN (${marks})`, keys)
|
||||
return rows.map((r) => r.key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a shipped template, or bring an un-customized one up to a newer seed.
|
||||
*
|
||||
* **The `customized = 0` guard is in the SQL, not in a read-then-write.** The
|
||||
* seeder runs on every boot and a deployment can start two app processes at once;
|
||||
* a check in JavaScript followed by an UPDATE is a window in which an operator's
|
||||
* edit can be overwritten by a concurrent boot. `WHERE customized = 0` in the
|
||||
* UPDATE closes it, and MariaDB's `ON DUPLICATE KEY UPDATE` cannot express a
|
||||
* WHERE — so this is deliberately an INSERT IGNORE plus a guarded UPDATE rather
|
||||
* than the upsert §4.6.1 sketches.
|
||||
*
|
||||
* @returns {'inserted'|'updated'|'skipped'} what happened, for the boot log
|
||||
*/
|
||||
const seedOne = async (t) => {
|
||||
const inserted = await query(
|
||||
'INSERT IGNORE INTO engagement_templates ' +
|
||||
'(`key`, name, trigger_id, trigger_version, channel, subject, blocks, text_body, status, ' +
|
||||
' protected, seed_key, seed_version, customized) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, 0)',
|
||||
[
|
||||
t.key,
|
||||
t.name,
|
||||
t.triggerId ?? null,
|
||||
t.triggerVersion ?? null,
|
||||
t.channel,
|
||||
t.subject ?? null,
|
||||
JSON.stringify(t.blocks),
|
||||
t.status || 'published',
|
||||
t.protected ? 1 : 0,
|
||||
t.key,
|
||||
t.seedVersion,
|
||||
],
|
||||
)
|
||||
if (inserted.affectedRows === 1) return 'inserted'
|
||||
|
||||
const updated = await query(
|
||||
'UPDATE engagement_templates SET name = ?, channel = ?, subject = ?, blocks = ?, ' +
|
||||
'protected = ?, seed_version = ?, status = ? ' +
|
||||
'WHERE seed_key = ? AND customized = 0 AND (seed_version IS NULL OR seed_version < ?)',
|
||||
[
|
||||
t.name,
|
||||
t.channel,
|
||||
t.subject ?? null,
|
||||
JSON.stringify(t.blocks),
|
||||
t.protected ? 1 : 0,
|
||||
t.seedVersion,
|
||||
t.status || 'published',
|
||||
t.key,
|
||||
t.seedVersion,
|
||||
],
|
||||
)
|
||||
return updated.affectedRows === 1 ? 'updated' : 'skipped'
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an operator's edit. Always sets `customized = 1` — that flag is not a
|
||||
* field the caller may choose, it is the record that a human touched this row, and
|
||||
* it is the only thing standing between their work and the next seed bump.
|
||||
*
|
||||
* **`affectedRows === 1` here means "the row exists", not "something changed",**
|
||||
* because the connector defaults to `foundRows: true` (the trap Phase 4a's
|
||||
* cooldown check fell into). That is the semantics this caller wants — re-saving a
|
||||
* template unchanged is a success, not a 404 — and it is stated rather than
|
||||
* relied on, since the same expression means the other thing under `foundRows:
|
||||
* false`. `seedOne`'s UPDATE above is safe under either reading: its WHERE only
|
||||
* matches a row whose `seed_version` is behind, so a match always implies a write.
|
||||
*/
|
||||
const update = async (id, t, userId) => {
|
||||
const res = await query(
|
||||
'UPDATE engagement_templates SET name = ?, subject = ?, blocks = ?, text_body = ?, ' +
|
||||
'status = ?, trigger_id = ?, trigger_version = ?, customized = 1, updated_by = ? WHERE id = ?',
|
||||
[
|
||||
t.name,
|
||||
t.subject ?? null,
|
||||
JSON.stringify(t.blocks),
|
||||
t.textBody ?? null,
|
||||
t.status,
|
||||
t.triggerId ?? null,
|
||||
t.triggerVersion ?? null,
|
||||
userId ?? null,
|
||||
id,
|
||||
],
|
||||
)
|
||||
return res.affectedRows === 1
|
||||
}
|
||||
|
||||
/** Templates whose shipped default has moved on since the operator edited them. */
|
||||
const staleCustomized = async (pairs) => {
|
||||
if (!Array.isArray(pairs) || pairs.length === 0) return []
|
||||
const clauses = pairs.map(() => '(seed_key = ? AND seed_version < ?)').join(' OR ')
|
||||
const params = pairs.flatMap((p) => [p.key, p.seedVersion])
|
||||
const rows = await query(
|
||||
`SELECT * FROM engagement_templates WHERE customized = 1 AND (${clauses})`,
|
||||
params,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
module.exports = { list, getById, getByKey, existingKeys, seedOne, update, staleCustomized }
|
||||
@@ -10,6 +10,11 @@
|
||||
// as an ordinary SMTP relay (`smtp.gmail.com:587` with an app password) — the
|
||||
// operator types those in like any other host; nothing in here knows about it.
|
||||
//
|
||||
// Engagement Phase 5a moved every subject and body out of this file. What each
|
||||
// sender still owns is its RECIPIENT, its headers and its failure contract; what
|
||||
// it says is an `engagement_templates` row rendered by `engagement/templates.js`
|
||||
// (§4.6.1), which an operator can edit and which falls back to the shipped seed.
|
||||
//
|
||||
// **The failure contracts are the point of this file.** Six call sites, five
|
||||
// senders, and each one degrades a specific way when mail is unconfigured. Those
|
||||
// contracts are unchanged by the transport rewrite and are asserted in
|
||||
@@ -22,9 +27,39 @@
|
||||
const emailConfig = require('../model/emailConfig/emailConfig.model')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const { transports } = require('../engagement')
|
||||
const brand = require('../config/brand')
|
||||
const templates = require('../engagement/templates')
|
||||
const log = require('./logger')('mailer')
|
||||
|
||||
/**
|
||||
* The body for one message, from its template (engagement Phase 5a).
|
||||
*
|
||||
* Every subject and body in this file used to be a template literal; they are now
|
||||
* `engagement_templates` rows an operator can edit, and this is the single seam
|
||||
* where that happens. Three properties the senders below depend on:
|
||||
*
|
||||
* - **It cannot fail.** `renderByKey` falls back to the shipped seed whenever the
|
||||
* row is missing or unusable, so no failure mode of the templates table can
|
||||
* stop a password-reset mail. It returns null only for a key that names neither
|
||||
* a row nor a seed, which is a programmer error and throws here rather than
|
||||
* sending a blank message.
|
||||
* - **The text part is byte-identical to what this file used to build**, which is
|
||||
* §5a's acceptance criterion and is pinned by `test/emailTemplates.test.js`.
|
||||
* - **The HTML part is new.** Nothing here had one before; mail is now
|
||||
* multipart/alternative, so a client that prefers HTML shows the branded body
|
||||
* and one that does not shows exactly the text it always showed.
|
||||
*/
|
||||
async function body(key, values) {
|
||||
const rendered = await templates.renderByKey(key, values)
|
||||
if (!rendered) throw new Error(`mailer: no template and no shipped seed for "${key}"`)
|
||||
if (rendered.missing.length) {
|
||||
// Not an error: an optional variable a caller chose not to supply renders as
|
||||
// nothing by design. Logged because the other cause is a renamed variable in
|
||||
// an operator's edited template, and that one reads as words gone missing.
|
||||
log.debug('template variables had no value', { key, missing: rendered.missing })
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
// Ready to send only when enabled, holding a complete credential for a
|
||||
// registered transport, and knowing which address to send as.
|
||||
async function isConfigured() {
|
||||
@@ -100,13 +135,23 @@ async function sendContactMessage({ name, email, message }) {
|
||||
}
|
||||
const { transport, config } = built
|
||||
const to = await contactRecipient(config.senderEmail)
|
||||
// `fromLabel` and `fromName` are the same missing name with the two different
|
||||
// fallbacks this message has always used — 'a visitor' in the subject, 'unknown'
|
||||
// in the body. Kept exactly, and now visible to an operator who wants one word.
|
||||
const { subject, html, text } = await body('admin.contact-message', {
|
||||
fromLabel: name || 'a visitor',
|
||||
fromName: name || 'unknown',
|
||||
fromEmail: email || 'no email',
|
||||
message,
|
||||
})
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
replyTo: replyToFor(config, email),
|
||||
subject: `${brand.name} contact from ${name || 'a visitor'}`,
|
||||
text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Last send OK', lastVerifiedAt: new Date() })
|
||||
return { sent: true }
|
||||
@@ -165,13 +210,18 @@ async function sendTest(to) {
|
||||
err.code = 'NO_RECIPIENT'
|
||||
throw err
|
||||
}
|
||||
const { subject, html, text } = await body('admin.test', {
|
||||
transport: config.transport,
|
||||
sentAt: new Date().toISOString(),
|
||||
})
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to: recipient,
|
||||
replyTo: replyToFor(config),
|
||||
subject: `${brand.name} email test`,
|
||||
text: `This is a test message confirming ${config.transport} email delivery is working.`,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() })
|
||||
return { sent: true, to: recipient }
|
||||
@@ -196,18 +246,23 @@ async function sendInvite({ to, acceptUrl, role, invitedByName }) {
|
||||
const built = await buildTransport()
|
||||
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
||||
const { transport, config } = built
|
||||
// The two conditional fragments stay HERE, where a ternary belongs, and reach
|
||||
// the template as values. §4.6.2's grammar has no conditional by design.
|
||||
const roleLabel = role && role !== 'player' ? ` as ${role}` : ''
|
||||
const by = invitedByName ? ` by ${invitedByName}` : ''
|
||||
const { subject, html, text } = await body('auth.invite', {
|
||||
acceptUrl,
|
||||
roleLabel,
|
||||
invitedBy: by,
|
||||
})
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
replyTo: replyToFor(config),
|
||||
subject: `Your ${brand.name} invitation`,
|
||||
text:
|
||||
`You have been invited${by} to join ${brand.name}${roleLabel}.\n\n` +
|
||||
`Accept your invitation and set up your account here:\n${acceptUrl}\n\n` +
|
||||
`This link is single-use and will expire. If you weren't expecting this, you can ignore it.`,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Invite send OK', lastVerifiedAt: new Date() })
|
||||
return { sent: true }
|
||||
@@ -229,17 +284,15 @@ async function sendPasswordReset({ to, resetUrl, username }) {
|
||||
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
||||
const { transport, config } = built
|
||||
const forWhom = username ? ` for the account “${username}”` : ''
|
||||
const { subject, html, text } = await body('auth.password-reset', { resetUrl, username, forWhom })
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
replyTo: replyToFor(config),
|
||||
subject: `Reset your ${brand.name} password`,
|
||||
text:
|
||||
`We received a request to reset the password${forWhom} at ${brand.name}.\n\n` +
|
||||
`Choose a new password here:\n${resetUrl}\n\n` +
|
||||
`This link is single-use and expires in about an hour. If you didn't request ` +
|
||||
`this, you can safely ignore this email — your password won't change.`,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Password reset send OK', lastVerifiedAt: new Date() })
|
||||
return { sent: true }
|
||||
@@ -266,19 +319,15 @@ async function sendEmailVerification({ to, verifyUrl, username }) {
|
||||
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
||||
const { transport, config } = built
|
||||
const forWhom = username ? ` “${username}”` : ''
|
||||
const { subject, html, text } = await body('auth.email-verify', { verifyUrl, username, forWhom })
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
replyTo: replyToFor(config),
|
||||
subject: `Confirm your email address for ${brand.name}`,
|
||||
text:
|
||||
`The ${brand.name} account${forWhom} asked to use this address for contact and account recovery.\n\n` +
|
||||
`Confirm it here:\n${verifyUrl}\n\n` +
|
||||
`This link is single-use and expires in about a day. Until it is used, nothing changes — ` +
|
||||
`the account keeps whatever address it had.\n\n` +
|
||||
`If you did not ask for this, you can ignore this email. Someone may have mistyped their ` +
|
||||
`own address; no account of yours is affected and this link grants no access to anything.`,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Verification send OK', lastVerifiedAt: new Date() })
|
||||
return { sent: true }
|
||||
|
||||
@@ -73,6 +73,18 @@ test('an ordinary sentence with a full stop is not a hostname', () => {
|
||||
assert.deepEqual(hostsIn(`const msg = 'Send failed. Check the host and port.'`), [])
|
||||
})
|
||||
|
||||
test('a dotted identifier is not a hostname just because a real TLD is a label', () => {
|
||||
// `.email`, `.mail` and `.app` are real TLDs, so an engagement template key or a
|
||||
// trigger id can look like a host to a regex. A real hostname's TLD is its LAST
|
||||
// label; these carry on into another word.
|
||||
assert.deepEqual(hostsIn(`const key = 'auth.email-verify'`), [])
|
||||
assert.deepEqual(hostsIn(`await body('auth.email-verify', { verifyUrl })`), [])
|
||||
assert.deepEqual(hostsIn(`const t = 'core.mail_bounced'`), [])
|
||||
// …and the real thing still trips it, so the loosening did not blunt the check.
|
||||
assert.deepEqual(hostsIn(`const h = 'smtp.somewhere.email'`), ['smtp.somewhere.email'])
|
||||
assert.deepEqual(hostsIn(`const h = 'relay.somewhere.email:587'`), ['relay.somewhere.email'])
|
||||
})
|
||||
|
||||
// ── the pieces, directly ────────────────────────────────────────────────────
|
||||
|
||||
test('maskComments blanks comments but keeps string bodies and line count', () => {
|
||||
|
||||
468
server/test/emailTemplates.test.js
Normal file
468
server/test/emailTemplates.test.js
Normal file
@@ -0,0 +1,468 @@
|
||||
// Engagement Phase 5a — the `email.*` block family, the renderer, and the shipped
|
||||
// template set (ENGAGEMENT.md §4.4 / §4.6.1).
|
||||
//
|
||||
// The centrepiece is the byte-comparison block: §5a's acceptance criterion is that
|
||||
// "every one of the five current message types renders byte-comparably from its
|
||||
// seeded template", so the strings below are the LITERALS this phase deleted from
|
||||
// `utils/mailer.js`, copied character for character. If a seed's wording changes,
|
||||
// these fail — which is the point. They are the only thing standing between an
|
||||
// edit to a block array and a silently reworded password-reset mail.
|
||||
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const emailBlocks = require('../src/emailBlocks')
|
||||
const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('../src/engagement/templateSeeds')
|
||||
const templatesDb = require('../src/model/engagement/engagementTemplates.db')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const templates = require('../src/engagement/templates')
|
||||
|
||||
const SITE = 'Runic Gateway'
|
||||
const BASE = 'https://shard.example.com'
|
||||
|
||||
/** Render one seed the way mailer does, without any of the database. */
|
||||
function render(key, values = {}, opts = {}) {
|
||||
const seed = seedByKey(key)
|
||||
const ctx = emailBlocks.buildContext({
|
||||
values: { siteName: SITE, siteUrl: BASE, year: '2026', logoUrl: '', ...values },
|
||||
baseUrl: BASE,
|
||||
theme: opts.theme || {},
|
||||
})
|
||||
const out = emailBlocks.renderBlocks(seed.blocks, ctx)
|
||||
return {
|
||||
subject: seed.subject ? ctx.t(seed.subject) : '',
|
||||
text: out.text,
|
||||
html: emailBlocks.renderDocument(out.html, ctx),
|
||||
rows: out.html,
|
||||
missing: [...ctx.missing],
|
||||
}
|
||||
}
|
||||
|
||||
// ── The acceptance criterion: byte-comparable bodies ────────────────────────
|
||||
|
||||
test('password reset renders byte-identically to the literal it replaced', () => {
|
||||
const r = render('auth.password-reset', {
|
||||
resetUrl: 'https://shard.example.com/reset/tok',
|
||||
username: 'Darrow',
|
||||
forWhom: ' for the account “Darrow”',
|
||||
})
|
||||
assert.equal(r.subject, `Reset your ${SITE} password`)
|
||||
assert.equal(
|
||||
r.text,
|
||||
`We received a request to reset the password for the account “Darrow” at ${SITE}.\n\n` +
|
||||
'Choose a new password here:\nhttps://shard.example.com/reset/tok\n\n' +
|
||||
"This link is single-use and expires in about an hour. If you didn't request " +
|
||||
"this, you can safely ignore this email — your password won't change.",
|
||||
)
|
||||
})
|
||||
|
||||
test('password reset with no username keeps the other branch byte-identical too', () => {
|
||||
// The ternary lives at the call site and reaches the template as a value, so
|
||||
// BOTH of its branches have to survive the move — the empty one is the branch a
|
||||
// template language with a conditional would most likely get wrong.
|
||||
const r = render('auth.password-reset', { resetUrl: 'https://x.test/r', forWhom: '' })
|
||||
assert.match(r.text, /^We received a request to reset the password at Runic Gateway\.\n\n/)
|
||||
})
|
||||
|
||||
test('invite renders byte-identically', () => {
|
||||
const r = render('auth.invite', {
|
||||
acceptUrl: 'https://shard.example.com/invite/tok',
|
||||
roleLabel: ' as moderator',
|
||||
invitedBy: ' by Aldric',
|
||||
})
|
||||
assert.equal(r.subject, `Your ${SITE} invitation`)
|
||||
assert.equal(
|
||||
r.text,
|
||||
`You have been invited by Aldric to join ${SITE} as moderator.\n\n` +
|
||||
'Accept your invitation and set up your account here:\nhttps://shard.example.com/invite/tok\n\n' +
|
||||
"This link is single-use and will expire. If you weren't expecting this, you can ignore it.",
|
||||
)
|
||||
})
|
||||
|
||||
test('email verification renders byte-identically', () => {
|
||||
const r = render('auth.email-verify', {
|
||||
verifyUrl: 'https://shard.example.com/verify/tok',
|
||||
forWhom: ' “Darrow”',
|
||||
})
|
||||
assert.equal(r.subject, `Confirm your email address for ${SITE}`)
|
||||
assert.equal(
|
||||
r.text,
|
||||
`The ${SITE} account “Darrow” asked to use this address for contact and account recovery.\n\n` +
|
||||
'Confirm it here:\nhttps://shard.example.com/verify/tok\n\n' +
|
||||
'This link is single-use and expires in about a day. Until it is used, nothing changes — ' +
|
||||
'the account keeps whatever address it had.\n\n' +
|
||||
'If you did not ask for this, you can ignore this email. Someone may have mistyped their ' +
|
||||
'own address; no account of yours is affected and this link grants no access to anything.',
|
||||
)
|
||||
})
|
||||
|
||||
test('contact message renders byte-identically, both fallbacks included', () => {
|
||||
const named = render('admin.contact-message', {
|
||||
fromLabel: 'Ann',
|
||||
fromName: 'Ann',
|
||||
fromEmail: 'ann@player.com',
|
||||
message: 'Is the shard open?',
|
||||
})
|
||||
assert.equal(named.subject, `${SITE} contact from Ann`)
|
||||
assert.equal(named.text, 'From: Ann <ann@player.com>\n\nIs the shard open?')
|
||||
|
||||
// The two different fallbacks for one missing name are inherited from the
|
||||
// literal and are asserted so a later tidy-up is a deliberate change.
|
||||
const anon = render('admin.contact-message', {
|
||||
fromLabel: 'a visitor',
|
||||
fromName: 'unknown',
|
||||
fromEmail: 'no email',
|
||||
message: 'hi',
|
||||
})
|
||||
assert.equal(anon.subject, `${SITE} contact from a visitor`)
|
||||
assert.equal(anon.text, 'From: unknown <no email>\n\nhi')
|
||||
})
|
||||
|
||||
test('delivery test renders byte-identically', () => {
|
||||
const r = render('admin.test', { transport: 'smtp' })
|
||||
assert.equal(r.subject, `${SITE} email test`)
|
||||
assert.equal(r.text, 'This is a test message confirming smtp email delivery is working.')
|
||||
})
|
||||
|
||||
// ── The registries are siblings, not one namespace ──────────────────────────
|
||||
|
||||
test('an email block is not a page block, and a page block is not an email block', () => {
|
||||
const pageBlocks = require('../src/blocks')
|
||||
const asPage = pageBlocks.validateBlocks([{ id: 'a', type: 'email.heading', props: { level: 'h1', text: 'x' } }])
|
||||
assert.equal(asPage.valid, false)
|
||||
assert.match(asPage.errors.join(' '), /not a registered block type \(email\.heading\)/)
|
||||
|
||||
const asEmail = emailBlocks.validateEmailBlocks([{ id: 'a', type: 'heading', props: { level: 'h1', text: 'x' } }])
|
||||
assert.equal(asEmail.valid, false)
|
||||
assert.match(asEmail.errors.join(' '), /not a registered block type \(heading\)/)
|
||||
})
|
||||
|
||||
test('the shared walk enforces the same envelope for both families', () => {
|
||||
const r = emailBlocks.validateEmailBlocks([
|
||||
{ id: 'a', type: 'email.text', props: { text: 'one' }, smuggled: 1 },
|
||||
{ id: 'a', type: 'email.text', props: { text: 'two' } },
|
||||
])
|
||||
assert.equal(r.valid, false)
|
||||
assert.match(r.errors.join(' '), /smuggled is not an allowed top-level key/)
|
||||
assert.match(r.errors.join(' '), /duplicates another block id/)
|
||||
})
|
||||
|
||||
test('a block registered without both renderers is refused at registration', () => {
|
||||
const { registerEmailBlock } = require('../src/emailBlocks/registry')
|
||||
assert.throws(
|
||||
() => registerEmailBlock({ type: 'email.broken', toHtml: () => '' }),
|
||||
/needs both toHtml and toText/,
|
||||
)
|
||||
assert.throws(
|
||||
() => registerEmailBlock({ type: 'notEmail', toHtml: () => '', toText: () => '' }),
|
||||
/must be namespaced "email\."/,
|
||||
)
|
||||
})
|
||||
|
||||
// ── Interpolation and the security posture (§4.6.2) ─────────────────────────
|
||||
|
||||
test('an interpolated variable containing markup renders escaped in HTML and raw in text', () => {
|
||||
const ctx = emailBlocks.buildContext({ values: { message: '<script>alert(1)</script>' }, baseUrl: BASE })
|
||||
const block = { id: 'm', type: 'email.text', props: { text: '{{message}}' } }
|
||||
const out = emailBlocks.renderBlocks([block], ctx)
|
||||
assert.match(out.html, /<script>alert\(1\)<\/script>/)
|
||||
assert.equal(out.html.includes('<script>'), false)
|
||||
// The text part has no markup to escape into; `<` in an inbox is the bug.
|
||||
assert.equal(out.text, '<script>alert(1)</script>')
|
||||
})
|
||||
|
||||
test('a variable carrying a javascript: url never becomes an href', () => {
|
||||
const ctx = emailBlocks.buildContext({ values: { link: 'javascript:alert(1)' }, baseUrl: BASE })
|
||||
const block = { id: 'b', type: 'email.button', props: { label: 'Press me', url: '{{link}}' } }
|
||||
const out = emailBlocks.renderBlocks([block], ctx)
|
||||
assert.equal(out.html.includes('href'), false)
|
||||
assert.match(out.html, /Press me/) // inert, but not silently vanished
|
||||
})
|
||||
|
||||
test('a literal unsafe url is refused at save, and a tokened one is allowed through', () => {
|
||||
const bad = emailBlocks.validateEmailBlocks([
|
||||
{ id: 'b', type: 'email.button', props: { label: 'x', url: 'javascript:alert(1)' } },
|
||||
])
|
||||
assert.equal(bad.valid, false)
|
||||
const tokened = emailBlocks.validateEmailBlocks([
|
||||
{ id: 'b', type: 'email.button', props: { label: 'x', url: '{{resetUrl}}' } },
|
||||
])
|
||||
assert.equal(tokened.valid, true)
|
||||
})
|
||||
|
||||
test('the token grammar is names only — an expression is not a token', () => {
|
||||
assert.deepEqual(emailBlocks.scanTokens('{{ user }} and {{other}}'), ['user', 'other'])
|
||||
assert.deepEqual(emailBlocks.scanTokens('{{ user.email }}'), [])
|
||||
const ctx = emailBlocks.buildContext({ values: { user: { email: 'a@b.c' } }, baseUrl: BASE })
|
||||
assert.equal(ctx.t('{{ user.email }}'), '{{ user.email }}')
|
||||
})
|
||||
|
||||
// ── "Nothing in, nothing out" — the stand-in for a conditional ──────────────
|
||||
|
||||
test('a block whose only content is an absent variable disappears from BOTH parts', () => {
|
||||
const r = render('notify.digest', {
|
||||
periodLabel: 'your daily summary',
|
||||
intro: 'Here is what happened.',
|
||||
items: [{ heading: 'A thread', url: '/teams/1' }],
|
||||
// moreNote, scopeUrl and unsubscribeUrl all absent
|
||||
})
|
||||
assert.equal(r.text.includes('undefined'), false)
|
||||
assert.equal(r.text.includes('Unsubscribe'), false)
|
||||
assert.equal(r.rows.includes('Unsubscribe'), false)
|
||||
assert.equal(r.text, 'Here is what happened.\n\nA thread\n https://shard.example.com/teams/1')
|
||||
})
|
||||
|
||||
test('the divider contributes to the HTML and nothing to the text', () => {
|
||||
const ctx = emailBlocks.buildContext({ values: {}, baseUrl: BASE })
|
||||
const out = emailBlocks.renderBlocks(
|
||||
[
|
||||
{ id: 'a', type: 'email.text', props: { text: 'one' } },
|
||||
{ id: 'r', type: 'email.divider', props: {} },
|
||||
{ id: 'b', type: 'email.text', props: { text: 'two' } },
|
||||
],
|
||||
ctx,
|
||||
)
|
||||
assert.equal(out.text, 'one\n\ntwo') // no dashes, and no doubled blank line
|
||||
assert.match(out.html, /background:#dfe4ea/)
|
||||
})
|
||||
|
||||
test('an absent variable is reported rather than rendered as the word undefined', () => {
|
||||
const r = render('auth.password-reset', { resetUrl: 'https://x.test/r' })
|
||||
assert.equal(r.text.includes('undefined'), false)
|
||||
assert.deepEqual(r.missing.sort(), ['forWhom'])
|
||||
})
|
||||
|
||||
// ── The item list ───────────────────────────────────────────────────────────
|
||||
|
||||
test('itemList renders the shape teamNotify already builds, and survives a bad one', () => {
|
||||
const ctx = emailBlocks.buildContext({
|
||||
values: {
|
||||
items: [
|
||||
{ heading: 'First', excerpt: 'a line', url: '/teams/1?thread=9' },
|
||||
'Second',
|
||||
{ excerpt: 'no heading' }, // dropped: an item with nothing to name
|
||||
null,
|
||||
],
|
||||
},
|
||||
baseUrl: BASE,
|
||||
})
|
||||
const out = emailBlocks.renderBlocks([{ id: 'l', type: 'email.itemList', props: { variable: 'items' } }], ctx)
|
||||
assert.equal(out.text, 'First\n a line\n https://shard.example.com/teams/1?thread=9\n\nSecond')
|
||||
assert.match(out.html, /First/)
|
||||
})
|
||||
|
||||
test('an empty list renders emptyText, or nothing at all when there is none', () => {
|
||||
const ctx = emailBlocks.buildContext({ values: { items: [] }, baseUrl: BASE })
|
||||
const withText = emailBlocks.renderBlocks(
|
||||
[{ id: 'l', type: 'email.itemList', props: { variable: 'items', emptyText: 'Nothing new.' } }],
|
||||
ctx,
|
||||
)
|
||||
assert.equal(withText.text, 'Nothing new.')
|
||||
const without = emailBlocks.renderBlocks([{ id: 'l', type: 'email.itemList', props: { variable: 'items' } }], ctx)
|
||||
assert.equal(without.text, '')
|
||||
assert.equal(without.html, '')
|
||||
})
|
||||
|
||||
// ── Branding is data (§4.6.1 property 2) ────────────────────────────────────
|
||||
|
||||
test('every shipped template validates against the email registry', () => {
|
||||
for (const seed of SEEDS) {
|
||||
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
|
||||
assert.equal(valid, true, `${seed.key}: ${errors.join('; ')}`)
|
||||
assert.equal(typeof seed.seedVersion, 'number')
|
||||
assert.equal(['email', 'inapp'].includes(seed.channel), true)
|
||||
}
|
||||
// The five transactional bodies the system itself depends on are protected.
|
||||
const guarded = SEEDS.filter((s) => s.protected).map((s) => s.key)
|
||||
assert.deepEqual(guarded, [
|
||||
'auth.password-reset',
|
||||
'auth.invite',
|
||||
'auth.email-verify',
|
||||
'admin.contact-message',
|
||||
'admin.test',
|
||||
])
|
||||
})
|
||||
|
||||
test('an invalid shipped template is refused rather than stored', async () => {
|
||||
const seeded = []
|
||||
templatesDb.seedOne = async (t) => {
|
||||
seeded.push(t.key)
|
||||
return 'inserted'
|
||||
}
|
||||
templatesDb.staleCustomized = async () => []
|
||||
const original = SEEDS[0].blocks
|
||||
try {
|
||||
SEEDS[0].blocks = [{ id: 'x', type: 'email.nope', props: {} }]
|
||||
const r = await templates.seedTemplates()
|
||||
assert.equal(r.invalid, 1)
|
||||
assert.equal(seeded.includes(SEEDS[0].key), false)
|
||||
} finally {
|
||||
SEEDS[0].blocks = original
|
||||
}
|
||||
})
|
||||
|
||||
test('no seeded template contains a hex colour or a hostname', () => {
|
||||
for (const seed of SEEDS) {
|
||||
const json = JSON.stringify(seed.blocks) + String(seed.subject || '')
|
||||
assert.equal(/#[0-9a-fA-F]{6}\b/.test(json), false, `${seed.key} contains a hex colour`)
|
||||
assert.equal(/https?:\/\//.test(json), false, `${seed.key} contains a literal URL`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the accent comes from the resolved theme, and its text colour is readable over it', () => {
|
||||
const light = emailBlocks.palette({ accent: '#f4d35e' })
|
||||
assert.equal(light.accent, '#f4d35e')
|
||||
assert.equal(light.onAccent, '#151a20')
|
||||
const dark = emailBlocks.palette({ accent: '#2b3a55' })
|
||||
assert.equal(dark.onAccent, '#ffffff')
|
||||
// A stored value that is not a colour degrades to the shipped accent rather
|
||||
// than reaching an inline style — the forgiving-on-read posture.
|
||||
assert.equal(emailBlocks.palette({ accent: 'red; }' }).accent, '#7f99bd')
|
||||
})
|
||||
|
||||
test('a relative image url is absolutized, and an unresolvable one is dropped', () => {
|
||||
const withBase = emailBlocks.buildContext({ values: { logoUrl: '/uploads/logo.png' }, baseUrl: BASE })
|
||||
const block = { id: 'i', type: 'email.image', props: { url: '{{logoUrl}}', alt: 'Logo' } }
|
||||
assert.match(emailBlocks.renderBlocks([block], withBase).html, /src="https:\/\/shard\.example\.com\/uploads\/logo\.png"/)
|
||||
|
||||
const noBase = emailBlocks.buildContext({ values: { logoUrl: '/uploads/logo.png' }, baseUrl: '' })
|
||||
const out = emailBlocks.renderBlocks([block], noBase)
|
||||
assert.equal(out.html, '') // no broken image in someone's inbox
|
||||
assert.equal(out.text, 'Logo') // the alt still stands in for it
|
||||
})
|
||||
|
||||
// ── The document shell ──────────────────────────────────────────────────────
|
||||
|
||||
test('the shell adds structure and no content', () => {
|
||||
const r = render('admin.test', { transport: 'smtp' })
|
||||
// Everything the text part says, the HTML says; nothing the HTML says is
|
||||
// absent from the text. A footer in one and not the other is the failure.
|
||||
assert.match(r.html, /This is a test message confirming smtp email delivery is working\./)
|
||||
assert.equal(/unsubscribe/i.test(r.html), false)
|
||||
assert.equal(/sent by/i.test(r.html), false)
|
||||
assert.match(r.html, /^<!doctype html>/)
|
||||
assert.match(r.html, /role="presentation"/)
|
||||
assert.equal(r.html.includes('<style'), false) // a <style> block is what clients strip
|
||||
})
|
||||
|
||||
// ── The variable contract ───────────────────────────────────────────────────
|
||||
|
||||
test('every seeded variable carries an example, and every token is declared', () => {
|
||||
for (const seed of SEEDS) {
|
||||
const declared = new Set([...seed.variables.map((v) => v.name), ...AMBIENT_VARIABLES.map((v) => v.name)])
|
||||
for (const v of seed.variables) {
|
||||
assert.notEqual(v.example, undefined, `${seed.key}.${v.name} has no example`)
|
||||
}
|
||||
const used = new Set()
|
||||
for (const b of seed.blocks) {
|
||||
for (const value of Object.values(b.props)) {
|
||||
if (typeof value === 'string') emailBlocks.scanTokens(value).forEach((t) => used.add(t))
|
||||
}
|
||||
}
|
||||
if (seed.subject) emailBlocks.scanTokens(seed.subject).forEach((t) => used.add(t))
|
||||
for (const name of used) {
|
||||
assert.equal(declared.has(name), true, `${seed.key} references undeclared {{${name}}}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('variablesFor answers from the seed for a template with no trigger, plus the ambient set', () => {
|
||||
const names = templates.variablesFor({ seed_key: 'auth.invite' }).map((v) => v.name)
|
||||
assert.deepEqual(names, ['acceptUrl', 'roleLabel', 'invitedBy', 'siteName', 'siteUrl', 'logoUrl', 'year'])
|
||||
// A template that is neither seeded nor tied to a trigger still gets the brand
|
||||
// values, because those come from the deployment rather than from the message.
|
||||
assert.deepEqual(templates.variablesFor({}).map((v) => v.name), ['siteName', 'siteUrl', 'logoUrl', 'year'])
|
||||
})
|
||||
|
||||
// ── The seeder and the render entrypoint ────────────────────────────────────
|
||||
|
||||
test('the shipped default is used when the row is missing, and when it is unusable', async () => {
|
||||
settings.getInstanceName = async () => SITE
|
||||
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
|
||||
|
||||
templatesDb.getByKey = async () => null
|
||||
const missing = await templates.renderByKey('admin.test', { transport: 'smtp' })
|
||||
assert.equal(missing.text, 'This is a test message confirming smtp email delivery is working.')
|
||||
|
||||
// A row whose blocks would not parse hydrates to [] (engagementTemplates.db.js)
|
||||
// and must fall back too — this is the hand-edited-row case, and the one that
|
||||
// would otherwise send an empty password reset.
|
||||
templatesDb.getByKey = async () => ({ subject: 'wrong', blocks: [], text_body: null })
|
||||
const broken = await templates.renderByKey('admin.test', { transport: 'smtp' })
|
||||
assert.equal(broken.subject, `${SITE} email test`)
|
||||
|
||||
assert.equal(await templates.renderByKey('nope.not-a-key', {}), null)
|
||||
})
|
||||
|
||||
test('an operator edit is rendered instead of the seed, and text_body overrides the generated text', async () => {
|
||||
settings.getInstanceName = async () => SITE
|
||||
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
|
||||
templatesDb.getByKey = async () => ({
|
||||
subject: 'Edited: {{siteName}}',
|
||||
blocks: [{ id: 'a', type: 'email.text', props: { text: 'Generated body.' } }],
|
||||
text_body: 'A hand-written text part for {{siteName}}.',
|
||||
})
|
||||
const r = await templates.renderByKey('admin.test', {})
|
||||
assert.equal(r.subject, `Edited: ${SITE}`)
|
||||
assert.equal(r.text, `A hand-written text part for ${SITE}.`)
|
||||
assert.match(r.html, /Generated body\./) // the override replaces the TEXT part only
|
||||
})
|
||||
|
||||
test('a caller cannot override the deployment brand', async () => {
|
||||
settings.getInstanceName = async () => SITE
|
||||
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
|
||||
templatesDb.getByKey = async () => null
|
||||
const r = await templates.renderByKey('admin.test', { transport: 'smtp', siteName: 'Somewhere Else' })
|
||||
assert.equal(r.subject, `${SITE} email test`)
|
||||
})
|
||||
|
||||
test('the seeder inserts, then skips, and never touches a customized row', async () => {
|
||||
const calls = []
|
||||
const state = new Map() // key → { seedVersion, customized }
|
||||
templatesDb.seedOne = async (t) => {
|
||||
calls.push(t.key)
|
||||
const row = state.get(t.key)
|
||||
if (!row) {
|
||||
state.set(t.key, { seedVersion: t.seedVersion, customized: false })
|
||||
return 'inserted'
|
||||
}
|
||||
if (row.customized) return 'skipped'
|
||||
if (row.seedVersion < t.seedVersion) {
|
||||
row.seedVersion = t.seedVersion
|
||||
return 'updated'
|
||||
}
|
||||
return 'skipped'
|
||||
}
|
||||
templatesDb.staleCustomized = async () => []
|
||||
|
||||
const first = await templates.seedTemplates()
|
||||
assert.equal(first.inserted, SEEDS.length)
|
||||
assert.equal(calls.length, SEEDS.length)
|
||||
|
||||
const second = await templates.seedTemplates()
|
||||
assert.equal(second.skipped, SEEDS.length)
|
||||
assert.equal(second.inserted, 0) // re-running the seeder is a no-op
|
||||
|
||||
// A version bump reaches an untouched row and stops at a customized one.
|
||||
state.get('auth.invite').seedVersion = 0
|
||||
state.get('auth.password-reset').seedVersion = 0
|
||||
state.get('auth.password-reset').customized = true
|
||||
const third = await templates.seedTemplates()
|
||||
assert.equal(third.updated, 1)
|
||||
assert.equal(state.get('auth.password-reset').seedVersion, 0) // the operator's row, untouched
|
||||
})
|
||||
|
||||
test('a seed that throws does not stop the boot or the other seeds', async () => {
|
||||
let n = 0
|
||||
templatesDb.seedOne = async () => {
|
||||
n += 1
|
||||
if (n === 2) throw new Error('deadlock')
|
||||
return 'inserted'
|
||||
}
|
||||
templatesDb.staleCustomized = async () => []
|
||||
const r = await templates.seedTemplates()
|
||||
assert.equal(r.inserted, SEEDS.length - 1)
|
||||
})
|
||||
@@ -16,6 +16,7 @@ const assert = require('node:assert/strict')
|
||||
const nodemailer = require('nodemailer')
|
||||
const emailConfig = require('../src/model/emailConfig/emailConfig.model')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const templatesDb = require('../src/model/engagement/engagementTemplates.db')
|
||||
const mailer = require('../src/utils/mailer')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
@@ -41,6 +42,14 @@ beforeEach(() => {
|
||||
transportCfg = null
|
||||
emailConfig.recordStatus = async () => {}
|
||||
settings.get = async () => 'contact@example.com'
|
||||
// Engagement Phase 5a: every body now comes from `engagement_templates`, so a
|
||||
// send reaches three more model functions than it used to. Stubbed here for the
|
||||
// reason everything else in this file is — the suite must never touch a database
|
||||
// — and `getByKey` answering null exercises the shipped-seed fallback, which is
|
||||
// exactly the state a fresh deployment is in before its first seed runs.
|
||||
settings.getInstanceName = async () => 'UOMysticmoon'
|
||||
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
|
||||
templatesDb.getByKey = async () => null
|
||||
nodemailer.createTransport = (cfg) => {
|
||||
transportCfg = cfg
|
||||
return { sendMail: async (opts) => { sent = opts; return { messageId: '1' } } }
|
||||
|
||||
Reference in New Issue
Block a user