fix(engagement): a trigger-bound template may reference the unsubscribe link
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

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>
This commit is contained in:
2026-09-01 01:07:16 -05:00
parent cfd1cb3c3c
commit 0a9149a04f
2 changed files with 28 additions and 2 deletions

View File

@@ -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 }

View File

@@ -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)