From 0a9149a04f2861e9171f179f51256842b415b20b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 1 Sep 2026 01:07:16 -0500 Subject: [PATCH] fix(engagement): a trigger-bound template may reference the unsubscribe link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/engagement/templateSeeds.js | 23 ++++++++++++++++++++++- server/src/engagement/templates.js | 7 ++++++- 2 files changed, 28 insertions(+), 2 deletions(-) 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)