Files
website/server/src/engagement/templateSeeds.js
wtclaude 0a9149a04f
Some checks failed
PR Checks / client-build (pull_request) Failing after 23s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m6s
fix(engagement): a trigger-bound template may reference the unsubscribe link
Found building module-uo's sixteen in-universe bodies, which are the first
trigger-bound templates in the system to carry an unsubscribe line of their own.

`emailChannel.deliver` computes an unsubscribe token per recipient and merges it
LAST over the projection, so `{{unsubscribeUrl}}` has always RENDERED correctly.
But `variablesFor` takes a trigger-bound template's variable list from the
trigger's declaration, and a trigger has no business declaring a fact about how
the mail was delivered — so the token was undeclared, and the save-time
undeclared-variable check would have refused the first operator who tried to EDIT
one of those bodies. Rendering right and then refusing the edit is the worst of
both.

Nothing had ever taken this path: core's generic `notify.event` declares
`unsubscribeUrl` in its own seed and is bound to no trigger, so `seedByKey`
supplied it there.

Adds DELIVERY_VARIABLES beside AMBIENT_VARIABLES — declared separately because
they apply to a different set of templates. Ambient facts are about the
deployment and reach every body; delivery facts are about the send and reach the
trigger-bound ones, which is exactly the set that is engagement mail.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 01:07:16 -05:00

321 lines
15 KiB
JavaScript

// ── 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' },
])
// The per-DELIVERY additions, which are a different thing from the ambient set
// above and are declared separately because they apply to a different set of
// templates.
//
// `emailChannel.deliver` computes an unsubscribe token per recipient and merges
// it LAST over the projection, so a body may always reference it — but a template
// bound to a TRIGGER takes its variable list from that trigger's declaration
// (`variablesFor`), and a trigger has no business declaring a fact about how the
// mail was delivered. Without these, `{{unsubscribeUrl}}` renders correctly and
// then the save-time undeclared-variable check refuses the first operator who
// tries to EDIT the body around it.
//
// Found in Phase 11b, where module-uo's sixteen in-universe bodies are the first
// trigger-bound templates in the system to carry an unsubscribe line of their
// own: core's generic `notify.event` declares it in its own seed and is bound to
// no trigger, so nothing had ever taken this path.
const DELIVERY_VARIABLES = Object.freeze([
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
])
// 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: 2,
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: 'postUrl', type: 'string', required: false, example: '/guilds/the-silver-anvil/forum/412' },
{ 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', '{{postUrl}}'),
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 2, and the bump is a correction rather than an improvement.**
// Phase 5a wrote this template before the channel that renders it existed, and
// named its variables `body` and `url` — names NOTHING supplies. A trigger
// declares domain names (`teamName`, `threadTitle`), and `projection.project`
// fills the gaps with the STRUCTURAL ones the generic seeds use: `title`,
// `intro`, `actionUrl`. So every rendering of this template would have found
// `body` and `url` missing and produced a title and nothing else. Renamed to
// the vocabulary `notify.event` uses, which is the same property stated once:
// a new trigger must render with no authoring at all.
seedVersion: 2,
// 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: 'intro', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
{ name: 'actionUrl', type: 'string', required: false, example: '/player/uo/houses' },
],
// The three blocks map onto the three columns of `user_notifications` by ROLE
// (templates.js `renderInappByKey`): the heading is the item's title, the
// button is its one action, and everything else is the body. There is no
// unsubscribe line — an inbox item has nowhere to send someone that the
// preferences screen it links to from does not already reach.
blocks: [
heading('h', '{{title}}', 'h3'),
text('intro', '{{intro}}'),
button('cta', 'Open', '{{actionUrl}}'),
],
},
]
/** @returns {object|null} the seed definition for `key`. */
function seedByKey(key) {
return SEEDS.find((s) => s.key === key) || null
}
module.exports = { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey }