feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s

Every subject and body moves out of `mailer.js` into `engagement_templates` rows an
operator can edit. A relocation, not a regression: nothing that sends mail today
starts depending on an operator authoring something first.

- `email.*` block family in its own registry, sharing the page family's envelope
  walk and validate-then-sanitize order by binding rather than by copy.
- A server-side renderer producing both parts of a multipart message; the text
  part is byte-identical to the literals this commit deletes.
- Nine seeded templates, six of them wired now; the seeder's `customized = 0`
  guard lives in the UPDATE's own WHERE.
- `renderByKey` falls back to the shipped seed when a row is missing or unusable,
  so no failure of the table can stop a password reset.

Also fixes `check:hosts` reading the template key `auth.email-verify` as the
hostname `auth.email`.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 13:07:39 -05:00
parent 1d7961e7a2
commit 12ff201ed5
22 changed files with 2222 additions and 154 deletions

View File

@@ -0,0 +1,149 @@
const { query } = require('../../utils/db')
// Same JSON-column caveat as engagementRules.db.js — `blocks` is a MEDIUMTEXT
// holding JSON rather than a JSON column (it can be large and is never queried
// into), so it is always a string on the way out and always parsed here.
function parseJson(value, fallback) {
if (value === null || value === undefined) return fallback
if (typeof value !== 'string') return value
try {
return JSON.parse(value)
} catch {
return fallback
}
}
// A stored `blocks` that will not parse degrades to an EMPTY array, not to an
// error. Same posture `settingsJson` takes and the same one `resolveThemeTokens`
// takes: a row hand-edited in the DB, or written by an older version of this code,
// must not stop a password-reset mail from being attempted — the renderer produces
// an empty body, the send log records it, and the operator is told in the admin
// list rather than at 3am by a boot that will not come up.
const hydrate = (row) =>
row && {
...row,
blocks: parseJson(row.blocks, []),
protected: Boolean(row.protected),
customized: Boolean(row.customized),
}
const list = async () =>
(await query('SELECT * FROM engagement_templates ORDER BY channel, `key`')).map(hydrate)
const getById = async (id) => {
const [row] = await query('SELECT * FROM engagement_templates WHERE id = ?', [id])
return hydrate(row)
}
const getByKey = async (key) => {
const [row] = await query('SELECT * FROM engagement_templates WHERE `key` = ?', [key])
return hydrate(row)
}
/** Which of `keys` exist. Used to validate a rule's `template_keys` map. */
const existingKeys = async (keys) => {
if (!Array.isArray(keys) || keys.length === 0) return []
const marks = keys.map(() => '?').join(',')
const rows = await query(`SELECT \`key\` FROM engagement_templates WHERE \`key\` IN (${marks})`, keys)
return rows.map((r) => r.key)
}
/**
* Insert a shipped template, or bring an un-customized one up to a newer seed.
*
* **The `customized = 0` guard is in the SQL, not in a read-then-write.** The
* seeder runs on every boot and a deployment can start two app processes at once;
* a check in JavaScript followed by an UPDATE is a window in which an operator's
* edit can be overwritten by a concurrent boot. `WHERE customized = 0` in the
* UPDATE closes it, and MariaDB's `ON DUPLICATE KEY UPDATE` cannot express a
* WHERE — so this is deliberately an INSERT IGNORE plus a guarded UPDATE rather
* than the upsert §4.6.1 sketches.
*
* @returns {'inserted'|'updated'|'skipped'} what happened, for the boot log
*/
const seedOne = async (t) => {
const inserted = await query(
'INSERT IGNORE INTO engagement_templates ' +
'(`key`, name, trigger_id, trigger_version, channel, subject, blocks, text_body, status, ' +
' protected, seed_key, seed_version, customized) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, 0)',
[
t.key,
t.name,
t.triggerId ?? null,
t.triggerVersion ?? null,
t.channel,
t.subject ?? null,
JSON.stringify(t.blocks),
t.status || 'published',
t.protected ? 1 : 0,
t.key,
t.seedVersion,
],
)
if (inserted.affectedRows === 1) return 'inserted'
const updated = await query(
'UPDATE engagement_templates SET name = ?, channel = ?, subject = ?, blocks = ?, ' +
'protected = ?, seed_version = ?, status = ? ' +
'WHERE seed_key = ? AND customized = 0 AND (seed_version IS NULL OR seed_version < ?)',
[
t.name,
t.channel,
t.subject ?? null,
JSON.stringify(t.blocks),
t.protected ? 1 : 0,
t.seedVersion,
t.status || 'published',
t.key,
t.seedVersion,
],
)
return updated.affectedRows === 1 ? 'updated' : 'skipped'
}
/**
* Save an operator's edit. Always sets `customized = 1` — that flag is not a
* field the caller may choose, it is the record that a human touched this row, and
* it is the only thing standing between their work and the next seed bump.
*
* **`affectedRows === 1` here means "the row exists", not "something changed",**
* because the connector defaults to `foundRows: true` (the trap Phase 4a's
* cooldown check fell into). That is the semantics this caller wants — re-saving a
* template unchanged is a success, not a 404 — and it is stated rather than
* relied on, since the same expression means the other thing under `foundRows:
* false`. `seedOne`'s UPDATE above is safe under either reading: its WHERE only
* matches a row whose `seed_version` is behind, so a match always implies a write.
*/
const update = async (id, t, userId) => {
const res = await query(
'UPDATE engagement_templates SET name = ?, subject = ?, blocks = ?, text_body = ?, ' +
'status = ?, trigger_id = ?, trigger_version = ?, customized = 1, updated_by = ? WHERE id = ?',
[
t.name,
t.subject ?? null,
JSON.stringify(t.blocks),
t.textBody ?? null,
t.status,
t.triggerId ?? null,
t.triggerVersion ?? null,
userId ?? null,
id,
],
)
return res.affectedRows === 1
}
/** Templates whose shipped default has moved on since the operator edited them. */
const staleCustomized = async (pairs) => {
if (!Array.isArray(pairs) || pairs.length === 0) return []
const clauses = pairs.map(() => '(seed_key = ? AND seed_version < ?)').join(' OR ')
const params = pairs.flatMap((p) => [p.key, p.seedVersion])
const rows = await query(
`SELECT * FROM engagement_templates WHERE customized = 1 AND (${clauses})`,
params,
)
return rows.map(hydrate)
}
module.exports = { list, getById, getByKey, existingKeys, seedOne, update, staleCustomized }