// ── 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} 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], } } /** * The template `key` should actually render through, or null. * * Extracted from `renderByKey` in Phase 7 rather than duplicated into the in-app * channel: the fallback chain below is a policy about what this deployment sends * when its own table is in a bad state, and a second channel resolving templates * by its own rules would be a second answer to that. `renderInappByKey` takes the * same rows, the same seeds and the same three refusals. * * @returns {Promise<{subject: string|null, blocks: object[], text_body: string|null}|null>} */ async function resolveTemplate(key) { let template = null try { template = await templatesDb.getByKey(key) } catch (err) { log.warn('template read failed; using the shipped default', { key, message: err.message }) } // Three ways a row is not the thing to send, and they are one branch on purpose: // whether the row is absent, structurally unusable, or deliberately unpublished, // the answer is the shipped default rather than a failed message. // // **The `status` arm is the one with teeth** (Phase 5b, decision 3). `status` // has existed since 5a and nothing read it, so an operator who saved a template // as a draft kept mailing it — the editor offered a working state that did not // work. A draft is now exactly what the word means: not what goes out. It falls // back rather than refusing, for the same reason the other two arms do — no // state of this table may stop a password reset. let unusable = null if (!template) unusable = null else if (!Array.isArray(template.blocks) || template.blocks.length === 0) unusable = 'unusable' else if (template.status !== 'published') unusable = 'unpublished' if (!template || unusable) { const seed = seedByKey(key) if (!seed) return null if (unusable === 'unusable') log.warn('stored template is unusable; using the shipped default', { key }) if (unusable === 'unpublished') log.warn('stored template is a draft; using the shipped default', { key }) template = { subject: seed.subject, blocks: seed.blocks, text_body: null } } return template } /** * Render the template stored under `key`, falling back to its shipped default. * @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>} * null when `key` names no usable row AND no seed — which now includes a * duplicated (seedless) template still in draft. */ async function renderByKey(key, values = {}) { const resolved = await ambient() const template = await resolveTemplate(key) if (!template) return null return renderTemplate(template, values, resolved) } // ── The in-app projection (Phase 7) ──────────────────────────────────────── // // `user_notifications` has three columns — title, body, url — where email has a // subject and a document, so the in-app channel needs the template rendered into // those three rather than into a mail. **The mapping is by block ROLE**, and it // is here rather than in the channel because it is a statement about what the // block registry means, not about how a row gets written: // // - the first `email.heading` → `title` (a heading IS the item's headline) // - the first `email.button` → `url` (a button IS the item's one action) // - everything else, as TEXT → `body` // // **Text, not the email HTML, and that is the load-bearing choice.** The block // renderer's HTML is built for mail clients: table rows, inline hex colours, a // light-only palette declared with `color-scheme`. Dropped into a page that // follows the viewer's theme it renders as a pale card floating in a dark one. // `toText` is the same content with none of that, and it is the part the block // contract already promises every block can produce. // // The three refusals a mail can afford and an inbox row cannot are handled here // too: a title is NOT NULL, so an empty one falls back to the projected `title` // and then to the trigger id; and a url that is not site-relative is dropped // rather than stored, because the column's whole contract is that a template // cannot aim a signed-in user's click off-site. const HEADING = 'email.heading' const BUTTON = 'email.button' // user_notifications.title / .url. Truncated rather than refused: a long title is // a cosmetic problem and a dropped notification is not. const MAX_TITLE = 300 const MAX_URL = 500 // The same character class `pageUrlTemplate` and the engine's `url` variables // use (registries.js, engagementEmit.js). Duplicated as a literal rather than // imported from `engagementEmit`, which would be a cycle through the engine. const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/ /** * Site-relative form of `raw`, or null. * * An absolute url on this deployment's own base is accepted and reduced — a * template that writes `{{siteUrl}}/guilds/4` is saying the same thing as * `/guilds/4`, and refusing it would make the ambient `siteUrl` variable a trap * in the one channel where the link never leaves the site. */ function relativeUrl(raw, base) { const value = String(raw || '').trim() if (!value) return null const stripped = base && value.startsWith(`${base}/`) ? value.slice(base.length) : value if (!RELATIVE_URL.test(stripped)) return null return stripped.slice(0, MAX_URL) } /** * Render one template into an inbox item. * * @returns {Promise<{title: string, body: string|null, url: string|null, missing: string[]}|null>} * null when `key` names no usable row and no seed — the caller reports a * terminal failure, exactly as the email channel does. */ async function renderInappByKey(key, values = {}) { const resolved = await ambient() const template = await resolveTemplate(key) if (!template) return null const merged = { ...values, ...resolved.values } const missing = new Set() const ctx = emailBlocks.buildContext({ values: merged, theme: resolved.theme, baseUrl: resolved.baseUrl, missing, }) const blocks = Array.isArray(template.blocks) ? template.blocks : [] const visible = blocks.filter((b) => b && b.visible !== false) const heading = visible.find((b) => b.type === HEADING) const button = visible.find((b) => b.type === BUTTON) // Only the FIRST of each is consumed; a second heading or button is ordinary // body content, which is what an operator who added one meant. const rest = visible.filter((b) => b !== heading && b !== button) const headingText = heading ? ctx.t((heading.props || {}).text || '').trim() : '' const title = (headingText || String(merged.title || '').trim() || key).slice(0, MAX_TITLE) const url = button ? relativeUrl(ctx.t((button.props || {}).url || ''), resolved.baseUrl) : null const body = emailBlocks.renderBlocks(rest, ctx).text.trim() return { title, body: body || null, url, missing: [...missing] } } /** * 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) } } /** * The shape of a template key, defined HERE rather than in the templates model * because two unrelated callers need it and only one of them should own it: * `engagementTemplates.model` checks it when a duplicate names a new key, and * `engagementRules.model` checks it when a rule points at one. Phase 4a had its * own pattern with no dot in it, which could not match any key this system * actually uses; one definition is what stops that recurring. */ const KEY_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/ const MAX_KEY = 96 module.exports = { ambient, variablesFor, renderTemplate, resolveTemplate, renderByKey, renderInappByKey, relativeUrl, seedTemplates, baseUrl, KEY_RE, MAX_KEY, }