feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s

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:
2026-08-29 13:07:39 -05:00
parent 1d7961e7a2
commit 12ff201ed5
22 changed files with 2222 additions and 154 deletions

View File

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

View File

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

View File

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

View File

@@ -4,16 +4,21 @@
// 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)
}
function sanitizeOne(block) {
const def = getBlock(block.type)
/**
* 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
let props = block.props && typeof block.props === 'object' ? { ...block.props } : {}
@@ -42,6 +47,15 @@ function sanitizeOne(block) {
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 }

View File

@@ -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,37 +26,28 @@ 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) {
// 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) {
// 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)) {
@@ -56,11 +55,11 @@ function checkId(block, path, seenIds, errors) {
} 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) {
// 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 {
@@ -69,10 +68,10 @@ function checkPropSchema(def, props, path, errors) {
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) {
// 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
@@ -84,20 +83,20 @@ function checkNesting(def, props, path, seenIds, errors, nested) {
errors.push(`${path}.props.${slot} must be an array of blocks`)
continue
}
if (sub.length > MAX_SUBBLOCKS) {
errors.push(`${path}.props.${slot} may not exceed ${MAX_SUBBLOCKS} blocks`)
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 }) {
function validateBlock(block, path, seenIds, errors, { nested }) {
if (block === null || typeof block !== 'object' || Array.isArray(block)) {
errors.push(`${path} must be an object`)
return
@@ -118,7 +117,7 @@ function validateBlock(block, path, seenIds, errors, { nested }) {
}
// type — must resolve to a registered block.
const def = typeof block.type === 'string' ? getBlock(block.type) : null
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
@@ -126,6 +125,30 @@ function validateBlock(block, path, seenIds, errors, { nested }) {
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 }
}
}
module.exports = { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }
// The page-registry binding — the export every existing caller already uses.
const validateBlocks = makeValidateBlocks(getBlock)
module.exports = { validateBlocks, makeValidateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }

View 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,
}

View 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `&amp;` 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 }

View 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,
}

View 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,
}

View 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
},
})

View 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};">&nbsp;</div>` +
'</td></tr>'
)
},
toText() {
return ''
},
})

View 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()
},
})

View 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)
},
})

View 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')
},
})

View 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, '')
},
})

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

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

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

View File

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

View File

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

View 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, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/)
assert.equal(out.html.includes('<script>'), false)
// The text part has no markup to escape into; `&lt;` 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)
})

View File

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