From 3f9007056686a650a65741dc1f0e9aada845725b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 29 Aug 2026 18:13:57 -0500 Subject: [PATCH] feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.jsx | 8 +- client/src/api/client.js | 26 + client/src/emailBlocks/index.js | 12 + client/src/emailBlocks/registry.js | 100 +++ client/src/emailBlocks/types.jsx | 272 +++++++ client/src/routes/admin/AdminLayout.jsx | 18 +- .../routes/admin/views/EngagementSendLog.jsx | 172 +++++ .../admin/views/EngagementTemplates.jsx | 649 +++++++++++++++++ .../routes/admin/views/EngagementTriggers.jsx | 129 ++++ client/test/emailTemplates.test.js | 154 ++++ server/routes.guards.json | 72 ++ server/routes.manifest.json | 32 + server/src/emailBlocks/index.js | 2 + server/src/emailBlocks/registry.js | 18 + server/src/emailBlocks/types/itemList.js | 7 + server/src/emailBlocks/variables.js | 100 +++ server/src/engagement/templates.js | 36 +- .../model/engagement/engagementRules.model.js | 11 +- .../model/engagement/engagementSends.db.js | 53 +- .../engagement/engagementTemplates.db.js | 85 ++- .../engagement/engagementTemplates.model.js | 412 +++++++++++ .../router/v1/admin/engagement.controller.js | 144 ++++ .../src/router/v1/admin/engagement.router.js | 120 ++++ server/src/utils/mailer.js | 43 ++ server/swagger/swagger-output.json | 671 ++++++++++++++++++ server/test/emailTemplates.test.js | 24 + server/test/engagementTemplatesAdmin.test.js | 404 +++++++++++ 27 files changed, 3754 insertions(+), 20 deletions(-) create mode 100644 client/src/emailBlocks/index.js create mode 100644 client/src/emailBlocks/registry.js create mode 100644 client/src/emailBlocks/types.jsx create mode 100644 client/src/routes/admin/views/EngagementSendLog.jsx create mode 100644 client/src/routes/admin/views/EngagementTemplates.jsx create mode 100644 client/src/routes/admin/views/EngagementTriggers.jsx create mode 100644 client/test/emailTemplates.test.js create mode 100644 server/src/emailBlocks/variables.js create mode 100644 server/src/model/engagement/engagementTemplates.model.js create mode 100644 server/test/engagementTemplatesAdmin.test.js 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 }) => ( +
+ onChange({ ...props, level })} + options={[ + ['h1', 'Large'], + ['h2', 'Medium'], + ['h3', 'Small'], + ]} + /> + onChange({ ...props, text })} + /> +
+ ), +}) + +registerEmailBlock({ + type: 'email.text', + version: 1, + label: 'Paragraph', + icon: '¶', + hint: 'A paragraph of body text.', + defaults: () => ({ text: 'Write your message here.', muted: false }), + editor: ({ props, onChange, variables }) => ( +
+ onChange({ ...props, text })} + /> + + + +
+ ), +}) + +registerEmailBlock({ + type: 'email.button', + version: 1, + label: 'Button / link', + icon: '▭', + hint: 'The call to action. Its plain-text form is a sentence plus the URL.', + defaults: () => ({ label: 'Open', url: '/', textLead: 'Open it here:' }), + editor: ({ props, onChange, variables }) => ( +
+ onChange({ ...props, label })} + /> + onChange({ ...props, url })} + /> + onChange({ ...props, textLead })} + /> +
+ ), +}) + +registerEmailBlock({ + type: 'email.divider', + version: 1, + label: 'Divider', + icon: '—', + hint: 'A horizontal rule.', + defaults: () => ({}), + editor: () => ( +

+ A divider has nothing to configure. +

+ ), +}) + +registerEmailBlock({ + type: 'email.image', + version: 1, + label: 'Image', + icon: '▣', + hint: 'An image by URL. Many clients block images until the reader allows them.', + defaults: () => ({ url: '/brand/logo.png', alt: 'Logo' }), + editor: ({ props, onChange, variables }) => ( +
+ onChange({ ...props, url })} + /> + onChange({ ...props, alt })} + /> + + { + const next = { ...props } + const value = Number(e.target.value) + if (!e.target.value || !Number.isFinite(value)) delete next.width + else next.width = Math.trunc(value) + onChange(next) + }} + /> + +
+ ), +}) + +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 ( +
+ {lists.length ? ( + onChange({ ...props, variable })} + options={[['', 'Choose a list…'], ...lists.map((v) => [v.name, v.name])]} + /> + ) : ( + +

+ This template’s trigger declares no list variable, so an item list has nothing to + repeat over. Point the template at a trigger that declares one — a digest, typically — + or use paragraphs instead. +

+
+ )} + onChange({ ...props, emptyText })} + /> +
+ ) + }, +}) 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.'} +

+ ) : ( + <> +
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
WhenWhatToChannelResultDetail
+ {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}} + + {r.user_id ? user #{r.user_id} : } + + {r.channel} + {r.transport && · {r.transport}} + + {STATUS_LABEL[r.status] || r.status} + + {r.detail || ''} +
+
+ +
+ + {offset + 1}–{to} of {total} + +
+ + +
+
+ + )} +
+ ) +} 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 ( +
+