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

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