feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
Every subject and body moves out of `mailer.js` into `engagement_templates` rows an operator can edit. A relocation, not a regression: nothing that sends mail today starts depending on an operator authoring something first. - `email.*` block family in its own registry, sharing the page family's envelope walk and validate-then-sanitize order by binding rather than by copy. - A server-side renderer producing both parts of a multipart message; the text part is byte-identical to the literals this commit deletes. - Nine seeded templates, six of them wired now; the seeder's `customized = 0` guard lives in the UPDATE's own WHERE. - `renderByKey` falls back to the shipped seed when a row is missing or unusable, so no failure of the table can stop a password reset. Also fixes `check:hosts` reading the template key `auth.email-verify` as the hostname `auth.email`. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
285
server/src/engagement/templateSeeds.js
Normal file
285
server/src/engagement/templateSeeds.js
Normal file
@@ -0,0 +1,285 @@
|
||||
// ── The shipped template set (§4.6.1) ──────────────────────────────────────
|
||||
//
|
||||
// "A fresh deployment mails correctly before anyone opens the editor." Every body
|
||||
// that used to be a template literal inside `utils/mailer.js` is a row here, so
|
||||
// Phase 5 is a RELOCATION rather than a regression: nothing that sends mail today
|
||||
// starts depending on an operator authoring something first.
|
||||
//
|
||||
// **Nine seeds, six of them wired in this phase.** The five transactional bodies
|
||||
// plus `auth.email-verify` (which §4.6.1 lists as "new — Phase 9" and which Phase
|
||||
// 1b in fact already shipped) are rendered by `mailer` from this moment. The three
|
||||
// notification seeds are seeded but not yet rendered by anything: `notify.digest`
|
||||
// and `notify.team-post` belong to `teamNotify`/`teamDigestWorker`, which Phase 6
|
||||
// rewrites onto the engine, and `inapp.event` to the channel Phase 7 builds.
|
||||
// Settled with the org lead: seed all nine now so those phases open something
|
||||
// rather than shipping seeds of their own — a seeder bump is the mechanism of last
|
||||
// resort (property 3 below), not a per-phase routine.
|
||||
//
|
||||
// **`seedVersion` is the whole "improve a default without stealing an operator's
|
||||
// work" mechanism.** Bump it when a body changes; the seeder updates rows where
|
||||
// `customized = 0` and skips rows where it is 1. Do NOT bump it for a comment.
|
||||
//
|
||||
// ── Two conventions the bodies follow, both of which are visible to operators ──
|
||||
//
|
||||
// **1. Presentational fragments are variables, because templates have no logic.**
|
||||
// `mailer` used to build ` for the account “Darrow”` with a ternary. A template
|
||||
// cannot, by design (interpolate.js: no conditionals). So the ternary stays at the
|
||||
// call site and its RESULT arrives as a variable — `forWhom` — whose `example`
|
||||
// shows exactly what it produces, leading space and quotes included. That is the
|
||||
// price of a logic-free template language, and it is paid here rather than by
|
||||
// giving operator-authored data a conditional to get wrong.
|
||||
//
|
||||
// **2. Ambient brand variables are supplied by the renderer, not by the caller.**
|
||||
// `siteName`, `siteUrl`, `logoUrl` and `year` are available to every template and
|
||||
// cannot be overridden by whatever a caller passes (`engagement/templates.js`).
|
||||
// §4.6.1 property 2: "no template contains a literal hex code or a logo URL", so
|
||||
// one prebuilt image running as any shard mails in that shard's identity.
|
||||
|
||||
// The ambient set, declared once so the editor's palette (Phase 5b) can offer them
|
||||
// on EVERY template rather than each seed having to list them.
|
||||
const AMBIENT_VARIABLES = Object.freeze([
|
||||
{ name: 'siteName', type: 'string', required: true, example: 'UOMysticmoon' },
|
||||
{ name: 'siteUrl', type: 'string', required: false, example: 'https://example.com' },
|
||||
{ name: 'logoUrl', type: 'string', required: false, example: 'https://example.com/brand/logo.png' },
|
||||
{ name: 'year', type: 'string', required: true, example: '2026' },
|
||||
])
|
||||
|
||||
// A tiny helper so the block arrays below read as content rather than as JSON.
|
||||
const text = (id, body, opts = {}) => ({
|
||||
id,
|
||||
type: 'email.text',
|
||||
props: opts.muted ? { text: body, muted: true } : { text: body },
|
||||
})
|
||||
const heading = (id, body, level = 'h1') => ({
|
||||
id,
|
||||
type: 'email.heading',
|
||||
props: { level, text: body },
|
||||
})
|
||||
const button = (id, label, url, textLead) => ({
|
||||
id,
|
||||
type: 'email.button',
|
||||
props: textLead ? { label, url, textLead } : { label, url },
|
||||
})
|
||||
const itemList = (id, variable, emptyText) => ({
|
||||
id,
|
||||
type: 'email.itemList',
|
||||
props: emptyText ? { variable, emptyText } : { variable },
|
||||
})
|
||||
const divider = (id) => ({ id, type: 'email.divider', props: {} })
|
||||
|
||||
const SEEDS = [
|
||||
// ── Transactional: protected = 1, editable but not deletable ─────────────
|
||||
{
|
||||
key: 'auth.password-reset',
|
||||
name: 'Password reset',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: 'Reset your {{siteName}} password',
|
||||
variables: [
|
||||
{ name: 'username', type: 'string', required: false, example: 'Darrow' },
|
||||
{ name: 'forWhom', type: 'string', required: false, example: ' for the account “Darrow”' },
|
||||
{ name: 'resetUrl', type: 'string', required: true, example: 'https://example.com/reset/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'We received a request to reset the password{{forWhom}} at {{siteName}}.'),
|
||||
button('cta', 'Choose a new password', '{{resetUrl}}', 'Choose a new password here:'),
|
||||
text(
|
||||
'p2',
|
||||
'This link is single-use and expires in about an hour. If you didn\'t request this, ' +
|
||||
'you can safely ignore this email — your password won\'t change.',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'auth.invite',
|
||||
name: 'Account invite',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: 'Your {{siteName}} invitation',
|
||||
variables: [
|
||||
{ name: 'acceptUrl', type: 'string', required: true, example: 'https://example.com/invite/abc123' },
|
||||
{ name: 'roleLabel', type: 'string', required: false, example: ' as moderator' },
|
||||
{ name: 'invitedBy', type: 'string', required: false, example: ' by Aldric' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'You have been invited{{invitedBy}} to join {{siteName}}{{roleLabel}}.'),
|
||||
button('cta', 'Accept your invitation', '{{acceptUrl}}', 'Accept your invitation and set up your account here:'),
|
||||
text('p2', 'This link is single-use and will expire. If you weren\'t expecting this, you can ignore it.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'auth.email-verify',
|
||||
name: 'Email address confirmation',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: 'Confirm your email address for {{siteName}}',
|
||||
variables: [
|
||||
{ name: 'username', type: 'string', required: false, example: 'Darrow' },
|
||||
{ name: 'forWhom', type: 'string', required: false, example: ' “Darrow”' },
|
||||
{ name: 'verifyUrl', type: 'string', required: true, example: 'https://example.com/verify/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'The {{siteName}} account{{forWhom}} asked to use this address for contact and account recovery.'),
|
||||
button('cta', 'Confirm this address', '{{verifyUrl}}', 'Confirm it here:'),
|
||||
text(
|
||||
'p2',
|
||||
'This link is single-use and expires in about a day. Until it is used, nothing changes — ' +
|
||||
'the account keeps whatever address it had.',
|
||||
),
|
||||
text(
|
||||
'p3',
|
||||
'If you did not ask for this, you can ignore this email. Someone may have mistyped their ' +
|
||||
'own address; no account of yours is affected and this link grants no access to anything.',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin.contact-message',
|
||||
name: 'Contact form message',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
// `fromLabel` and `fromName` are the SAME missing name with two different
|
||||
// fallbacks — 'a visitor' in the subject, 'unknown' in the body. That
|
||||
// divergence is inherited from the literal this replaces, and the template is
|
||||
// where it becomes visible and fixable: an operator who wants one word can now
|
||||
// edit the subject line instead of a source file.
|
||||
subject: '{{siteName}} contact from {{fromLabel}}',
|
||||
variables: [
|
||||
{ name: 'fromLabel', type: 'string', required: true, example: 'a visitor' },
|
||||
{ name: 'fromName', type: 'string', required: true, example: 'unknown' },
|
||||
{ name: 'fromEmail', type: 'string', required: true, example: 'ann@example.com' },
|
||||
{ name: 'message', type: 'string', required: true, example: 'Is the shard open to new players?' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'From: {{fromName}} <{{fromEmail}}>'),
|
||||
text('p2', '{{message}}'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin.test',
|
||||
name: 'Delivery test',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: '{{siteName}} email test',
|
||||
variables: [
|
||||
{ name: 'transport', type: 'string', required: true, example: 'smtp' },
|
||||
{ name: 'sentAt', type: 'string', required: false, example: '2026-08-29 18:04 UTC' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'This is a test message confirming {{transport}} email delivery is working.'),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Notification: protected = 0, replaceable ─────────────────────────────
|
||||
//
|
||||
// **`notify.event` and `notify.digest` are generic on purpose** (§4.6.1 property
|
||||
// 1): their variables are structural — `title`, `intro`, `items[]` — rather than
|
||||
// domain-specific, so a trigger from core or from any module renders through
|
||||
// them with NO authoring at all. This is what stops "add a trigger" from meaning
|
||||
// "and now write a template".
|
||||
{
|
||||
key: 'notify.event',
|
||||
name: 'Notification (single event)',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
subject: '{{title}}',
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
|
||||
{ name: 'intro', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
|
||||
{ name: 'items', type: 'list', required: false, example: [{ heading: 'The Silver Anvil', excerpt: 'Britain, Trammel (1119, 1794)' }] },
|
||||
{ name: 'actionUrl', type: 'string', required: false, example: 'https://example.com/houses' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
heading('h', '{{title}}'),
|
||||
text('intro', '{{intro}}'),
|
||||
itemList('items', 'items'),
|
||||
button('cta', 'Open {{siteName}}', '{{actionUrl}}'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'notify.digest',
|
||||
name: 'Notification digest',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
subject: '{{siteName}}: {{periodLabel}}',
|
||||
variables: [
|
||||
{ name: 'periodLabel', type: 'string', required: true, example: 'your daily summary' },
|
||||
{ name: 'intro', type: 'string', required: false, example: 'Here is what happened while you were away.' },
|
||||
{ name: 'items', type: 'list', required: false, example: [{ heading: 'New thread in Guild Hall', excerpt: 'Meeting moved to Friday', url: 'https://example.com/teams/1?thread=9' }] },
|
||||
// Precomputed for the same reason `forWhom` is: "and 3 more" needs a
|
||||
// conditional and a plural, and a template has neither.
|
||||
{ name: 'moreNote', type: 'string', required: false, example: 'and 3 more.' },
|
||||
{ name: 'scopeUrl', type: 'string', required: false, example: 'https://example.com/teams/1' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('intro', '{{intro}}'),
|
||||
itemList('items', 'items'),
|
||||
text('more', '{{moreNote}}', { muted: true }),
|
||||
button('cta', 'Open {{siteName}}', '{{scopeUrl}}'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'notify.team-post',
|
||||
name: 'Team post notification',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
subject: '{{teamName}}: {{threadTitle}}',
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil' },
|
||||
{ name: 'authorName', type: 'string', required: true, example: 'Aldric' },
|
||||
{ name: 'threadTitle', type: 'string', required: true, example: 'Meeting moved to Friday' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'We are pushing this week back a day so more people can make it.' },
|
||||
{ name: 'threadUrl', type: 'string', required: false, example: 'https://example.com/teams/1?thread=9' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', '{{authorName}} posted in {{teamName}}.'),
|
||||
heading('h', '{{threadTitle}}', 'h2'),
|
||||
text('excerpt', '{{excerpt}}', { muted: true }),
|
||||
button('cta', 'Read the thread', '{{threadUrl}}'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails for this team, use this link:'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'inapp.event',
|
||||
name: 'On-site notification',
|
||||
channel: 'inapp',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
// No subject: an inbox row has a title, and the title is a block. The column
|
||||
// is email's, and leaving it NULL is how a non-email template says so.
|
||||
subject: null,
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
|
||||
{ name: 'body', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
|
||||
{ name: 'url', type: 'string', required: false, example: 'https://example.com/houses' },
|
||||
],
|
||||
blocks: [
|
||||
heading('h', '{{title}}', 'h3'),
|
||||
text('body', '{{body}}'),
|
||||
button('cta', 'Open', '{{url}}'),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** @returns {object|null} the seed definition for `key`. */
|
||||
function seedByKey(key) {
|
||||
return SEEDS.find((s) => s.key === key) || null
|
||||
}
|
||||
|
||||
module.exports = { SEEDS, AMBIENT_VARIABLES, seedByKey }
|
||||
190
server/src/engagement/templates.js
Normal file
190
server/src/engagement/templates.js
Normal file
@@ -0,0 +1,190 @@
|
||||
// ── Templates: resolve, render, seed ───────────────────────────────────────
|
||||
//
|
||||
// The seam between a stored `engagement_templates` row and the two body parts a
|
||||
// transport sends. Everything that needs a database happens here; `emailBlocks/`
|
||||
// stays pure and synchronous below it.
|
||||
//
|
||||
// **A missing row renders the shipped default rather than nothing.** `renderByKey`
|
||||
// falls back to `templateSeeds.js` whenever the row is absent or its blocks will
|
||||
// not parse. This is not defensive padding — it is what makes it safe for
|
||||
// `mailer` to depend on the database for a password-reset body at all. Before the
|
||||
// first seed runs, after a restore that dropped the table, on a deployment whose
|
||||
// operator deleted a row by hand: the mail still goes out, in the shipped wording,
|
||||
// and the `protected` flag stops the last of those from being reachable through
|
||||
// the API. The same posture `settingsJson` and `resolveThemeTokens` take — a
|
||||
// stored value that is unusable is treated as absent, never as an error.
|
||||
|
||||
const templatesDb = require('../model/engagement/engagementTemplates.db')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const brand = require('../config/brand')
|
||||
const emailBlocks = require('../emailBlocks')
|
||||
const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('./templateSeeds')
|
||||
// The trigger registry lives with the module registries, not here — a trigger is
|
||||
// something a MODULE declares (see engagement/index.js's header).
|
||||
const { eventTrigger } = require('../modules/registries')
|
||||
const log = require('../utils/logger')('templates')
|
||||
|
||||
const baseUrl = () => (process.env.APP_BASE_URL || brand.url || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* The brand values every template may reference, resolved from the same places
|
||||
* the site's own chrome resolves them (§4.6.1 property 2).
|
||||
*
|
||||
* **They are merged OVER the caller's values, not under.** A caller supplies the
|
||||
* message; the deployment supplies its identity. Letting a caller pass its own
|
||||
* `siteName` would mean a module — or a bug — could send mail that claims to be
|
||||
* from somewhere else, which is precisely the thing a recipient cannot check.
|
||||
*
|
||||
* Never throws: a settings read that fails degrades to the BRAND_* env values, so
|
||||
* mail is branded slightly less specifically rather than not sent.
|
||||
*/
|
||||
async function ambient() {
|
||||
let name = brand.name
|
||||
let logo = brand.logo
|
||||
let theme = null
|
||||
try {
|
||||
name = await settings.getInstanceName()
|
||||
const shell = await settings.getShellBrand()
|
||||
logo = shell.logo || brand.logo
|
||||
theme = shell.theme
|
||||
} catch (err) {
|
||||
log.warn('brand resolution failed; falling back to BRAND_* env', { message: err.message })
|
||||
}
|
||||
const base = baseUrl()
|
||||
const absLogo = logo && logo.startsWith('/') ? `${base}${logo}` : logo || ''
|
||||
return {
|
||||
values: {
|
||||
siteName: name,
|
||||
siteUrl: base,
|
||||
logoUrl: absLogo,
|
||||
year: String(new Date().getUTCFullYear()),
|
||||
},
|
||||
// resolveThemeTokens speaks CSS custom properties; the renderer speaks colour
|
||||
// names. One mapping, here, rather than the renderer knowing about CSS.
|
||||
theme: { accent: theme ? theme['--accent'] : undefined },
|
||||
baseUrl: base,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which variables a template may reference — the input to Phase 5b's palette and
|
||||
* to its save-time "undeclared variable" refusal.
|
||||
*
|
||||
* Two sources, because a template has two possible origins. One tied to a trigger
|
||||
* reads §4.3's declaration, which is the authority for anything a module emits.
|
||||
* One with no trigger — every transactional seed is one; `mailer` renders them by
|
||||
* key with no rule involved — has no trigger to ask, so its shipped definition
|
||||
* carries the list. Ambient brand variables are appended to both.
|
||||
*
|
||||
* @param {{ trigger_id?: string|null, seed_key?: string|null }} template
|
||||
* @returns {Array<{name: string, type: string, required: boolean, example: unknown}>}
|
||||
*/
|
||||
function variablesFor(template) {
|
||||
const own = []
|
||||
if (template && template.trigger_id) {
|
||||
const declared = eventTrigger(template.trigger_id)
|
||||
if (declared && Array.isArray(declared.variables)) own.push(...declared.variables)
|
||||
} else if (template && template.seed_key) {
|
||||
const seed = seedByKey(template.seed_key)
|
||||
if (seed) own.push(...seed.variables)
|
||||
}
|
||||
const names = new Set(own.map((v) => v.name))
|
||||
return [...own, ...AMBIENT_VARIABLES.filter((v) => !names.has(v.name))]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one template into its two body parts.
|
||||
*
|
||||
* @param {object} template a row, or a seed definition
|
||||
* @param {Record<string, unknown>} values
|
||||
* @param {object} resolved the result of ambient()
|
||||
* @returns {{ subject: string, html: string, text: string, missing: string[] }}
|
||||
*/
|
||||
function renderTemplate(template, values, resolved) {
|
||||
const merged = { ...values, ...resolved.values }
|
||||
const missing = new Set()
|
||||
const ctx = emailBlocks.buildContext({
|
||||
values: merged,
|
||||
theme: resolved.theme,
|
||||
baseUrl: resolved.baseUrl,
|
||||
missing,
|
||||
})
|
||||
const rendered = emailBlocks.renderBlocks(template.blocks, ctx)
|
||||
const subject = template.subject ? ctx.t(template.subject) : ''
|
||||
// An authored `text_body` REPLACES the generated one (§4.4), and is interpolated
|
||||
// like any other authored string. It is a per-template override, not an addition.
|
||||
const text = template.text_body ? ctx.t(template.text_body) : rendered.text
|
||||
return {
|
||||
subject,
|
||||
html: emailBlocks.renderDocument(rendered.html, ctx, subject),
|
||||
text,
|
||||
missing: [...missing],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the template stored under `key`, falling back to its shipped default.
|
||||
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
|
||||
* null only when `key` names neither a row nor a seed.
|
||||
*/
|
||||
async function renderByKey(key, values = {}) {
|
||||
const resolved = await ambient()
|
||||
let template = null
|
||||
try {
|
||||
template = await templatesDb.getByKey(key)
|
||||
} catch (err) {
|
||||
log.warn('template read failed; using the shipped default', { key, message: err.message })
|
||||
}
|
||||
if (!template || !Array.isArray(template.blocks) || template.blocks.length === 0) {
|
||||
const seed = seedByKey(key)
|
||||
if (!seed) return null
|
||||
if (template) log.warn('stored template is unusable; using the shipped default', { key })
|
||||
template = { subject: seed.subject, blocks: seed.blocks, text_body: null }
|
||||
}
|
||||
return renderTemplate(template, values, resolved)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure every shipped template exists, and bring un-customized rows up to the
|
||||
* current seed. Idempotent: a second run reports nine skips and writes nothing.
|
||||
*
|
||||
* Never throws — it is called from `seedDefaults()` on the boot path, and a
|
||||
* template that failed to seed costs the shipped default (see the header note),
|
||||
* not the deployment.
|
||||
*/
|
||||
async function seedTemplates() {
|
||||
const counts = { inserted: 0, updated: 0, skipped: 0, invalid: 0 }
|
||||
for (const seed of SEEDS) {
|
||||
// Validated against the registry before it is stored, even though a seed is
|
||||
// code rather than input. The alternative is a shipped block array that no
|
||||
// renderer understands sitting in the table, which reads to an operator as
|
||||
// their deployment being broken; refusing to write it leaves `renderByKey`'s
|
||||
// fallback in charge and puts the reason in the boot log.
|
||||
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
|
||||
if (!valid) {
|
||||
log.error('shipped template is invalid and was not seeded', { key: seed.key, errors })
|
||||
counts.invalid += 1
|
||||
continue
|
||||
}
|
||||
try {
|
||||
counts[await templatesDb.seedOne(seed)] += 1
|
||||
} catch (err) {
|
||||
log.error('template seed failed', { key: seed.key, message: err.message })
|
||||
}
|
||||
}
|
||||
// The third arm of §4.6.1 property 3: a customized row is never touched, and the
|
||||
// fact that a better default now exists is surfaced instead of applied.
|
||||
let stale = []
|
||||
try {
|
||||
stale = await templatesDb.staleCustomized(SEEDS.map((s) => ({ key: s.key, seedVersion: s.seedVersion })))
|
||||
} catch {
|
||||
stale = []
|
||||
}
|
||||
if (stale.length) {
|
||||
log.info('customized templates have a newer shipped default', { keys: stale.map((t) => t.key) })
|
||||
}
|
||||
log.info('engagement templates ensured', counts)
|
||||
return { ...counts, stale: stale.map((t) => t.key) }
|
||||
}
|
||||
|
||||
module.exports = { ambient, variablesFor, renderTemplate, renderByKey, seedTemplates, baseUrl }
|
||||
Reference in New Issue
Block a user