// ── 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 }