diff --git a/client/src/App.jsx b/client/src/App.jsx
index b1d9789..00a21a5 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -44,6 +44,9 @@ import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
import EngagementRules from './routes/admin/views/EngagementRules.jsx'
import EngagementAudiences from './routes/admin/views/EngagementAudiences.jsx'
+import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
+import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
+import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
@@ -186,7 +189,7 @@ export default function App() {
actions that publish a game-written name is applied per request
on the server, from the caller's live role (TEAMS.md 2.9). */}
} />
- {/* Engagement (ENGAGEMENT.md Phase 4b). Admin-only, matching the
+ {/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
server: every route under /admin/engagement re-gates to `admin`
on top of the group's staff gate, because this is the group that
decides who receives mail. */}
@@ -201,6 +204,9 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
+ } />
} />
{/* Installed modules' admin pages, at /admin//…, already inside
diff --git a/client/src/api/client.js b/client/src/api/client.js
index af9423f..6f01d54 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -389,6 +389,32 @@ export const api = {
return req(`/admin/engagement/audience-preview${withQs(qs.toString())}`)
},
+ // Templates and the send log (engagement Phase 5b). `previewEngagementTemplate`
+ // and `testSendEngagementTemplate` are POSTs that write nothing: both act on
+ // the draft in the request, so the editor can show and send what is on screen
+ // rather than what was last saved.
+ listEngagementTemplates: () => req('/admin/engagement/templates'),
+ getEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`),
+ updateEngagementTemplate: (id, body) =>
+ req(`/admin/engagement/templates/${id}`, { method: 'PUT', body }),
+ duplicateEngagementTemplate: (id, body) =>
+ req(`/admin/engagement/templates/${id}/duplicate`, { method: 'POST', body }),
+ deleteEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`, { method: 'DELETE' }),
+ previewEngagementTemplate: (id, body) =>
+ req(`/admin/engagement/templates/${id}/preview`, { method: 'POST', body }),
+ testSendEngagementTemplate: (id, body) =>
+ req(`/admin/engagement/templates/${id}/test-send`, { method: 'POST', body }),
+ listEngagementSends: ({ limit, offset, triggerId, ruleId, userId, status } = {}) => {
+ const qs = new URLSearchParams()
+ if (limit) qs.set('limit', String(limit))
+ if (offset) qs.set('offset', String(offset))
+ if (triggerId) qs.set('triggerId', triggerId)
+ if (ruleId) qs.set('ruleId', String(ruleId))
+ if (userId) qs.set('userId', String(userId))
+ if (status) qs.set('status', status)
+ return req(`/admin/engagement/sends${withQs(qs.toString())}`)
+ },
+
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
// different depending on who calls them: for a moderator, unhide and
// setTeamDisplayName file a request and the response says `pending: true`.
diff --git a/client/src/emailBlocks/index.js b/client/src/emailBlocks/index.js
new file mode 100644
index 0000000..adb9920
--- /dev/null
+++ b/client/src/emailBlocks/index.js
@@ -0,0 +1,12 @@
+// Client email-block registry entrypoint. Importing this module registers every
+// `email.*` authoring definition exactly once, then re-exports the registry API.
+// The template editor imports from HERE, never from ./registry, so the
+// definitions are loaded before anything reads the palette.
+//
+// Same shape as `blocks/index.js` — and the same reason for existing.
+
+export * from './registry'
+export { VariablePalette } from './types.jsx'
+
+// ── Definitions (self-register on import) ──────────────────────────────────
+import './types.jsx'
diff --git a/client/src/emailBlocks/registry.js b/client/src/emailBlocks/registry.js
new file mode 100644
index 0000000..027fa92
--- /dev/null
+++ b/client/src/emailBlocks/registry.js
@@ -0,0 +1,100 @@
+// ── The client-side `email.*` block registry ───────────────────────────────
+//
+// ENGAGEMENT.md §4.6.2, Phase 5b. A sibling of `blocks/registry.js` for the same
+// reason its server counterpart is a sibling of `blocks/registry.js` on that side
+// — and with ONE structural difference that is the whole argument for the shape of
+// this screen:
+//
+// **an email block definition here has no `component`.**
+//
+// A page block carries a React renderer because a page IS React. A mail body is a
+// string this deployment's server produces, and the preview shows exactly that
+// string. Giving these entries a React renderer would mean two renderers for one
+// artifact — one drawing the editor's preview, one producing what actually lands
+// in someone's inbox — and nothing would make them agree. They would agree on the
+// day they were written and drift from the first Outlook fix onward, at which
+// point the preview becomes a confident lie about mail nobody can see.
+//
+// So the division is: **this registry owns authoring, the server owns rendering.**
+// Everything here is about the editing experience — the palette entry, the prop
+// form, the starting props — and the preview arrives from
+// `POST /admin/engagement/templates/:id/preview` as HTML that goes into a
+// sandboxed iframe.
+//
+// `type` and `version` must match the server definition in
+// `server/src/emailBlocks/types/`. That pairing is the same discipline the page
+// family already runs on, and the save is the thing that enforces it: the server
+// validates against its own registry, so a client entry that has drifted produces
+// a refused save rather than a bad row.
+
+const registry = new Map()
+
+// The same reserved envelope keys the server's `RESERVED_KEYS` names. Duplicated
+// rather than imported because the client cannot import from `server/`, exactly as
+// `blocks/registry.js` duplicates them — and, as there, the server is the one that
+// decides: a block this list let through is still refused at the save.
+export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
+
+/**
+ * Register an email block definition.
+ *
+ * @param {object} def
+ * @param {string} def.type must match the server type, e.g. 'email.heading'
+ * @param {number} def.version must match the server schema version
+ * @param {string} def.label palette display name
+ * @param {string} def.icon palette icon glyph
+ * @param {Function} def.editor ({ props, onChange, variables }) => JSX
+ * @param {Function} def.defaults starting props when the block is added
+ */
+export function registerEmailBlock(def) {
+ if (!def || typeof def.type !== 'string' || !def.type.startsWith('email.')) {
+ throw new Error('registerEmailBlock: a definition needs a type namespaced "email."')
+ }
+ if (registry.has(def.type)) {
+ throw new Error(`registerEmailBlock: block type already registered: ${def.type}`)
+ }
+ const entry = {
+ type: def.type,
+ version: Number.isInteger(def.version) ? def.version : 1,
+ label: def.label || def.type,
+ icon: def.icon || null,
+ // The one-line description under the palette button. Mail blocks are less
+ // self-evident than page ones — "Item list" does not say that it repeats over
+ // a variable — and the palette is where that has to be said.
+ hint: def.hint || '',
+ editor: def.editor || null,
+ defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
+ }
+ registry.set(entry.type, entry)
+ return entry
+}
+
+/** @returns {object|null} the definition for `type`, or null if unknown. */
+export function getEmailBlock(type) {
+ return registry.get(type) || null
+}
+
+/** @returns {object[]} every definition, in registration order — the palette. */
+export function listEmailBlocks() {
+ return [...registry.values()]
+}
+
+/**
+ * A fresh block envelope of `type`, ready to push onto the array.
+ *
+ * The id is random rather than sequential because block ids are unique across the
+ * whole document and an operator can delete block 2 and add another; a counter
+ * would hand out an id that is already taken and the save would be refused for a
+ * reason nothing on screen explains.
+ */
+export function newEmailBlock(type) {
+ const def = getEmailBlock(type)
+ if (!def) return null
+ return {
+ id: `b${Math.random().toString(36).slice(2, 10)}`,
+ type: def.type,
+ version: def.version,
+ visible: true,
+ props: def.defaults(),
+ }
+}
diff --git a/client/src/emailBlocks/types.jsx b/client/src/emailBlocks/types.jsx
new file mode 100644
index 0000000..fde6eb2
--- /dev/null
+++ b/client/src/emailBlocks/types.jsx
@@ -0,0 +1,272 @@
+// The six `email.*` block editors, in one file rather than one file each.
+//
+// The page family gives every block its own module because each carries a React
+// RENDERER as well as a form, and those are substantial. An email block carries
+// only a form — the rendering is the server's (see ./registry.js) — and six short
+// prop panels split across six files would be six imports of the same three
+// controls to no benefit.
+//
+// Every `type` and `version` here pairs with a definition in
+// `server/src/emailBlocks/types/`, and the field lists are the server's `onlyKeys`
+// lists. Where a server schema has a bound (`MAX_TEXT`, `MAX_LABEL`), the input
+// carries the same `maxLength` — not as the check, which is the server's, but so
+// that an operator meets the limit while typing rather than at the save.
+import { TextField, TextAreaField, SelectField, Field } from '../blocks/editorKit.jsx'
+import { registerEmailBlock } from './registry'
+
+/**
+ * The variable palette, rendered under whichever field is being edited.
+ *
+ * Clicking a variable APPENDS its token rather than inserting at the caret. That
+ * is a deliberate simplification: tracking a caret across a controlled React input
+ * that a parent may re-render costs a ref and a selection-restore on every change,
+ * and appending is both predictable and trivially undone. §4.6.2's requirement is
+ * that inserting a variable "writes a token; it is never free-text" — which this
+ * satisfies — not that it lands at the cursor.
+ */
+export function VariablePalette({ variables, onInsert }) {
+ if (!variables || !variables.length) return null
+ return (
+
+ {variables.map((v) => (
+
+ ))}
+
+ )
+}
+
+/** A text field with the palette attached — the shape four of the six blocks want. */
+function VariableTextField({ label, hint, value, onChange, variables, maxLength, area, rows }) {
+ const Control = area ? TextAreaField : TextField
+ return (
+
+
+ onChange(`${value || ''}${token}`)} />
+
+ )
+}
+
+registerEmailBlock({
+ type: 'email.heading',
+ version: 1,
+ label: 'Heading',
+ icon: 'H',
+ hint: 'A section heading, at one of three sizes.',
+ defaults: () => ({ level: 'h2', text: 'Heading' }),
+ editor: ({ props, onChange, variables }) => (
+
+ ),
+})
+
+registerEmailBlock({
+ type: 'email.itemList',
+ version: 1,
+ label: 'Item list',
+ icon: '☰',
+ hint: 'Repeats over a list variable — this is how a digest lists its items.',
+ defaults: () => ({ variable: '', emptyText: '' }),
+ editor: ({ props, onChange, variables }) => {
+ // Only LIST variables may be chosen, and the field is a select rather than a
+ // text input because this prop is a bare NAME, not a token: a typo here is the
+ // one variable reference a reader of the template cannot see is wrong, and it
+ // renders as an empty mail rather than as a visible gap.
+ const lists = (variables || []).filter((v) => v.type === 'list' || v.type === 'array')
+ return (
+
+ )
+ },
+})
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index ddef1ec..10a4a24 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -48,6 +48,9 @@ const IconPalette = () =>
const IconMail = () =>
const IconList = () =>
+const IconTemplate = () =>
+const IconSpark = () =>
+const IconLog = () =>
// Nav is grouped into collapsible categories. A group with no `title` renders
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
@@ -93,15 +96,17 @@ export const NAV = [
},
{
// Its own top-level group (ENGAGEMENT.md §7.1 Q4), not a section of
- // Settings. Settings is already one long page of sections, and the screens
- // that join this group in Phase 5 - Triggers, Templates and the send log -
- // are a catalog, an editor and a paged table, none of which is a settings
- // section. Email Delivery stays under Settings: configuring a transport is
- // not the same job as deciding who gets mail.
+ // Settings. Settings is already one long page of sections, and these five
+ // screens are two editors, a catalog and a paged table, none of which is a
+ // settings section. Email Delivery stays under Settings: configuring a
+ // transport is not the same job as deciding who gets mail.
title: 'Engagement',
items: [
{ to: '/admin/engagement/rules', label: 'Rules', icon: IconMail, roles: ['admin'] },
{ to: '/admin/engagement/audiences', label: 'Audiences', icon: IconList, roles: ['admin'] },
+ { to: '/admin/engagement/templates', label: 'Templates', icon: IconTemplate, roles: ['admin'] },
+ { to: '/admin/engagement/triggers', label: 'Triggers', icon: IconSpark, roles: ['admin'] },
+ { to: '/admin/engagement/sends', label: 'Send Log', icon: IconLog, roles: ['admin'] },
],
},
{
@@ -174,6 +179,9 @@ const TITLES = {
'/admin/account': 'Account Security',
'/admin/engagement/rules': 'Engagement Rules',
'/admin/engagement/audiences': 'Engagement Audiences',
+ '/admin/engagement/templates': 'Message Templates',
+ '/admin/engagement/triggers': 'Triggers',
+ '/admin/engagement/sends': 'Send Log',
}
// An installed module's admin pages are not in TITLES and cannot be — core does
diff --git a/client/src/routes/admin/views/EngagementSendLog.jsx b/client/src/routes/admin/views/EngagementSendLog.jsx
new file mode 100644
index 0000000..bba6231
--- /dev/null
+++ b/client/src/routes/admin/views/EngagementSendLog.jsx
@@ -0,0 +1,172 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { api } from '../../../api/client.js'
+
+// Admin → Engagement → Send Log (ENGAGEMENT.md §4.5, gap G15, Phase 5b).
+//
+// G15 was stated as: "no per-message record — no send log, no delivery status, no
+// audit". The table has been filling since Phase 4a; this is the screen that reads
+// it, and the question it exists to answer is the operator's, not the engine's:
+// **did that person get that mail, and if not, why not?**
+//
+// Two things it deliberately does not show.
+//
+// • **The address.** The log stores a sha256 so a bounce can be correlated back
+// to a recipient (Phase 9) without becoming a second address book. The route
+// strips the column; this screen could not render it if it wanted to.
+// • **A name for the user.** The `user_id` is what the log holds, and joining
+// users in would make a delivery screen into a directory. The id is enough to
+// paste into Moderation, which is where a person's record belongs.
+//
+// `failed` rows are the point of the screen, so the reason is a column and not a
+// tooltip: a delivery log whose failures need a hover is a log nobody reads.
+
+const STATUS_LABEL = {
+ sent: 'Sent',
+ failed: 'Failed',
+ suppressed: 'Not sent',
+ bounced: 'Bounced',
+ complained: 'Marked as spam',
+}
+
+const STATUS_COLOR = {
+ failed: '#d98b84',
+ bounced: '#d98b84',
+ complained: '#d98b84',
+}
+
+const PAGE = 50
+
+export default function EngagementSendLog() {
+ const [rows, setRows] = useState([])
+ const [total, setTotal] = useState(0)
+ const [offset, setOffset] = useState(0)
+ const [status, setStatus] = useState('')
+ const [testTrigger, setTestTrigger] = useState('')
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const load = useCallback(async (nextOffset, nextStatus) => {
+ const result = await api.admin.listEngagementSends({
+ limit: PAGE,
+ offset: nextOffset,
+ status: nextStatus || undefined,
+ })
+ setRows(result.sends || [])
+ setTotal(result.total || 0)
+ setTestTrigger(result.testSendTrigger || '')
+ }, [])
+
+ useEffect(() => {
+ let alive = true
+ ;(async () => {
+ setLoading(true)
+ try {
+ await load(offset, status)
+ if (alive) setError(null)
+ } catch (err) {
+ if (alive) setError(err.message)
+ } finally {
+ if (alive) setLoading(false)
+ }
+ })()
+ return () => { alive = false }
+ }, [load, offset, status])
+
+ if (loading && rows.length === 0) return
+ if (error) return
+
+ const to = Math.min(offset + PAGE, total)
+
+ return (
+
+
+
+ Every message this deployment tried to deliver, successful or not. Addresses are not kept
+ here — only a one-way hash, so a bounce can be matched back without the log becoming a
+ second address book.
+
+
+
+
+ {total === 0 ? (
+
+ {status ? 'Nothing matches that filter.' : 'Nothing has been sent yet.'}
+
+ ) : (
+ <>
+
+
+
+
+
When
+
What
+
To
+
Channel
+
Result
+
Detail
+
+
+
+ {rows.map((r) => (
+
+
+ {new Date(r.created_at).toLocaleString()}
+
+
+ {/* The synthetic test-send id is rendered by name: it is not a
+ registered trigger and will never appear in the catalog,
+ so showing the raw id would send someone looking for it. */}
+ {r.trigger_id === testTrigger
+ ? Test send from the template editor
+ : {r.trigger_id}}
+
+ >
+ )}
+
+ )
+}
diff --git a/client/src/routes/admin/views/EngagementTemplates.jsx b/client/src/routes/admin/views/EngagementTemplates.jsx
new file mode 100644
index 0000000..ecead56
--- /dev/null
+++ b/client/src/routes/admin/views/EngagementTemplates.jsx
@@ -0,0 +1,649 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { api } from '../../../api/client.js'
+import { getEmailBlock, listEmailBlocks, newEmailBlock } from '../../../emailBlocks/index.js'
+
+// Admin → Engagement → Templates (ENGAGEMENT.md §4.6.2, Phase 5b).
+//
+// Phase 5a moved every subject and body out of `mailer.js` into rows. This is the
+// screen that lets someone change one, and its whole shape follows from a single
+// fact about email:
+//
+// **the server renders the mail, so the server renders the preview.**
+//
+// There is no React renderer for an `email.*` block anywhere in this client. The
+// preview is HTML the server produced with the same call the send path uses,
+// dropped into a sandboxed iframe. That costs a round trip per edit — debounced
+// below — and buys the only property that matters on a screen like this: what is
+// on screen is what will arrive, not a second implementation's opinion of it.
+//
+// **The sandbox is a security boundary, not a nicety.** The preview is
+// operator-authored HTML. It renders with `sandbox` and no `allow-scripts`, from
+// `srcdoc` (an opaque origin), so it can neither run script nor reach this page's
+// cookies even if someone stores markup that gets past `sanitizeHtml`. The
+// attributes are asserted in `client/test/emailTemplates.test.js` for the same
+// reason the server's checks are asserted: this is the kind of attribute someone
+// removes while debugging and does not put back.
+//
+// What the operator can do here is deliberately bounded (settled with the org
+// lead at the start of the phase):
+//
+// • **A shipped default is edited in place.** `protected` blocks deletion and
+// nothing else; saving sets `customized = 1`, which is what stops the next
+// seed bump from taking the edit back.
+// • **Duplicate is the only way to a new template**, so every template on a
+// deployment descends from one that renders.
+
+const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
+
+// Three widths, because a mail body has to survive all of them and the failures
+// are different: 640 is a desktop client's reading pane, 360 is a phone, and the
+// plain-text part is what a text-only client and every screen reader gets.
+const WIDTHS = [
+ ['desktop', 'Desktop', 640],
+ ['mobile', 'Mobile', 360],
+]
+
+/** Short, human label for a template's channel. */
+const CHANNEL_LABEL = { email: 'Email', inapp: 'On the site', push: 'Push' }
+
+// ── The preview frame ──────────────────────────────────────────────────────
+
+/**
+ * The rendered HTML, in a sandboxed frame.
+ *
+ * `dark` applies a CSS inversion to the FRAME, not to the mail: it approximates
+ * what Apple Mail and Outlook do to a light-only message, which is the failure
+ * §4.6.2 asks this control to expose ("a light-only template renders as unreadable
+ * dark-on-dark in about a third of inboxes"). It is an approximation and says so
+ * on screen — the alternative, rendering a second dark palette server-side, would
+ * be a preview of a mail this system does not send.
+ */
+function PreviewFrame({ html, width, dark }) {
+ return (
+
+
+
+ )
+}
+
+// ── The editor ─────────────────────────────────────────────────────────────
+
+function TemplateEditor({ template, triggers, onDone, onCancel }) {
+ const [name, setName] = useState(template.name)
+ const [subject, setSubject] = useState(template.subject || '')
+ const [blocks, setBlocks] = useState(template.blocks || [])
+ const [textBody, setTextBody] = useState(template.text_body || '')
+ const [status, setStatus] = useState(template.status)
+ const [triggerId, setTriggerId] = useState(template.trigger_id || '')
+ const [selected, setSelected] = useState(template.blocks?.[0]?.id || null)
+
+ const [preview, setPreview] = useState(null)
+ const [previewError, setPreviewError] = useState(null)
+ const [tab, setTab] = useState('html')
+ const [width, setWidth] = useState('desktop')
+ const [dark, setDark] = useState(false)
+
+ const [saving, setSaving] = useState(false)
+ const [errors, setErrors] = useState([])
+ const [saved, setSaved] = useState(false)
+ const [testTo, setTestTo] = useState('')
+ const [testState, setTestState] = useState(null)
+
+ // The variable palette. It comes from the server with the row and is refreshed
+ // by every preview, because re-pointing the template at another trigger changes
+ // it and the server is the one that knows what that trigger declares.
+ const [variables, setVariables] = useState(template.variables || [])
+
+ const draft = useMemo(
+ () => ({ name, subject, blocks, textBody: textBody || null, status, triggerId: triggerId || null }),
+ [name, subject, blocks, textBody, status, triggerId],
+ )
+
+ // Debounced preview. The delay is not about server load — it is one small
+ // render — but about the frame: re-mounting an iframe on every keystroke makes
+ // the preview flicker and steals nothing back.
+ const timer = useRef(null)
+ useEffect(() => {
+ if (timer.current) clearTimeout(timer.current)
+ timer.current = setTimeout(async () => {
+ try {
+ const body = { subject: draft.subject, blocks: draft.blocks, textBody: draft.textBody, triggerId: draft.triggerId }
+ const result = await api.admin.previewEngagementTemplate(template.id, body)
+ setPreview(result)
+ setPreviewError(null)
+ if (Array.isArray(result.variables)) setVariables(result.variables)
+ } catch (err) {
+ // A preview failure is expected while a block is half-edited, so it is
+ // shown where the preview would be rather than as a page-level error.
+ setPreviewError(err.body?.errors?.join(' · ') || err.message)
+ }
+ }, 400)
+ return () => timer.current && clearTimeout(timer.current)
+ }, [draft, template.id])
+
+ const selectedBlock = blocks.find((b) => b.id === selected) || null
+ const selectedDef = selectedBlock ? getEmailBlock(selectedBlock.type) : null
+
+ const updateBlock = (id, props) =>
+ setBlocks((bs) => bs.map((b) => (b.id === id ? { ...b, props } : b)))
+
+ const addBlock = (type) => {
+ const block = newEmailBlock(type)
+ if (!block) return
+ setBlocks((bs) => [...bs, block])
+ setSelected(block.id)
+ }
+
+ const move = (id, delta) =>
+ setBlocks((bs) => {
+ const i = bs.findIndex((b) => b.id === id)
+ const j = i + delta
+ if (i < 0 || j < 0 || j >= bs.length) return bs
+ const next = [...bs]
+ ;[next[i], next[j]] = [next[j], next[i]]
+ return next
+ })
+
+ const removeBlock = (id) =>
+ setBlocks((bs) => {
+ const next = bs.filter((b) => b.id !== id)
+ if (selected === id) setSelected(next[0]?.id || null)
+ return next
+ })
+
+ async function save() {
+ setSaving(true)
+ setErrors([])
+ setSaved(false)
+ try {
+ await api.admin.updateEngagementTemplate(template.id, draft)
+ setSaved(true)
+ onDone()
+ } catch (err) {
+ setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ async function sendTest() {
+ setTestState({ busy: true })
+ try {
+ const body = { ...draft, to: testTo }
+ const result = await api.admin.testSendEngagementTemplate(template.id, body)
+ setTestState({ ok: true, message: `Sent to ${result.to}.` })
+ } catch (err) {
+ setTestState({ ok: false, message: err.body?.errors?.join(' · ') || err.message })
+ }
+ }
+
+ const widthPx = WIDTHS.find(([id]) => id === width)?.[2] || 640
+
+ return (
+
+
+
+
{template.name}
+
+ {template.key} · {CHANNEL_LABEL[template.channel] || template.channel}
+ {template.protected && ' · part of the system'}
+
setSelected(b.id)}
+ >
+ {def?.icon || '?'}
+
+ {/* An unknown type is a client/server version skew, and saying
+ so beats rendering a blank row the operator cannot act on. */}
+ {def ? def.label : `${b.type} (not known to this client)`}
+
+
+
+
+
+
+ )
+}
+
+/** The variable tokens, for the two fields that are not block props. */
+function VariableButtons({ variables, onInsert }) {
+ if (!variables?.length) return null
+ return (
+
+ Every message this deployment sends. The shipped ones are editable — your edits survive
+ upgrades — and cannot be deleted, because the system breaks without them. To make a new
+ template, duplicate one that already works.
+
+
+ )
+}
+
+/**
+ * The three warnings a row can carry. Each is a different fact and they are worded
+ * as what an operator should DO, not as the flag name: "dormant" and "behind" mean
+ * nothing to someone who has not read the design document.
+ */
+function Flags({ template }) {
+ const notes = []
+ if (template.dormant) {
+ notes.push(`No installed module declares ${template.trigger_id} — nothing will send this.`)
+ }
+ if (template.triggerBehind) {
+ notes.push('Its trigger has changed since this was written; check the variables still exist.')
+ }
+ if (template.seedBehind) {
+ notes.push('A newer version of the shipped default exists. Your edits were kept, so it was not applied.')
+ }
+ if (!notes.length) return null
+ return (
+
+ {notes.map((n) =>
{n}
)}
+
+ )
+}
diff --git a/client/src/routes/admin/views/EngagementTriggers.jsx b/client/src/routes/admin/views/EngagementTriggers.jsx
new file mode 100644
index 0000000..da69fb9
--- /dev/null
+++ b/client/src/routes/admin/views/EngagementTriggers.jsx
@@ -0,0 +1,129 @@
+import { useEffect, useState } from 'react'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { api } from '../../../api/client.js'
+
+// Admin → Engagement → Triggers (ENGAGEMENT.md §4.3, Phase 5b).
+//
+// Read-only, and structurally so: **there is no table behind this screen.** A
+// trigger is DECLARED in code by core or by an installed module, so this is
+// whatever registered on the current boot. Uninstall a module and its triggers
+// stop appearing here; nothing was deleted and nothing needs to be.
+//
+// It exists because the two things it shows are otherwise invisible and both are
+// load-bearing elsewhere:
+//
+// • **The variables** are the contract a template may reference. When a rule
+// mails nothing sensible, "which variables does this event actually carry"
+// is the first question, and the answer used to live only in a module's source.
+// • **The ceiling** is the security boundary from G24 — the widest audience a
+// rule may ever give this trigger. A rule editor that offers a narrower set
+// than an operator expects is obeying a number declared here.
+
+const CEILING_NOTE = {
+ owner: 'only the person the event is about',
+ members: 'only members of the thing it is about',
+ subscribers: 'only people who opted in',
+ staff: 'only staff',
+ authenticated: 'any signed-in account',
+ everyone: 'anyone',
+}
+
+export default function EngagementTriggers() {
+ const [triggers, setTriggers] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ let alive = true
+ ;(async () => {
+ try {
+ const { triggers: list } = await api.admin.engagementTriggers()
+ if (alive) setTriggers(list || [])
+ } catch (err) {
+ if (alive) setError(err.message)
+ } finally {
+ if (alive) setLoading(false)
+ }
+ })()
+ return () => { alive = false }
+ }, [])
+
+ if (loading) return
+ if (error) return
+
+ return (
+
+
+ The events a rule can be built on, declared in code by core and by installed modules. This
+ list is whatever is registered right now — it is not stored anywhere, so a module that is
+ uninstalled simply stops appearing.
+
+ {/* `nowrap`: without it the "always set" pill wraps between its
+ two words on a longer variable name, orphaning "set" on a
+ line of its own and making the row read as two facts. */}
+
+
+ {/* A list variable's example is an array of objects; showing
+ it as JSON is honest and short, and it is the shape an
+ item list repeats over. */}
+ {typeof v.example === 'string' ? v.example : JSON.stringify(v.example)}
+
+
+
{v.description || ''}
+
+ ))}
+
+
+ )}
+
+ ))}
+
+ )
+}
diff --git a/client/test/emailTemplates.test.js b/client/test/emailTemplates.test.js
new file mode 100644
index 0000000..b3b7130
--- /dev/null
+++ b/client/test/emailTemplates.test.js
@@ -0,0 +1,154 @@
+import { test, beforeEach } from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import {
+ RESERVED_KEYS,
+ registerEmailBlock,
+ getEmailBlock,
+ listEmailBlocks,
+ newEmailBlock,
+} from '../src/emailBlocks/registry.js'
+
+// Engagement Phase 5b — the client half of the template editor.
+//
+// Two kinds of test, and the second kind is the one worth explaining.
+//
+// `registry.js` is plain `.js` and imports nothing, so it is exercised directly.
+// `types.jsx` and `EngagementTemplates.jsx` cannot be: this runner has no JSX
+// transform and no DOM, the same limit `moduleRegistry.test.js` documents. So the
+// properties that live in those files are asserted **against their source text**.
+//
+// That is a weaker test than executing them, and it is used for exactly two things
+// where a weak test still beats none:
+//
+// • **The preview sandbox.** `sandbox=""` with no `allow-scripts` is the reason
+// operator-authored HTML cannot run under this site's origin. It is one
+// attribute, on one element, and it is precisely the sort of thing someone
+// removes to debug a rendering problem and does not put back. A source
+// assertion catches that in review; nothing else here would.
+// • **Registry drift.** Every `email.*` type this client offers must exist in
+// the server registry with the same version, because the server validates
+// against its own and a drifted client produces a refused save with no
+// explanation on screen. Reading both trees is the only way to check a
+// pairing that spans a process boundary.
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+const read = (rel) => fs.readFileSync(path.join(here, '..', rel), 'utf8')
+
+// The registry is module state; each test starts from a known entry.
+beforeEach(() => {
+ if (!getEmailBlock('email.test')) {
+ registerEmailBlock({
+ type: 'email.test',
+ version: 2,
+ label: 'Test block',
+ defaults: () => ({ text: 'hi' }),
+ editor: () => null,
+ })
+ }
+})
+
+// ── The registry ───────────────────────────────────────────────────────────
+
+test('a definition must be namespaced "email."', () => {
+ assert.throws(() => registerEmailBlock({ type: 'heading' }), /namespaced/)
+ assert.throws(() => registerEmailBlock({}), /namespaced/)
+})
+
+test('a duplicate type is a programmer error, caught at import', () => {
+ assert.throws(() => registerEmailBlock({ type: 'email.test' }), /already registered/)
+})
+
+test('a new block carries the envelope the server expects, and a unique id', () => {
+ const a = newEmailBlock('email.test')
+ const b = newEmailBlock('email.test')
+ assert.deepEqual(Object.keys(a).sort(), [...RESERVED_KEYS].sort())
+ assert.equal(a.type, 'email.test')
+ assert.equal(a.version, 2)
+ assert.deepEqual(a.props, { text: 'hi' })
+ // Ids are unique across a whole document. A counter would re-issue an id after
+ // a delete and the save would be refused for a reason nothing on screen explains.
+ assert.notEqual(a.id, b.id)
+})
+
+test('an unknown type yields nothing rather than a half-built block', () => {
+ assert.equal(newEmailBlock('email.nope'), null)
+ assert.equal(getEmailBlock('email.nope'), null)
+})
+
+// ── The sandbox: §4.6.2's security posture, as an attribute ────────────────
+
+test('the preview frame is sandboxed with no allow-scripts', () => {
+ const source = read('src/routes/admin/views/EngagementTemplates.jsx')
+
+ // It renders in an iframe at all — not into the page.
+ assert.match(source, /