// ── 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], } } /** * 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() 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 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) } } /** * 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, renderByKey, seedTemplates, baseUrl, KEY_RE, MAX_KEY }