feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s

Phase 5a gave templates a table, a renderer and nine seeded rows; nothing could
change one. This is the screen that lets an operator change one without being able
to break the mail the system depends on — plus the two screens Q4 promised Phase 5:
Triggers (read-only, from the registries) and the Send Log, which closes G15.

The shape follows from one fact: a mail body is rendered by the SERVER, so the
preview is too, and framed rather than redrawn in React. A client-side renderer
would be a second implementation of the one artifact that matters, agreeing with
the send path on the day it was written and drifting from the first Outlook fix on.

Settled with the org lead before any code: a shipped default is edited IN PLACE
(`protected` blocks deletion and nothing else, `customized = 1` keeps the edit);
duplicate is the only way to a new template; `renderByKey` now requires
`published`; a test send is logged under a synthetic `core.admin.test-send`; and a
template a rule points at refuses deletion with a 409 naming the rules.

Three things the plan did not know, found by building it:

  - The undeclared-variable check cannot be a token scan. `email.itemList.variable`
    holds a BARE name, so a digest pointed at `itmes` would have saved clean and
    arrived empty. Blocks now declare `variables(props)`; the editor makes that
    field a select over the trigger's list variables so the typo is unavailable.
  - A duplicate that drops `seed_key` loses its variable palette, so duplicating
    `notify.event` would have been refused for the tokens it was copied with — the
    one action §4.6.2 offers, refusing itself. The copy inherits it; `customized`
    is what the seeder actually reads.
  - `validateEmailBlocks` returns `{ valid, errors }`, not an array, and the first
    version tested it with `.length` — so block validation never ran at all.

Also fixes a Phase 4a defect the live walk found, with the org lead's approval: a
rule's template key was checked against a pattern with no dot in it, so no rule
could name any template that exists — §4.6.2's whole duplicate-and-point-a-rule-at-it
workflow was unreachable. Both models now read one pattern.

Verified against the running stack: real multipart mail into a mailpit catcher
including an unsaved draft, the draft/published arms both ways through the real
mailer path, every refusal, and the end-to-end duplicate → rule → 409 walk.
Server 1428 tests green, client 324.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 18:13:57 -05:00
parent 42b40fdec2
commit 3f90070566
27 changed files with 3754 additions and 20 deletions

View File

@@ -10,6 +10,7 @@
const registry = require('./registry')
const render = require('./render')
const interpolate = require('./interpolate')
const variables = require('./variables')
// ── Block definitions (self-register on require) ───────────────────────────
require('./types/heading')
@@ -23,4 +24,5 @@ module.exports = {
...registry,
...render,
...interpolate,
...variables,
}

View File

