diff --git a/server/src/engagement/templateSeeds.js b/server/src/engagement/templateSeeds.js index ea44d64..d77e0f0 100644 --- a/server/src/engagement/templateSeeds.js +++ b/server/src/engagement/templateSeeds.js @@ -44,6 +44,27 @@ const AMBIENT_VARIABLES = Object.freeze([ { 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, @@ -296,4 +317,4 @@ function seedByKey(key) { return SEEDS.find((s) => s.key === key) || null } -module.exports = { SEEDS, AMBIENT_VARIABLES, seedByKey } +module.exports = { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey } diff --git a/server/src/engagement/templates.js b/server/src/engagement/templates.js index 1b01205..4964be3 100644 --- a/server/src/engagement/templates.js +++ b/server/src/engagement/templates.js @@ -18,7 +18,7 @@ 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') +const { SEEDS, AMBIENT_VARIABLES, DELIVERY_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') @@ -84,6 +84,11 @@ function variablesFor(template) { if (template && template.trigger_id) { const declared = eventTrigger(template.trigger_id) if (declared && Array.isArray(declared.variables)) own.push(...declared.variables) + // A trigger-bound body is engagement mail, and engagement mail always carries + // an unsubscribe the channel computes per recipient. A trigger declares what + // HAPPENED and has no business declaring how the mail was sent, so the + // delivery facts are added here rather than to every declaration. + own.push(...DELIVERY_VARIABLES) } else if (template && template.seed_key) { const seed = seedByKey(template.seed_key) if (seed) own.push(...seed.variables)