@@ -31,8 +31,19 @@
// sanitize: (props) => props, // optional, run on save AFTER validation
// toHtml: (props, ctx) => '<tr>…', // a table ROW; see render.js for the shell
// toText: (props, ctx) => 'text', // '' means "contributes nothing"
// variables: (props) => [], // optional; see below
// }
//
// `variables` exists because of ONE block, and the exception is the reason it has
// to be declared rather than inferred. Every other block references a declared
// variable the same way a person writes it — as a `{{token}}` inside an authored
// string — so scanning the string props finds them all. `email.itemList` does not:
// its `variable` prop holds a BARE NAME (`items`), because the block iterates the
// value rather than interpolating it. A save-time check that only scanned tokens
// would pass a template pointing its one repeating block at a variable no trigger
// declares, and the failure would surface as an empty digest in someone's inbox.
// A block that reads a variable by any means other than a token says so here.
//
// `ctx` is the render context (render.js): resolved brand values, an `interp`
// that substitutes declared variables HTML-escaped, and `interpText` that does
// the same without escaping for the plain-text part.
@@ -73,6 +84,9 @@ function registerEmailBlock(def) {
if (def.sanitize != null && typeof def.sanitize !== 'function') {
throw new Error(`registerEmailBlock: ${def.type}.sanitize must be a function`)
}
if (def.variables != null && typeof def.variables !== 'function') {
throw new Error(`registerEmailBlock: ${def.type}.variables must be a function`)
}
const entry = Object.freeze({
type: def.type,
label: def.label || def.type,
@@ -81,6 +95,10 @@ function registerEmailBlock(def) {
sanitize: def.sanitize || null,
toHtml: def.toHtml,
toText: def.toText,
// Null, not a default `() => []`: `variables.js` distinguishes "this block
// declares no non-token references" from "this block was never asked", and
// only the second is worth a comment when a new block type is added.
variables: def.variables || null,
// The shared walk reads these; email has no containers, and saying so here is
// what lets `makeValidateBlocks` be the same function for both families.
container: false,

View File

@@ -56,6 +56,13 @@ registerEmailBlock({
if (empty) errors.push(empty)
return errors
},
// The one block whose variable reference is not a token (see registry.js).
// Without this the Phase 5b save check reads a template whose digest points at
// `itmes` as clean, and the mistake surfaces as an empty mail rather than as an
// error naming the variable.
variables(props) {
return typeof props.variable === 'string' && props.variable ? [props.variable] : []
},
toHtml(props, ctx) {
const items = itemsOf(ctx.values[props.variable])
if (items.length === 0) {

View File

@@ -0,0 +1,100 @@
// ── Which declared variables a template references ─────────────────────────
//
// ENGAGEMENT.md §4.6.2: "A template referencing an undeclared variable is refused
// at save, naming the variable — the editor validates, it does not blindly
// interpolate module JSON."
//
// This is the walk that makes that sentence enforceable. It is deliberately a
// SEPARATE pass from rendering: a render only discovers a bad reference when a
// value happens to be missing at that moment, which makes the failure depend on
// the event rather than on the template. Phase 5a's `renderTemplate` already
// reports `missing` for exactly that runtime case; this answers the static
// question — what does this template ask for at all — and it can therefore refuse
// a save before any mail exists.
//
// Two kinds of reference, and both have to be found or the check is theatre:
//
// - **Tokens** in every authored string: the subject, an overriding text part,
// and every string-valued prop on every block. `scanTokens` finds these.
// - **Named references** a block declares (`registry.js`'s `variables`), which
// today is `email.itemList.variable` and its bare `items`. A token scan cannot
// see these and would pass them silently.
//
// The block walk mirrors `makeValidateBlocks`' — top level plus container slots —
// rather than sharing it, because that function's job is to decide validity and
// this one's is to collect names from a structure already known to be valid. The
// email family has no containers today; the slot arm exists so that adding one
// does not quietly halve this function's coverage.
const { scanTokens } = require('./interpolate')
const { getEmailBlock } = require('./registry')
/** Every distinct token name in a string, an array of strings, or a nested plain object. */
function tokensIn(value, out) {
if (typeof value === 'string') {
for (const name of scanTokens(value)) out.add(name)
return
}
if (Array.isArray(value)) {
for (const entry of value) tokensIn(entry, out)
return
}
if (value && typeof value === 'object') {
for (const entry of Object.values(value)) tokensIn(entry, out)
}
}
function walkBlock(block, out) {
if (!block || typeof block !== 'object') return
tokensIn(block.props, out)
const def = getEmailBlock(block.type)
if (def && typeof def.variables === 'function') {
let named = []
try {
named = def.variables(block.props || {}) || []
} catch {
// A definition that throws on malformed props must not take the save path
// down with it: validation runs first and has already refused those props,
// so the only way here is a definition bug, and the right answer to that is
// to contribute no names rather than to 500 the request.
named = []
}
for (const name of named) if (typeof name === 'string' && name) out.add(name)
}
for (const slot of def?.containerSlots || []) {
const children = block.props?.[slot]
if (Array.isArray(children)) for (const child of children) walkBlock(child, out)
}
}
/**
* Every declared-variable name this template references, in no particular order.
*
* @param {{ blocks?: unknown[], subject?: string, text_body?: string|null }} template
* @returns {string[]}
*/
function referencedVariables(template) {
const out = new Set()
tokensIn(template?.subject, out)
tokensIn(template?.text_body, out)
if (Array.isArray(template?.blocks)) for (const block of template.blocks) walkBlock(block, out)
return [...out]
}
/**
* The names `referencedVariables` found that `declared` does not contain.
*
* @param {{ blocks?: unknown[], subject?: string, text_body?: string|null }} template
* @param {Array<{ name: string }>} declared what §4.3 declares for this template's
* trigger, PLUS the ambient variables every template may use — the caller
* passes `templates.variablesFor(...)`, which already merges the two.
* @returns {string[]} sorted, so the error message is stable across saves
*/
function undeclaredVariables(template, declared) {
const known = new Set((declared || []).map((v) => v && v.name).filter(Boolean))
return referencedVariables(template)
.filter((name) => !known.has(name))
.sort()
}
module.exports = { referencedVariables, undeclaredVariables }

View File

@@ -125,7 +125,8 @@ function renderTemplate(template, values, resolved) {
/**
* Render the template stored under `key`, falling back to its shipped default.
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
* null only when `key` names neither a row nor a seed.
* null when `key` names no usable row AND no seed — which now includes a
* duplicated (seedless) template still in draft.
*/
async function renderByKey(key, values = {}) {
const resolved = await ambient()
@@ -135,10 +136,26 @@ async function renderByKey(key, values = {}) {
} catch (err) {
log.warn('template read failed; using the shipped default', { key, message: err.message })
}
if (!template || !Array.isArray(template.blocks) || template.blocks.length === 0) {
// Three ways a row is not the thing to send, and they are one branch on purpose:
// whether the row is absent, structurally unusable, or deliberately unpublished,
// the answer is the shipped default rather than a failed message.
//
// **The `status` arm is the one with teeth** (Phase 5b, decision 3). `status`
// has existed since 5a and nothing read it, so an operator who saved a template
// as a draft kept mailing it — the editor offered a working state that did not
// work. A draft is now exactly what the word means: not what goes out. It falls
// back rather than refusing, for the same reason the other two arms do — no
// state of this table may stop a password reset.
let unusable = null
if (!template) unusable = null
else if (!Array.isArray(template.blocks) || template.blocks.length === 0) unusable = 'unusable'
else if (template.status !== 'published') unusable = 'unpublished'
if (!template || unusable) {
const seed = seedByKey(key)
if (!seed) return null
if (template) log.warn('stored template is unusable; using the shipped default', { key })
if (unusable === 'unusable') log.warn('stored template is unusable; using the shipped default', { key })
if (unusable === 'unpublished') log.warn('stored template is a draft; using the shipped default', { key })
template = { subject: seed.subject, blocks: seed.blocks, text_body: null }
}
return renderTemplate(template, values, resolved)
@@ -187,4 +204,15 @@ async function seedTemplates() {
return { ...counts, stale: stale.map((t) => t.key) }
}
module.exports = { ambient, variablesFor, renderTemplate, renderByKey, seedTemplates, baseUrl }
/**
* The shape of a template key, defined HERE rather than in the templates model
* because two unrelated callers need it and only one of them should own it:
* `engagementTemplates.model` checks it when a duplicate names a new key, and
* `engagementRules.model` checks it when a rule points at one. Phase 4a had its
* own pattern with no dot in it, which could not match any key this system
* actually uses; one definition is what stops that recurring.
*/
const KEY_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
const MAX_KEY = 96
module.exports = { ambient, variablesFor, renderTemplate, renderByKey, seedTemplates, baseUrl, KEY_RE, MAX_KEY }

View File

@@ -25,6 +25,7 @@ const ceilings = require('../../modules/ceilings')
const channels = require('../../engagement/channels')
const segmentExpressions = require('../../engagement/segments')
const conditions = require('../../engagement/conditions')
const templates = require('../../engagement/templates')
// A day. Longer than this and "cooldown" is really "send once", which a rule
// expresses by being disabled rather than by a decade-long interval.
@@ -77,6 +78,14 @@ async function validate(input, { existing = null } = {}) {
// KEYS are checked for shape and not for existence - a rule may legitimately
// name a template that has not been authored yet, and Phase 5's editor is where
// that becomes resolvable.
//
// **The shape check was wrong until Phase 5b, and wrong in the way that matters:**
// it required `/^[a-z0-9][a-z0-9-]{0,63}$/`, which has no dot, while every
// template key that exists is dotted (`notify.event`, `auth.password-reset`).
// Written before templates existed, it could not match one, so no rule could name
// any real template - which is precisely the workflow S4.6.2's duplicate action
// exists to serve. It now uses the templates model's own pattern, so the two
// cannot disagree about what a key is.
const templateKeys = {}
if (raw.templateKeys !== undefined && !isPlainObject(raw.templateKeys)) {
errors.push('templateKeys must be an object of { channel: templateKey }')
@@ -86,7 +95,7 @@ async function validate(input, { existing = null } = {}) {
errors.push(`templateKeys names "${channel}", which is not one of this rule's channels`)
continue
}
if (typeof key !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(key)) {
if (typeof key !== 'string' || key.length > templates.MAX_KEY || !templates.KEY_RE.test(key)) {
errors.push(`templateKeys.${channel} is not a valid template key`)
continue
}

View File

@@ -47,8 +47,22 @@ const countSentSince = async (ruleId, since) => {
return Number(row?.n || 0)
}
/** The admin send log (Phase 4b/5), newest first. */
const list = ({ triggerId = null, userId = null, ruleId = null, limit = 100, offset = 0 } = {}) => {
/**
* The trigger id a template test send is logged under (Phase 5b, decision 4).
*
* §4.6.2 asks for a test send "recorded in `engagement_sends` like any other
* message", and `trigger_id` is NOT NULL — but a transactional template has no
* trigger at all, so there was nothing honest to put there. A synthetic id costs
* no schema change and keeps the column meaning one thing: what caused this send.
*
* It is deliberately NOT a registered trigger. Nothing may point a rule at it, and
* the admin list renders it by name rather than by looking it up in a catalog it
* will never appear in.
*/
const TEST_SEND_TRIGGER = 'core.admin.test-send'
/** WHERE-clause builder shared by `list` and `count`, so the two cannot disagree. */
const filters = ({ triggerId = null, userId = null, ruleId = null, status = null } = {}) => {
const where = []
const params = []
if (triggerId) {
@@ -63,11 +77,34 @@ const list = ({ triggerId = null, userId = null, ruleId = null, limit = 100, off
where.push('rule_id = ?')
params.push(ruleId)
}
const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''
return query(
`SELECT * FROM engagement_sends ${clause} ORDER BY id DESC LIMIT ? OFFSET ?`,
[...params, limit, offset],
)
if (status) {
where.push('status = ?')
params.push(status)
}
return { clause: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
}
module.exports = { record, countSentSince, list }
/** The admin send log (Phase 5b), newest first. */
const list = (opts = {}) => {
const { clause, params } = filters(opts)
return query(`SELECT * FROM engagement_sends ${clause} ORDER BY id DESC LIMIT ? OFFSET ?`, [
...params,
opts.limit || 50,
opts.offset || 0,
])
}
/**
* How many rows match the same filters — the total the paged screen needs.
*
* Its own query rather than `SQL_CALC_FOUND_ROWS`, which MariaDB has deprecated,
* and rather than counting the page, which would report the page size as the total
* on every page but the last.
*/
const count = async (opts = {}) => {
const { clause, params } = filters(opts)
const [row] = await query(`SELECT COUNT(*) AS n FROM engagement_sends ${clause}`, params)
return Number(row?.n || 0)
}
module.exports = { record, countSentSince, list, count, TEST_SEND_TRIGGER }

View File

@@ -146,4 +146,87 @@ const staleCustomized = async (pairs) => {
return rows.map(hydrate)
}
module.exports = { list, getById, getByKey, existingKeys, seedOne, update, staleCustomized }
/**
* Insert an operator-created template. Phase 5b, and the ONLY way a row that is
* not a seed comes into being: §4.6.2 names duplicate as the creation path, so
* every template on a deployment descends from a shipped one that works.
*
* **`seed_key` is INHERITED from the source, and that is load-bearing rather than
* bookkeeping.** `templates.variablesFor()` resolves a template's variable palette
* from its trigger or, for the generic seeds that are tied to no trigger, from the
* seed. Nulling `seed_key` on a copy would leave it with only the ambient
* variables, so a duplicate of `notify.event` would fail its own save check on the
* variables it was copied with — the one action §4.6.2 offers, refusing itself.
*
* It is safe to inherit because `customized = 1` is what the seeder actually reads:
* `seedOne`'s UPDATE carries `AND customized = 0`, so it can only ever match the
* seeded row itself, never a copy. `staleCustomized` does match a copy, and should
* — "the default you duplicated has been improved" is worth telling someone.
*
* `protected` is 0 whatever the source was: protection is a statement about a row
* the system depends on by key, and nothing depends on a copy.
*/
const create = async (t, userId) => {
const res = await query(
'INSERT INTO engagement_templates ' +
'(`key`, name, trigger_id, trigger_version, channel, subject, blocks, text_body, status, ' +
' protected, seed_key, seed_version, customized, updated_by) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 1, ?)',
[
t.key,
t.name,
t.triggerId ?? null,
t.triggerVersion ?? null,
t.channel,
t.subject ?? null,
JSON.stringify(t.blocks),
t.textBody ?? null,
t.status,
t.seedKey ?? null,
t.seedVersion ?? null,
userId ?? null,
],
)
return res.insertId
}
/**
* Delete by id. `protected = 0` is in the WHERE rather than only in the model:
* the model refuses first and with a better message, but the row that must never
* disappear is the password-reset body, and a guard that only exists in a
* JavaScript branch is a guard one future caller skips.
*/
const remove = async (id) => {
const res = await query('DELETE FROM engagement_templates WHERE id = ? AND protected = 0', [id])
return res.affectedRows === 1
}
/**
* The rules that point at template key `key`, for the in-use refusal (§4.6.2's
* delete, Phase 5b decision 5 — the answer Phase 4b already gives for a segment).
*
* `template_keys` is a JSON object of channel → key, so this asks MariaDB whether
* the key appears among its VALUES. `JSON_SEARCH(..., 'one', ?)` returns a path
* or NULL and matches the whole scalar, so `notify.event` does not also match
* `notify.event.custom` the way a LIKE would.
*/
const rulesUsingKey = async (key) => {
const rows = await query(
"SELECT id, name FROM engagement_rules WHERE JSON_SEARCH(template_keys, 'one', ?) IS NOT NULL",
[key],
)
return rows
}
module.exports = {
list,
getById,
getByKey,
existingKeys,
seedOne,
create,
update,
remove,
rulesUsingKey,
staleCustomized,
}

View File

@@ -0,0 +1,412 @@
// ── Engagement templates — the save path ───────────────────────────────────
//
// ENGAGEMENT.md §4.6.2, Phase 5b. Phase 5a gave templates a table, a renderer and
// nine seeded rows; nothing could change one. This file is the boundary that lets
// an operator change one without being able to break the mail the system depends
// on, and — like `engagementRules.model.js` — it is the boundary rather than the
// screen. The editor re-implements some of these checks for the sake of a good
// inline message; that second copy is expected to drift, so this one decides.
//
// **What the org lead settled at the start of the phase, because the plan text and
// the schema said different things.** §4.6.2 introduces duplicate as "how an
// operator customizes a `protected` template safely: duplicate, edit, point the
// rule at the copy". The schema comment says the opposite and is the one that was
// built: "Editable, NOT deletable". The org lead's call is the schema's — **a
// default template is edited in place**, `customized = 1` stops the seeder from
// taking that edit back, and duplicate is how a NEW template comes into being
// rather than how an existing one is customized. So:
//
// - `protected` blocks DELETE and nothing else.
// - there is no blank-page create; `duplicate` is the only way to a new row, so
// every template on a deployment descends from a shipped one that renders.
//
// The four checks with teeth, in the order they can hurt:
//
// 1. **Undeclared variables** (§4.6.2). A token naming a variable no trigger
// declares renders as nothing, and the failure lands in a person's inbox as
// words gone missing. Refused at save, naming the variable.
// 2. **An empty text part on a published template.** Also §4.6.2, and it is
// checked by RENDERING with the declared examples rather than by inspecting
// the blocks: whether a text part exists depends on what each block's `toText`
// does with these props, which is a question only the renderer can answer.
// 3. **`key` and `channel` are immutable.** `mailer` renders by key; renaming
// `auth.password-reset` breaks password resets with no error anywhere. Changing
// a channel would leave a row whose blocks were authored for another surface.
// 4. **Blocks go through the same validate-then-sanitize gate a CMS page does**,
// against the `email.*` registry. Storing operator HTML was never on the table
// (§4.4); this is what makes that true at the write.
//
// **Dormancy, the same posture rules take.** A template pinned to a trigger no
// installed module currently declares cannot have its variables checked — the
// declaration is the only source of truth for what is legal, and it is absent.
// Refusing the save would make a module's absence corrupt the operator's ability
// to edit their own copy; passing it silently would call an unknowable thing
// clean. It saves, skips check 1, and the row is reported `dormant` so the admin
// list can say so (§7.3).
const crypto = require('crypto')
const db = require('./engagementTemplates.db')
const sendsDb = require('./engagementSends.db')
const templates = require('../../engagement/templates')
const mailer = require('../../utils/mailer')
const emailBlocks = require('../../emailBlocks')
const { SEEDS } = require('../../engagement/templateSeeds')
const registries = require('../../modules/registries')
// Both from `engagement/templates` — see there for why one definition.
const { KEY_RE, MAX_KEY } = templates
const MAX_NAME = 160
const MAX_SUBJECT = 300
const MAX_TEXT_BODY = 20_000
const STATUSES = ['draft', 'published']
/** The example values a trigger declares, as the map the renderer wants. */
function examplesFor(template) {
const values = {}
for (const variable of templates.variablesFor(template)) {
if (variable && variable.name !== undefined && variable.example !== undefined) {
values[variable.name] = variable.example
}
}
return values
}
/**
* Render a candidate template with its declared examples. Used by the save check
* and by the preview route, so that "what the preview showed" and "what the save
* judged" are the same string produced by the same call.
*/
async function renderWithExamples(template, overrides = {}) {
const resolved = await templates.ambient()
return templates.renderTemplate(template, { ...examplesFor(template), ...overrides }, resolved)
}
/**
* Whether this template's trigger is currently declared. `null` trigger_id is not
* dormant — it is a reusable template tied to no trigger, which is what every
* transactional seed is.
*/
const isDormant = (row) => Boolean(row.trigger_id) && !registries.eventTrigger(row.trigger_id)
/**
* Validate an incoming edit against `existing` (the row being changed) or, for a
* duplicate, against the row being copied.
*
* @returns {Promise<{ok: true, template: object} | {ok: false, errors: string[]}>}
*/
async function validate(input, existing) {
const errors = []
const next = {
key: existing.key,
channel: existing.channel,
name: typeof input.name === 'string' ? input.name.trim() : existing.name,
subject: input.subject === undefined ? existing.subject : input.subject,
blocks: input.blocks === undefined ? existing.blocks : input.blocks,
textBody: input.textBody === undefined ? (existing.text_body ?? null) : input.textBody,
status: input.status === undefined ? existing.status : input.status,
triggerId: input.triggerId === undefined ? (existing.trigger_id ?? null) : input.triggerId,
triggerVersion: existing.trigger_version ?? null,
}
// Check 3 — stated as a refusal rather than ignored, because a caller who sends
// a new key and gets a 200 has every reason to believe it was renamed.
if (input.key !== undefined && input.key !== existing.key) {
errors.push('key cannot be changed — duplicate the template instead')
}
if (input.channel !== undefined && input.channel !== existing.channel) {
errors.push('channel cannot be changed — duplicate the template instead')
}
if (!next.name || next.name.length > MAX_NAME) {
errors.push(`name is required and must be at most ${MAX_NAME} characters`)
}
if (next.subject != null && typeof next.subject !== 'string') {
errors.push('subject must be a string')
} else if (typeof next.subject === 'string' && next.subject.length > MAX_SUBJECT) {
errors.push(`subject must be at most ${MAX_SUBJECT} characters`)
}
if (next.textBody != null && typeof next.textBody !== 'string') {
errors.push('textBody must be a string or null')
} else if (typeof next.textBody === 'string' && next.textBody.length > MAX_TEXT_BODY) {
errors.push(`textBody must be at most ${MAX_TEXT_BODY} characters`)
}
if (!STATUSES.includes(next.status)) {
errors.push(`status must be one of: ${STATUSES.join(', ')}`)
}
// An email template's subject is not optional the way a body block is: a
// message with no Subject header is the shape spam filters were built to catch.
if (next.channel === 'email' && next.status === 'published' && !String(next.subject || '').trim()) {
errors.push('a published email template needs a subject')
}
if (next.triggerId != null && typeof next.triggerId !== 'string') {
errors.push('triggerId must be a string or null')
next.triggerId = existing.trigger_id ?? null
}
// Re-pointing at a trigger pins the version that was declared when it happened,
// which is what §4.3's versioning paragraph wants: a later declaration bump is
// then visible as a difference rather than as a silent reinterpretation.
if (next.triggerId !== (existing.trigger_id ?? null)) {
const declared = next.triggerId ? registries.eventTrigger(next.triggerId) : null
if (next.triggerId && !declared) {
errors.push(`no module declares the trigger "${next.triggerId}"`)
}
next.triggerVersion = declared ? (declared.version ?? 1) : null
}
// Check 4 — the envelope/id/schema walk, then the registry's sanitizers.
//
// `validateEmailBlocks` returns `{ valid, errors }`, NOT an array. Destructured
// here for the reason `pages.model.js` destructures it: a truthiness test on the
// returned object passes for every input, valid or not, and the failure mode is
// silent — unvalidated props reaching the renderer and the row.
const { valid, errors: blockErrors } = emailBlocks.validateEmailBlocks(next.blocks)
if (!valid) {
errors.push(...blockErrors)
} else {
next.blocks = emailBlocks.sanitizeEmailBlocks(next.blocks)
}
if (errors.length) return { ok: false, errors }
const candidate = {
key: next.key,
subject: next.subject,
blocks: next.blocks,
text_body: next.textBody,
trigger_id: next.triggerId,
seed_key: existing.seed_key ?? null,
}
// Check 1 — skipped, deliberately and only, when the trigger is dormant.
if (!isDormant(candidate)) {
const undeclared = emailBlocks.undeclaredVariables(candidate, templates.variablesFor(candidate))
if (undeclared.length) {
errors.push(
`this template uses ${undeclared.length === 1 ? 'a variable' : 'variables'} ` +
`its trigger does not declare: ${undeclared.join(', ')}`,
)
}
}
// Check 2 — by rendering, and only for a published template. A draft with an
// empty text part is a work in progress, and refusing to save one is refusing
// to let someone stop halfway.
if (!errors.length && next.status === 'published') {
try {
const rendered = await renderWithExamples(candidate)
if (!rendered.text.trim()) {
errors.push(
'a published template needs a plain-text part — every block rendered to nothing. ' +
'Add text, or write the text part yourself.',
)
}
} catch (err) {
errors.push(`this template could not be rendered: ${err.message}`)
}
}
if (errors.length) return { ok: false, errors }
return { ok: true, template: next }
}
/** Every template, annotated for the admin list. */
async function listAnnotated() {
const rows = await db.list()
const stale = new Set()
try {
const behind = await db.staleCustomized(SEEDS.map((seed) => ({ key: seed.key, seedVersion: seed.seedVersion })))
for (const row of behind) stale.add(row.id)
} catch {
// The annotation is a hint, not the list. A failure to compute it must not
// cost an operator the screen.
}
return rows.map((row) => ({
...row,
dormant: isDormant(row),
// §4.6.2: "a template pinned to an older `trigger_version` is flagged in the
// admin list". Pinned-and-behind is a different fact from dormant — the module
// is installed and has moved on — and it is the one that means the variable
// palette an operator authored against is no longer the current one.
triggerBehind: Boolean(
row.trigger_id &&
row.trigger_version != null &&
registries.eventTrigger(row.trigger_id) &&
(registries.eventTrigger(row.trigger_id).version ?? 1) > row.trigger_version,
),
seedBehind: stale.has(row.id),
}))
}
async function get(id) {
const row = await db.getById(id)
if (!row) return null
return { ...row, dormant: isDormant(row), variables: templates.variablesFor(row) }
}
/** PUT — the in-place edit of any template, seeded or not. */
async function update(id, input) {
const existing = await db.getById(id)
if (!existing) return { ok: false, errors: ['no such template'], status: 404 }
const result = await validate(input, existing)
if (!result.ok) return result
await db.update(id, result.template, input.updatedBy ?? null)
return { ok: true, template: await get(id) }
}
/**
* POST /:id/duplicate — the creation path.
*
* The copy starts as a **draft** whatever the original was. A duplicate is made
* to be changed, and a copy that arrives published is a second live template
* nobody has read yet, reachable by a rule the moment its key is typed.
*/
async function duplicate(id, input) {
const source = await db.getById(id)
if (!source) return { ok: false, errors: ['no such template'], status: 404 }
const key = typeof input.key === 'string' ? input.key.trim() : ''
if (!key || key.length > MAX_KEY || !KEY_RE.test(key)) {
return {
ok: false,
errors: [
'key must be lowercase letters, digits, dots and dashes ' +
`(for example "notify.my-event"), at most ${MAX_KEY} characters`,
],
}
}
if (await db.getByKey(key)) {
return { ok: false, errors: [`a template already uses the key "${key}"`], status: 409 }
}
// `seed_key` rides along — see `db.create` for why nulling it would make a
// duplicate of a generic template fail the variable check it was copied with.
const base = { ...source, key, protected: false }
const result = await validate({ ...input, key: undefined, status: 'draft' }, base)
if (!result.ok) return result
const created = await db.create(
{ ...result.template, seedKey: source.seed_key ?? null, seedVersion: source.seed_version ?? null },
input.updatedBy ?? null,
)
return { ok: true, template: await get(created) }
}
/**
* DELETE — refused for a protected template, and refused with a 409 for one a
* rule points at. The second is Phase 4b's answer for a segment in use, for the
* same reason: the alternative is a rule that silently stops producing mail.
*/
async function remove(id) {
const existing = await db.getById(id)
if (!existing) return { ok: false, errors: ['no such template'], status: 404 }
if (existing.protected) {
return {
ok: false,
errors: ['this template is part of the system and cannot be deleted. Edit it, or duplicate it.'],
status: 409,
}
}
const used = await db.rulesUsingKey(existing.key)
if (used.length) {
return {
ok: false,
errors: [
`${used.length === 1 ? 'a rule uses' : `${used.length} rules use`} this template: ` +
`${used.map((r) => r.name).join(', ')}. Point ${used.length === 1 ? 'it' : 'them'} elsewhere first.`,
],
status: 409,
}
}
if (!(await db.remove(id))) return { ok: false, errors: ['no such template'], status: 404 }
return { ok: true }
}
/**
* Render a candidate template for the editor's preview.
*
* **It renders the DRAFT, not the row**, so the preview answers "what would this
* send" rather than "what did I last save". `id` supplies everything the draft
* does not — channel, seed_key, and the trigger that decides the variable palette.
*
* The blocks are validated first and the preview refused if they fail, for a
* reason that is not tidiness: `renderBlocks` trusts its input to have been
* through the schema walk, so previewing unvalidated props is asking the renderer
* to interpret whatever the client sent.
*/
async function preview(id, draft) {
const source = await db.getById(id)
if (!source) return { ok: false, errors: ['no such template'], status: 404 }
const result = await validate({ ...draft, status: 'draft' }, source)
if (!result.ok) return result
const candidate = {
key: source.key,
subject: result.template.subject,
blocks: result.template.blocks,
text_body: result.template.textBody,
trigger_id: result.template.triggerId,
seed_key: source.seed_key ?? null,
}
const rendered = await renderWithExamples(candidate)
return { ok: true, preview: { ...rendered, variables: templates.variablesFor(candidate) } }
}
/**
* Send the draft on screen to one address, and record it.
*
* Two things this deliberately does NOT do. It does not save first — a test send
* is how someone decides whether to save. And it does not consult the recipient's
* channel preferences or the suppression list: the address is typed by an admin
* about their own deployment, it is not derived from a user, and running it
* through an opt-in gate would mean an operator could not test a template until
* they had subscribed themselves to it.
*
* It IS recorded (§4.6.2), under the synthetic trigger `engagementSends.db`
* documents — including when it fails, which is the case an operator most needs
* a record of.
*/
async function testSend(id, draft) {
const to = typeof draft.to === 'string' ? draft.to.trim() : ''
// Deliberately shallow: the relay is the authority on whether an address is
// deliverable, and a stricter regex here would refuse addresses that work.
if (!to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to) || to.length > 254) {
return { ok: false, errors: ['enter an email address to send the test to'] }
}
const rendered = await preview(id, draft)
if (!rendered.ok) return rendered
const source = await db.getById(id)
const addressHash = crypto.createHash('sha256').update(to.toLowerCase()).digest('hex')
const logRow = {
trigger_id: sendsDb.TEST_SEND_TRIGGER,
user_id: draft.updatedBy ?? null,
channel: source.channel,
address_hash: addressHash,
}
try {
const sent = await mailer.sendRendered(to, rendered.preview)
await sendsDb.record({ ...logRow, transport: sent.transport, status: 'sent', detail: source.key })
return { ok: true, sent: true, to }
} catch (err) {
await sendsDb
.record({ ...logRow, status: 'failed', detail: `${source.key}: ${err.message}` })
.catch(() => {})
return { ok: false, errors: [err.message], status: err.code === 'NOT_CONFIGURED' ? 409 : 502 }
}
}
module.exports = {
validate,
preview,
testSend,
listAnnotated,
get,
update,
duplicate,
remove,
renderWithExamples,
examplesFor,
isDormant,
KEY_RE,
}

View File

@@ -30,6 +30,8 @@ const audiences = require('../../../engagement/audiences')
const rules = require('../../../model/engagement/engagementRules.model')
const segments = require('../../../model/engagement/engagementSegments.model')
const recipients = require('../../../model/engagement/engagementRecipients.db')
const templates = require('../../../model/engagement/engagementTemplates.model')
const sendsDb = require('../../../model/engagement/engagementSends.db')
// The lattice, flattened for a client: for each ceiling, the ones a rule may
// choose under it. Served with the catalog rather than hardcoded in the admin
@@ -300,3 +302,145 @@ exports.previewAudience = async (req, res, next) => {
next(err)
}
}
// ── Templates (Phase 5b) ───────────────────────────────────────────────────
//
// §4.6.2. The model owns every rule; this file reads ids out of URLs, maps a
// refusal onto a status code and shapes responses. The one thing worth saying
// here rather than there: **`refuse` is handed `result.status`**, because these
// routes have three different refusals that are not all 400 — a missing template
// is 404, a duplicate key or an in-use delete is 409, and a transport that would
// not take the test send is 502. A single 400 for all of them would make the
// editor's error handling guess.
/** GET /api/v1/admin/engagement/templates */
exports.listTemplates = async (req, res, next) => {
try {
res.json({ templates: await templates.listAnnotated() })
} catch (err) {
next(err)
}
}
/** GET /api/v1/admin/engagement/templates/:id */
exports.getTemplate = async (req, res, next) => {
try {
const template = await templates.get(Number(req.params.id))
if (!template) return res.status(404).json({ message: 'No such template' })
res.json({ template })
} catch (err) {
next(err)
}
}
/** PUT /api/v1/admin/engagement/templates/:id */
exports.updateTemplate = async (req, res, next) => {
try {
const result = await templates.update(Number(req.params.id), {
...req.body,
updatedBy: req.user?.id ?? null,
})
if (!result.ok) return refuse(res, result, result.status || 400)
res.json({ template: result.template })
} catch (err) {
next(err)
}
}
/** POST /api/v1/admin/engagement/templates/:id/duplicate */
exports.duplicateTemplate = async (req, res, next) => {
try {
const result = await templates.duplicate(Number(req.params.id), {
...req.body,
updatedBy: req.user?.id ?? null,
})
if (!result.ok) return refuse(res, result, result.status || 400)
res.status(201).json({ template: result.template })
} catch (err) {
next(err)
}
}
/** DELETE /api/v1/admin/engagement/templates/:id */
exports.deleteTemplate = async (req, res, next) => {
try {
const result = await templates.remove(Number(req.params.id))
if (!result.ok) return refuse(res, result, result.status || 400)
res.status(204).end()
} catch (err) {
next(err)
}
}
/**
* POST /api/v1/admin/engagement/templates/:id/preview
*
* A POST because it renders the body in the request, not the row: the editor
* previews unsaved edits, which is the whole reason the preview exists.
*
* The response is HTML the client puts into a sandboxed iframe. It is NOT served
* as a document from this origin, and that is a security boundary rather than a
* convenience: operator-authored HTML rendered at the site's own origin would run
* under the site's CSP with access to its cookies. Returning it as a JSON string
* leaves the client no way to render it except into a frame it controls the
* sandbox attributes of.
*/
exports.previewTemplate = async (req, res, next) => {
try {
const result = await templates.preview(Number(req.params.id), req.body || {})
if (!result.ok) return refuse(res, result, result.status || 400)
res.json(result.preview)
} catch (err) {
next(err)
}
}
/** POST /api/v1/admin/engagement/templates/:id/test-send */
exports.testSendTemplate = async (req, res, next) => {
try {
const result = await templates.testSend(Number(req.params.id), {
...req.body,
updatedBy: req.user?.id ?? null,
})
if (!result.ok) return refuse(res, result, result.status || 400)
res.json({ sent: true, to: result.to })
} catch (err) {
next(err)
}
}
// ── Send log (Phase 5b) ────────────────────────────────────────────────────
/**
* GET /api/v1/admin/engagement/sends
*
* G15's answer, paged. `address_hash` is a column this route never returns: the
* log holds it so a bounce can be correlated back to a recipient (Phase 9), and
* shipping it to a browser would turn a screen about delivery into an offline
* dictionary attack against every address on the deployment.
*/
exports.listSends = async (req, res, next) => {
try {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200)
const offset = Math.max(Number(req.query.offset) || 0, 0)
const filters = {
triggerId: req.query.triggerId || null,
ruleId: req.query.ruleId ? Number(req.query.ruleId) : null,
userId: req.query.userId ? Number(req.query.userId) : null,
status: req.query.status || null,
}
const [rows, total] = await Promise.all([
sendsDb.list({ ...filters, limit, offset }),
sendsDb.count(filters),
])
res.json({
sends: rows.map(({ address_hash: _hash, ...row }) => row),
total,
limit,
offset,
testSendTrigger: sendsDb.TEST_SEND_TRIGGER,
})
} catch (err) {
next(err)
}
}

View File

@@ -211,4 +211,124 @@ engagementRouter.delete(
controller.deleteSegment,
)
// -- Templates (Phase 5b) --------------------------------------------------
//
// The editor's routes. Two of them are POSTs that write nothing -- preview and
// test-send -- because both act on the body in the request rather than on the
// stored row: an editor that could only preview what was already saved would make
// saving the way to find out whether a change was right.
engagementRouter.get(
'/templates',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'List every message template, annotated'
// #swagger.description = 'Each row carries three flags the list renders as warnings. `dormant`: the template is pinned to a trigger no installed module declares, so its variable palette cannot be checked. `triggerBehind`: the module is installed but has moved its declaration on past the version this template was authored against. `seedBehind`: a newer shipped default exists for the seed this row came from, and was NOT applied because a person had edited it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The templates', content: { "application/json": { schema: { type: "object", properties: { templates: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listTemplates,
)
engagementRouter.get(
'/templates/:id',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'One template, with the variables it may reference'
// #swagger.description = 'The `variables` array is the editor palette and comes from the trigger declaration (or, for a template tied to no trigger, from the shipped seed) merged with the ambient variables every template may use. It is served with the row so the editor never guesses what is legal.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The template', content: { "application/json": { schema: { type: "object", properties: { template: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[404] = { description: 'No such template', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.getTemplate,
)
engagementRouter.put(
'/templates/:id',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'Edit a template, including a shipped default'
// #swagger.description = 'A seeded template is edited IN PLACE; the save sets `customized = 1`, which is what stops a later seed bump from taking the edit back. `key` and `channel` cannot be changed and a request that tries is refused rather than ignored - mailer renders by key, so a rename would break the message it names with no error anywhere. Two refusals are the point of this route: a token naming a variable the trigger does not declare is refused WITH THE VARIABLE NAMED, and a template published with no plain-text part is refused, because the text part is checked by rendering rather than by inspecting the blocks.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, subject: { type: "string", nullable: true }, blocks: { type: "array", items: { type: "object", additionalProperties: true } }, textBody: { type: "string", nullable: true }, status: { type: "string", enum: ["draft", "published"] }, triggerId: { type: "string", nullable: true } } } } } } */
/* #swagger.responses[200] = { description: 'The updated template', content: { "application/json": { schema: { type: "object", properties: { template: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such template', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.updateTemplate,
)
engagementRouter.post(
'/templates/:id/duplicate',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'Copy a template under a new key'
// #swagger.description = 'The only way a template that is not a shipped seed comes into being, so every template on a deployment descends from one that renders. The copy always starts as a DRAFT whatever the original was, is never protected, and inherits the source seed reference - which is what keeps its variable palette, not bookkeeping.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { key: { type: "string" }, name: { type: "string" }, triggerId: { type: "string", nullable: true } }, required: ["key"] } } } } */
/* #swagger.responses[201] = { description: 'The new template', content: { "application/json": { schema: { type: "object", properties: { template: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'The key is not a legal template key', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'That key is already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.duplicateTemplate,
)
engagementRouter.delete(
'/templates/:id',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'Delete a template'
// #swagger.description = 'Refused with 409 for a protected template - the system breaks without a password-reset body, so those are editable and not deletable - and refused with 409 while any rule points at the key, naming the rules. The second is the answer a segment in use already gets, for the same reason: the alternative is a rule that silently stops producing mail.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[204] = { description: 'Deleted' } */
/* #swagger.responses[409] = { description: 'Protected, or still used by a rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.deleteTemplate,
)
engagementRouter.post(
'/templates/:id/preview',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'Render the draft on screen, without saving it'
// #swagger.description = 'Renders the body in the REQUEST, using the example value each variable declares, so no live game event is needed - which is why `example` is a required part of a trigger declaration rather than documentation. The HTML comes back as a JSON string and the client must render it inside a sandboxed iframe with no allow-scripts: operator-authored HTML served as a document from this origin would run under the site CSP with access to its cookies.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { subject: { type: "string", nullable: true }, blocks: { type: "array", items: { type: "object", additionalProperties: true } }, textBody: { type: "string", nullable: true }, triggerId: { type: "string", nullable: true } } } } } } */
/* #swagger.responses[200] = { description: 'Both parts, plus the variable palette and any variable with no value', content: { "application/json": { schema: { type: "object", properties: { subject: { type: "string" }, html: { type: "string" }, text: { type: "string" }, missing: { type: "array", items: { type: "string" } }, variables: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[400] = { description: 'The draft is not renderable; `errors` says why', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.previewTemplate,
)
engagementRouter.post(
'/templates/:id/test-send',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'Send the draft on screen to one address'
// #swagger.description = 'Sends what is on screen, saved or not, through the configured transport, and records the attempt in the send log under a synthetic `core.admin.test-send` trigger - including when it fails, which is the outcome an operator most needs a record of. It deliberately does not consult channel preferences or the suppression list: the address is typed by an admin about their own deployment and is not derived from a user.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { to: { type: "string" }, subject: { type: "string", nullable: true }, blocks: { type: "array", items: { type: "object", additionalProperties: true } }, textBody: { type: "string", nullable: true }, triggerId: { type: "string", nullable: true } }, required: ["to"] } } } } */
/* #swagger.responses[200] = { description: 'Sent', content: { "application/json": { schema: { type: "object", properties: { sent: { type: "boolean" }, to: { type: "string" } } } } } } */
/* #swagger.responses[400] = { description: 'No address, or the draft is not renderable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Email is not configured on this deployment', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[502] = { description: 'The transport refused the message; the message is the relay reason', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.testSendTemplate,
)
// -- Send log (Phase 5b) ---------------------------------------------------
engagementRouter.get(
'/sends',
// #swagger.tags = ['Admin - Engagement']
// #swagger.summary = 'The send log, newest first'
// #swagger.description = 'G15 answered: every terminal delivery outcome, success and failure alike, with the reason. `address_hash` is stored but never returned - the log keeps it so a bounce can be correlated back to a recipient, and shipping it to a browser would turn a delivery screen into an offline dictionary attack against every address on the deployment.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['limit'] = { in: 'query', description: 'Page size, 1-200 (default 50)', required: false, schema: { type: 'integer' } }
// #swagger.parameters['offset'] = { in: 'query', description: 'Rows to skip', required: false, schema: { type: 'integer' } }
// #swagger.parameters['triggerId'] = { in: 'query', description: 'Only sends caused by this trigger', required: false, schema: { type: 'string' } }
// #swagger.parameters['ruleId'] = { in: 'query', description: 'Only sends made by this rule', required: false, schema: { type: 'integer' } }
// #swagger.parameters['userId'] = { in: 'query', description: 'Only sends to this user', required: false, schema: { type: 'integer' } }
// #swagger.parameters['status'] = { in: 'query', description: 'sent, failed, suppressed, bounced or complained', required: false, schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'One page of the log, with the total matching the same filters', content: { "application/json": { schema: { type: "object", properties: { sends: { type: "array", items: { type: "object", additionalProperties: true } }, total: { type: "integer" }, limit: { type: "integer" }, offset: { type: "integer" }, testSendTrigger: { type: "string" } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listSends,
)
module.exports = engagementRouter

View File

@@ -409,10 +409,53 @@ async function sendTeamNotification({ to, subject, intro, items, teamUrl, unsubs
}
}
/**
* Send an ALREADY-RENDERED body to one address — the template editor's test send
* (§4.6.2, Phase 5b).
*
* It takes the rendered parts rather than a template key because the whole point
* of the button is to send **what is on screen**, including edits not yet saved.
* Rendering happens in the model, from the same call the preview uses, so the mail
* that arrives and the preview above it cannot disagree.
*
* Throws like `sendTest` and for the same reason: an admin is standing there
* waiting to be told why nothing arrived.
*/
async function sendRendered(to, rendered) {
const built = await buildTransport()
if (!built) {
const err = new Error('Email is not configured. Set a transport, its credentials and a sender address first.')
err.code = 'NOT_CONFIGURED'
throw err
}
const { transport, config } = built
try {
await transport.sendMail({
from: fromHeader(config),
to,
replyTo: replyToFor(config),
subject: rendered.subject,
text: rendered.text,
html: rendered.html,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() })
return { sent: true, to, transport: config.transport }
} catch (err) {
const detail = describeSendError(err, config)
log.error('template test send failed', err)
await emailConfig.recordStatus({ status: 'error', statusDetail: detail })
const wrapped = new Error(detail)
wrapped.code = err.code || 'SEND_FAILED'
wrapped.transport = config.transport
throw wrapped
}
}
module.exports = {
isConfigured,
sendContactMessage,
sendTest,
sendRendered,
sendInvite,
sendPasswordReset,
sendEmailVerification,