From 9e59d3de56728f6aefcac4fe2abff913fbea4564 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:28:20 +0000 Subject: [PATCH 1/6] feat(formula,plugin-email,lint)!: a mail template currency argument is a literal ISO 4217 code, refused at authoring and at render instead of formatting in USD `{{ x | currency:currency_code }}` passed every gate and rendered the amount in US dollars: the argument is not read as a path, Intl refused it, and the formatter substituted USD. The argument is now a literal code in three capital letters, judged once by `judgeHoleFormatter` in @objectstack/formula for both template dialects: - formula: the in-app template compile refuses the hole (`invalid-template` at os validate and the publish gate); `formatValue('currency', ...)` throws a RangeError instead of substituting USD. The mail hole grammar is exported once (`mailHolePattern`, `parseMailHole`, `judgeMailHoleGrammar`); the placement judge drops its copy. - lint: a gating rule `email-template-hole-grammar` judges subject, bodyText and bodyHtml on every command and the email_template publish door (422 INVALID_METADATA). - plugin-email: the renderer parses with the shared grammar and a refused hole fails the render with TEMPLATE_INVALID, naming the template and hole. No argument still formats in USD. Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude --- .changeset/26660-mail-hole-grammar-formula.md | 19 +++ .../26660-mail-hole-grammar-plugin-email.md | 18 +++ .changeset/26660-mail-hole-grammar.md | 19 +++ content/docs/automation/email-templates.mdx | 19 ++- packages/formula/src/index.ts | 7 +- packages/formula/src/mail-hole-grammar.ts | 102 +++++++++++++++ .../formula/src/mail-html-hole-placement.ts | 45 ++----- packages/formula/src/template-engine.ts | 86 +++++++++++-- .../formula/src/template-hole-grammar.test.ts | 116 ++++++++++++++++++ packages/lint/src/authoring-rules.ts | 24 ++++ packages/lint/src/index.ts | 9 ++ ...lidate-email-template-hole-grammar.test.ts | 101 +++++++++++++++ .../validate-email-template-hole-grammar.ts | 79 ++++++++++++ ...thoring-gate.email-template-writes.test.ts | 18 +++ .../plugins/plugin-email/src/email-service.ts | 26 +++- .../plugin-email/src/template-engine.ts | 53 +++----- .../src/template-hole-grammar.render.test.ts | 85 +++++++++++++ 17 files changed, 736 insertions(+), 90 deletions(-) create mode 100644 .changeset/26660-mail-hole-grammar-formula.md create mode 100644 .changeset/26660-mail-hole-grammar-plugin-email.md create mode 100644 .changeset/26660-mail-hole-grammar.md create mode 100644 packages/formula/src/mail-hole-grammar.ts create mode 100644 packages/formula/src/template-hole-grammar.test.ts create mode 100644 packages/lint/src/validate-email-template-hole-grammar.test.ts create mode 100644 packages/lint/src/validate-email-template-hole-grammar.ts create mode 100644 packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts diff --git a/.changeset/26660-mail-hole-grammar-formula.md b/.changeset/26660-mail-hole-grammar-formula.md new file mode 100644 index 00000000000..1c854f8f597 --- /dev/null +++ b/.changeset/26660-mail-hole-grammar-formula.md @@ -0,0 +1,19 @@ +--- +'@objectstack/formula': minor +--- + +feat(formula)!: the `currency` template formatter's argument is a literal ISO 4217 code — a path argument is refused at compile and never formats in US dollars + +Clause-②: no (narrowing) — the in-app template dialect's compile (and so `os validate` and the publish gate for every template slot) refuses a `{{ x | currency:ARG }}` hole whose `ARG` is not three capital letters, which it accepted before; `formatValue('currency', value, ARG)` throws a `RangeError` for such an `ARG` instead of formatting in US dollars. Nothing is widened. +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing in the template dialect, and a new shared mail hole grammar. + +- **Before.** `{{ record.amount | currency:currency_code }}` compiled; `Intl` refused the argument and the formatter caught that and used US dollars, so the amount printed in a currency nobody wrote. +- **Now.** `templateEngine.compile` / `evaluate` answer a `parse` error naming the hole and the rule. `formatValue('currency', …)` throws a `RangeError` for an argument that is not a code, before it reads the value. No argument still formats in US dollars. +- **Shared grammar.** `judgeHoleFormatter` is the judgement both template dialects use. The mail template grammar is exported once, `mailHolePattern`, `parseMailHole` and `judgeMailHoleGrammar`, and is what `@objectstack/plugin-email`'s renderer, `judgeMailHtmlHoles` and `@objectstack/lint` read; `judgeMailHtmlHoles` no longer keeps a copy of it. +- **Who sees it.** A template slot that wrote a path or a lowercase code as the currency argument. No template shipped in this repository does. + +**The remedy.** Write the code itself (`currency:EUR`), or format the amount where the data is assembled and pass the formatted text. diff --git a/.changeset/26660-mail-hole-grammar-plugin-email.md b/.changeset/26660-mail-hole-grammar-plugin-email.md new file mode 100644 index 00000000000..09109eebcdd --- /dev/null +++ b/.changeset/26660-mail-hole-grammar-plugin-email.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-email': minor +--- + +feat(plugin-email)!: a mail template hole whose `currency` argument is not a literal ISO 4217 code fails the render with `TEMPLATE_INVALID` instead of printing the amount in US dollars + +Clause-②: no (narrowing) — `sendTemplate` and `renderTemplate` refuse a stored template whose `subject`, `body_text` or `body_html` writes `{{ x | currency:ARG }}` with an `ARG` that is not three capital letters, which rendered before. Nothing is widened. +Metadata-protocol: narrowed + + + +**BREAKING** a render that printed a wrong currency now fails. + +- **Before.** `{{ planned_amount | currency:currency_code }}` with `currency_code = 'eur'` rendered `US$1,234.50` (zh-CN), `$1,234.50` (en-US) and `1 234,50 $US` (fr-FR), in every face. +- **Now.** The render throws `TEMPLATE_INVALID: email template NAME (locale=LOCALE): …`, naming the hole and the literal-only rule; `sendTemplate` sends nothing. The authoring doors refuse the same hole first (`email-template-hole-grammar`), so this is reached only by a template stored before that rule, until its next publish. +- **The grammar.** The renderer parses holes with `@objectstack/formula`'s `parseMailHole`, the grammar the authoring rule and the placement judge read; it keeps no copy of its own. + +**The remedy.** Republish the template with a literal code (`currency:EUR`), or format the amount in the caller and pass the formatted text as its own variable. diff --git a/.changeset/26660-mail-hole-grammar.md b/.changeset/26660-mail-hole-grammar.md new file mode 100644 index 00000000000..0dda9eee539 --- /dev/null +++ b/.changeset/26660-mail-hole-grammar.md @@ -0,0 +1,19 @@ +--- +'@objectstack/lint': minor +--- + +feat(lint)!: a new gating authoring rule, `email-template-hole-grammar`, refuses an email template hole whose `currency` argument is not a literal ISO 4217 code + +Clause-②: no (narrowing) — `os validate`, `os build`, `os lint` and the metadata publish door (`422 INVALID_METADATA`) refuse an `email_template` whose `subject`, `bodyText` or `bodyHtml` writes `{{ x | currency:ARG }}` with an `ARG` that is not three capital letters, which they accepted before. Nothing is widened. +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing at every authoring door for `email_template`. + +- **Before.** `{{ planned_amount | currency:currency_code }}` passed every gate. The argument is not read as a path, so the amount rendered in US dollars whatever the record's currency (`$1,234.50` for a record in euros). +- **Now.** The rule refuses that hole at `error`, naming it, the face and the template: the argument is a literal code in three capital letters (`currency:EUR`), never a path. A lowercase or empty argument is refused the same way. `currency` with no argument is unchanged (US dollars). +- **Which holes.** The grammar is `@objectstack/formula`'s `judgeMailHoleGrammar`, the one the mail renderer parses with; all three faces are judged. +- **Who sees it.** A template that wrote a path or a lowercase code as the currency argument. No template shipped in this repository does. + +**The remedy.** Write the code itself (`currency:EUR`). To show each record's own currency, format the amount in the caller that supplies the data and pass the formatted text as its own variable. diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index 863d635f68a..974934fa0ba 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -82,6 +82,15 @@ never needs triple braces. same way they do in-app. `datetime` honours the reference timezone the caller passes; calendar-day `date` values are timezone-naive. +**The `currency` argument is a literal code.** `currency:EUR` formats in euros, +and `currency` with no argument formats in US dollars. The argument is an ISO +4217 code in three capital letters, never a path: `currency:currency_code` does +not read the record's `currency_code`. `os validate`, `os build`, `os lint` and +the metadata publish door refuse any other argument (`email-template-hole-grammar`, +a `422 INVALID_METADATA` at the publish door), naming the hole and the rule. To +show an amount in each record's own currency, format it in the caller that +supplies the data and pass the formatted text as its own variable. + **Where a hole may sit in `bodyHtml`.** The renderer's escaping is right for element text and for a quoted attribute value that holds plain text, so those are the supported placements for `{{path}}`. A hole anywhere else — inside a @@ -94,10 +103,14 @@ the `href` shape above. The refusal (`email-template-hole-placement`, a supported ones. A template already stored keeps sending; it is judged the next time it is published. -Two properties of the renderer to author around: +Three properties of the renderer to author around: -- **A missing placeholder renders as an empty string.** Rendering never throws. - Declare a variable `required` (below) if absence should be an error instead. +- **A missing placeholder renders as an empty string.** Declare a variable + `required` (below) if absence should be an error instead. +- **A hole the authoring rules refuse is refused at render too.** A template + stored before a rule existed is judged at its next publish; until then + `sendTemplate` and `renderTemplate` fail with `TEMPLATE_INVALID`, naming the + template and the hole, rather than send a wrong value. - **There are no loops, conditionals or partials.** A template is a data-only rendering by design; branching belongs in the caller, which passes in the already-decided values. diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index 8a41367803f..61c749a6351 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -63,7 +63,12 @@ export type { ParseCelToAstOptions, } from './cel-engine'; export { cronEngine } from './cron-engine'; -export { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-engine'; +export { templateEngine, TEMPLATE_FORMATTERS, formatValue, judgeHoleFormatter } from './template-engine'; +export type { TemplateHoleGrammarRefusal, TemplateHoleGrammarRule } from './template-engine'; +// The mail template hole grammar — one copy, read by `@objectstack/plugin-email`'s +// renderer, the placement judge below and `@objectstack/lint`'s grammar rule. +export { mailHolePattern, parseMailHole, judgeMailHoleGrammar } from './mail-hole-grammar'; +export type { ParsedMailHole, MailHoleGrammarFinding } from './mail-hole-grammar'; // #25189 — where a hole may sit in a mail template's HTML face (ruling // 6335348115 on #25082, F1 A2 / F3 C2). Here because both readers already // depend on this package and on nothing else they share: `@objectstack/lint`'s diff --git a/packages/formula/src/mail-hole-grammar.ts b/packages/formula/src/mail-hole-grammar.ts new file mode 100644 index 00000000000..1c49fbbd0e3 --- /dev/null +++ b/packages/formula/src/mail-hole-grammar.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The mail template hole grammar — ONE copy, read by every reader of a mail + * template's holes: + * + * - `@objectstack/plugin-email`'s renderer (`template-engine.ts`), which + * substitutes the holes of every face (`subject`, `bodyText`, `bodyHtml`); + * - this package's placement judge (`mail-html-hole-placement.ts`), which + * classifies where each hole of `bodyHtml` sits; + * - `@objectstack/lint`'s `email-template-hole-grammar` authoring rule, which + * refuses a hole the grammar refuses at `os validate`, `os build`, `os lint` + * and the metadata publish door. + * + * A hole is `{{path}}`, `{{{path}}}` or either with one formatter from the + * shared whitelist, `{{ path | formatter[:arg] }}`. The path is a dotted path + * of word characters. Text between braces that is not a path is not a hole: + * the renderer leaves it as written. + * + * What follows the first `|` is judged by `judgeHoleFormatter` + * (`template-engine.ts`), the function the in-app template dialect judges its + * holes with too, so the two dialects refuse the same formatter parts with the + * same sentence. A refused hole is a hole — it is found, located and named — + * and the renderer refuses to render it rather than print a wrong value. + */ + +import { judgeHoleFormatter, type TemplateHoleGrammarRefusal } from './template-engine'; + +/** 1=open (`{{` or `{{{`), 2=brace-free inner body, 3=close (`}}` or `}}}`). */ +const MAIL_HOLE_SOURCE = String.raw`(\{\{\{?)([^{}]*)(\}\}\}?)`; + +/** + * A fresh global matcher for mail template holes. Capturing the inner body + * with `[^{}]*` (one star over a negated class, no nested quantifier) keeps + * the match linear — no polynomial-ReDoS shape; the body is then parsed with + * plain string operations ({@link parseMailHole}). A new instance per caller, + * so no two scans share a `lastIndex`. + */ +export function mailHolePattern(): RegExp { + return new RegExp(MAIL_HOLE_SOURCE, 'g'); +} + +// Validated against small, already-extracted strings (bounded input → no ReDoS). +const PATH_RE = /^[\w.]+$/; +const FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/; + +/** A mail hole's inner body, parsed. */ +export interface ParsedMailHole { + path: string; + formatter?: string; + /** The formatter's argument, quotes removed; absent when none was written. */ + arg?: string; + /** Set when the grammar refuses the hole: it is not rendered, and authoring refuses it. */ + refusal?: TemplateHoleGrammarRefusal; +} + +/** + * Parse a hole's inner body (the text between the braces). `null` when the + * body is not a path with an optional formatter — text the renderer leaves + * verbatim, and that no reader treats as a hole. + */ +export function parseMailHole(inner: string, hole = `{{${inner}}}`): ParsedMailHole | null { + const pipe = inner.indexOf('|'); + if (pipe === -1) { + const path = inner.trim(); + return PATH_RE.test(path) ? { path } : null; + } + const path = inner.slice(0, pipe).trim(); + if (!PATH_RE.test(path)) return null; + const formatterPart = inner.slice(pipe + 1).trim(); + const m = FILTER_RE.exec(formatterPart); + if (!m) return null; + const formatter = m[1]; + const arg = m[2]; + const refusal = judgeHoleFormatter(hole, formatterPart, formatter, arg); + return { + path, + formatter, + ...(arg !== undefined ? { arg } : {}), + ...(refusal ? { refusal } : {}), + }; +} + +/** One hole of a mail template face that the grammar refuses. */ +export interface MailHoleGrammarFinding { + /** Offset of the hole's first brace in the face. */ + start: number; + /** The hole as written, braces included. */ + text: string; + refusal: TemplateHoleGrammarRefusal; +} + +/** Every hole in one mail template face (`subject`, `bodyText` or `bodyHtml`) the grammar refuses, in order. */ +export function judgeMailHoleGrammar(face: string): MailHoleGrammarFinding[] { + if (typeof face !== 'string' || face === '') return []; + const findings: MailHoleGrammarFinding[] = []; + for (const m of face.matchAll(mailHolePattern())) { + const parsed = parseMailHole(m[2], m[0]); + if (parsed?.refusal) findings.push({ start: m.index ?? 0, text: m[0], refusal: parsed.refusal }); + } + return findings; +} diff --git a/packages/formula/src/mail-html-hole-placement.ts b/packages/formula/src/mail-html-hole-placement.ts index 2a6320f91be..95de7ccde72 100644 --- a/packages/formula/src/mail-html-hole-placement.ts +++ b/packages/formula/src/mail-html-hole-placement.ts @@ -40,14 +40,13 @@ * * ## Grammar parity with the renderer * - * {@link HOLE} and {@link parseHole} are the renderer's own hole grammar, - * spelled again here because the two packages share no module that could - * hold it (`plugin-email` depends on this package; this package depends on - * neither of its consumers). A copy that drifted would judge holes the - * renderer never substitutes, or miss ones it does, so the copy is pinned: - * `plugin-email`'s `text-field-html-outlets.pin.test.ts` renders a grammar - * corpus and every built-in body through the real renderer and requires the - * spans this judge finds to be exactly the spans the renderer replaces. + * The hole grammar is not spelled here: {@link mailHolePattern} and + * {@link parseMailHole} (`mail-hole-grammar.ts`) are the renderer's own, + * imported by `plugin-email`'s renderer from this package, so the spans this + * judge finds are the spans the renderer substitutes by construction. + * `plugin-email`'s `text-field-html-outlets.pin.test.ts` still renders a + * grammar corpus and every built-in body through the real renderer and holds + * the two to the same spans. * * ## Limits, stated so they are not read as more * @@ -62,31 +61,7 @@ * with the placement the template gives it. */ -// ─── The renderer's hole grammar (pinned to `plugin-email`'s template-engine.ts) ─── - -/** 1=open (`{{` or `{{{`), 2=brace-free inner body, 3=close (`}}` or `}}}`). */ -const HOLE = /(\{\{\{?)([^{}]*)(\}\}\}?)/g; -const PATH_RE = /^[\w.]+$/; -const FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/; - -interface ParsedHole { - path: string; - formatter?: string; -} - -/** A hole's inner body as the renderer parses it; null when the renderer leaves the text verbatim. */ -function parseHole(inner: string): ParsedHole | null { - const pipe = inner.indexOf('|'); - if (pipe === -1) { - const path = inner.trim(); - return PATH_RE.test(path) ? { path } : null; - } - const path = inner.slice(0, pipe).trim(); - if (!PATH_RE.test(path)) return null; - const m = FILTER_RE.exec(inner.slice(pipe + 1).trim()); - if (!m) return null; - return { path, formatter: m[1] }; -} +import { mailHolePattern, parseMailHole } from './mail-hole-grammar'; // ─── Placement classes ────────────────────────────────────────────── @@ -166,8 +141,8 @@ export function judgeMailHtmlHoles(html: string, options: MailHtmlHoleJudgeOptio if (typeof html !== 'string' || html === '') return []; const urlVariables = new Set(options.urlVariables ?? []); const found: Array & { formatter?: string }> = []; - for (const m of html.matchAll(HOLE)) { - const parsed = parseHole(m[2]); + for (const m of html.matchAll(mailHolePattern())) { + const parsed = parseMailHole(m[2], m[0]); if (!parsed) continue; const start = m.index ?? 0; found.push({ diff --git a/packages/formula/src/template-engine.ts b/packages/formula/src/template-engine.ts index a2e9acd8fba..310ab8703a5 100644 --- a/packages/formula/src/template-engine.ts +++ b/packages/formula/src/template-engine.ts @@ -68,16 +68,19 @@ const FORMATTERS: Record = { return new Intl.NumberFormat(locale, digits !== undefined && !Number.isNaN(digits) ? { minimumFractionDigits: digits, maximumFractionDigits: digits } : {}).format(n); }, - // currency | currency:EUR → defaults to USD + // currency | currency:EUR → no argument formats in USD; an argument is a + // literal ISO 4217 code. An argument that is not one (a path such as + // `currency_code`, a lowercase or empty code) is refused when the template + // is compiled or published (`judgeHoleFormatter`), and refused here too: it + // throws rather than format the amount in a currency nobody wrote. currency: (v, arg, locale) => { + if (arg !== undefined && !isCurrencyCode(arg)) { + throw new RangeError(currencyArgumentRefusal(`currency:${arg}`, arg)); + } const n = asNumber(v); if (n === undefined) return baseString(v); - const code = (arg && arg.trim()) || 'USD'; - try { - return new Intl.NumberFormat(locale, { style: 'currency', currency: code }).format(n); - } catch { - return new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' }).format(n); - } + const code = arg !== undefined ? arg.trim() : 'USD'; + return new Intl.NumberFormat(locale, { style: 'currency', currency: code }).format(n); }, // percent | percent:1 → 0.42 → "42%" (value is a 0..1 ratio) percent: (v, arg, locale) => { @@ -134,6 +137,67 @@ const FORMATTERS: Record = { /** Public list of whitelisted template formatters (for introspection/docs). */ export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS); +// ───────────────────────── hole grammar both dialects share ─────────────── +// +// The rules about a hole's formatter part, judged by this one function for +// both template dialects: this engine's (in-app slots, ADR-0032 §3) and the +// mail renderer's (`mail-hole-grammar.ts`, read by `@objectstack/plugin-email` +// and by `@objectstack/lint`'s email template rule). Each dialect parses its +// own path; what follows the first `|` is judged here, once. +// +// - The `currency` argument is a literal ISO 4217 code in capitals. A path +// (`currency:currency_code`) is not read: `Intl` refused it and the +// formatter used to substitute USD, so the amount printed in a currency +// nobody wrote. Reading a code from the data is a grammar widening this +// function does not make. + +/** The rule a refused hole breaks. */ +export type TemplateHoleGrammarRule = 'currency-code-literal'; + +/** A hole the shared grammar refuses, with the sentence an author is shown. */ +export interface TemplateHoleGrammarRefusal { + rule: TemplateHoleGrammarRule; + /** Names the hole as written, the rule it breaks and the remedy. */ + message: string; +} + +/** An ISO 4217 alphabetic code: three capital letters, the form `Intl` formats with. */ +const CURRENCY_CODE_RE = /^[A-Z]{3}$/; + +function isCurrencyCode(arg: string): boolean { + return CURRENCY_CODE_RE.test(arg.trim()); +} + +function currencyArgumentRefusal(hole: string, arg: string): string { + return ( + `the hole ${hole} gives the currency formatter the argument '${arg.trim()}', which is not an ISO 4217 ` + + 'currency code: the argument is a literal code in three capital letters (`currency:EUR`), and it is ' + + 'never read as a path to a value. Write the code itself, or format the amount in the caller that ' + + 'supplies the data and pass the formatted text as its own variable.' + ); +} + +/** + * Judge one hole's formatter against the grammar both template dialects share. + * + * @param hole the hole as written, braces included — the refusal names it + * @param _formatterPart the text after the hole's first `|`, trimmed + * @param formatter the formatter name the dialect parsed + * @param arg the argument the dialect parsed, quotes removed; `undefined` when none was written + * @returns the refusal, or `null` when the hole is within the grammar + */ +export function judgeHoleFormatter( + hole: string, + _formatterPart: string, + formatter: string, + arg: string | undefined, +): TemplateHoleGrammarRefusal | null { + if (formatter === 'currency' && arg !== undefined && !isCurrencyCode(arg)) { + return { rule: 'currency-code-literal', message: currencyArgumentRefusal(hole, arg) }; + } + return null; +} + /** * Apply a whitelisted formatter to a value, the single source of truth for * value→string semantics across dialects. Returns `undefined` for an unknown @@ -182,6 +246,8 @@ function resolvePath(scope: Record, path: string): unknown { interface ParsedHole { path: string; filter?: { name: string; arg?: string }; + /** Set when the hole is a path and formatter the shared grammar refuses ({@link judgeHoleFormatter}). */ + refusal?: TemplateHoleGrammarRefusal; } /** @@ -220,7 +286,8 @@ function parseHole(inner: string): ParsedHole | null { arg = filterPart.slice(colon + 1).trim().replace(/^['"]|['"]$/g, ''); } if (!FORMATTERS[name]) return null; - return { path, filter: { name, arg } }; + const refusal = judgeHoleFormatter(`{{${inner}}}`, filterPart, name, arg); + return { path, filter: { name, arg }, ...(refusal ? { refusal } : {}) }; } function compileTemplate(source: string): EvalResult { @@ -246,6 +313,9 @@ function compileTemplate(source: string): EvalResult { }, }; } + if (parsed.refusal) { + return { ok: false, error: { kind: 'parse', message: `invalid template hole: ${parsed.refusal.message}` } }; + } holes.push(parsed); } return { ok: true, value: holes }; diff --git a/packages/formula/src/template-hole-grammar.test.ts b/packages/formula/src/template-hole-grammar.test.ts new file mode 100644 index 00000000000..77a5eeae433 --- /dev/null +++ b/packages/formula/src/template-hole-grammar.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The hole grammar both template dialects share (`judgeHoleFormatter`), read + * through each dialect's own door: the in-app engine's compile/evaluate, and + * the mail grammar (`parseMailHole` / `judgeMailHoleGrammar`) that the mail + * renderer, the placement judge and the lint rule read. + * + * The `currency` argument is a literal ISO 4217 code. A path is never read as + * one: `Intl` refused it, and the formatter used to substitute USD. + */ +import { describe, expect, it } from 'vitest'; + +import { judgeMailHoleGrammar, parseMailHole } from './mail-hole-grammar'; +import { formatValue, judgeHoleFormatter, templateEngine } from './template-engine'; +import { validateExpression } from './validate'; + +const evaluate = (source: string, record: Record) => + templateEngine.evaluate({ dialect: 'template', source }, { record, locale: 'en-US' }); + +describe('the currency argument is a literal ISO 4217 code', () => { + it.each([ + ['a path', 'currency_code'], + ['a dotted path', 'record.currency_code'], + ['a lowercase code', 'eur'], + ['a code of the wrong length', 'EURO'], + ['an empty argument', ''], + ])('refuses %s, naming the literal-only rule', (_label, arg) => { + const refusal = judgeHoleFormatter(`{{ x | currency:${arg} }}`, `currency:${arg}`, 'currency', arg); + expect(refusal?.rule).toBe('currency-code-literal'); + expect(refusal?.message).toContain('ISO 4217'); + expect(refusal?.message).toContain('never read as a path'); + }); + + it('admits a capital three-letter code, quoted or bare, and no argument at all', () => { + expect(judgeHoleFormatter('{{ x | currency:EUR }}', 'currency:EUR', 'currency', 'EUR')).toBeNull(); + expect(judgeHoleFormatter("{{ x | currency: 'USD' }}", "currency: 'USD'", 'currency', 'USD')).toBeNull(); + expect(judgeHoleFormatter('{{ x | currency }}', 'currency', 'currency', undefined)).toBeNull(); + }); + + it('judges no other formatter\'s argument', () => { + expect(judgeHoleFormatter('{{ x | number:2 }}', 'number:2', 'number', '2')).toBeNull(); + expect(judgeHoleFormatter('{{ x | default:currency_code }}', 'default:currency_code', 'default', 'currency_code')).toBeNull(); + }); +}); + +describe('the in-app template dialect', () => { + it('⭐ compile refuses a path argument to currency, naming the hole and the rule', () => { + const r = templateEngine.compile('Due {{ record.amount | currency:currency_code }}'); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.error.kind).toBe('parse'); + expect(r.error.message).toContain('{{ record.amount | currency:currency_code }}'); + expect(r.error.message).toContain('ISO 4217'); + }); + + it('the authoring validator (`os validate`, the publish gate) refuses it as invalid-template', () => { + const r = validateExpression('template', { dialect: 'template', source: 'Due {{ record.amount | currency:currency_code }}' }); + expect(r.ok).toBe(false); + expect(r.errors.map((e) => e.code)).toEqual(['invalid-template']); + expect(r.errors[0].message).toContain('ISO 4217'); + }); + + it('evaluate refuses the same hole — it never formats the amount in USD', () => { + const r = evaluate('{{ record.amount | currency:currency_code }}', { amount: 1234.5, currency_code: 'EUR' }); + expect(r.ok).toBe(false); + }); + + it('a literal code still formats in that currency, and no argument still formats in USD', () => { + const eur = evaluate('{{ record.amount | currency:EUR }}', { amount: 1234.5 }); + expect(eur).toEqual({ ok: true, value: '€1,234.50' }); + const usd = evaluate('{{ record.amount | currency }}', { amount: 1234.5 }); + expect(usd).toEqual({ ok: true, value: '$1,234.50' }); + }); +}); + +describe('the formatter itself never substitutes a currency', () => { + it('⭐ throws on an argument that is not a code, instead of formatting in USD', () => { + expect(() => formatValue('currency', 1234.5, 'currency_code', { locale: 'en-US' })).toThrow(RangeError); + expect(() => formatValue('currency', 1234.5, 'currency_code', { locale: 'en-US' })).toThrow(/ISO 4217/); + // …even for a value it would otherwise print empty, so the refusal does not depend on the data. + expect(() => formatValue('currency', null, 'currency_code')).toThrow(RangeError); + }); + + it('formats a literal code, and the no-argument default, as before', () => { + expect(formatValue('currency', 1234.5, 'EUR', { locale: 'en-US' })).toBe('€1,234.50'); + expect(formatValue('currency', 1234.5, undefined, { locale: 'en-US' })).toBe('$1,234.50'); + }); +}); + +describe('the mail hole grammar', () => { + it('parses a currency path argument as a hole, carrying the refusal', () => { + const parsed = parseMailHole(' amount | currency:currency_code '); + expect(parsed).toMatchObject({ path: 'amount', formatter: 'currency', arg: 'currency_code' }); + expect(parsed?.refusal?.rule).toBe('currency-code-literal'); + expect(parsed?.refusal?.message).toContain('{{ amount | currency:currency_code }}'); + }); + + it('leaves text that is not a path unparsed — not a hole, nothing refused', () => { + expect(parseMailHole(' not a path ')).toBeNull(); + expect(judgeMailHoleGrammar('{{ not a path | currency:x }}')).toEqual([]); + }); + + it('finds every refused hole of a face with its offset, and admits a literal code', () => { + const face = 'Due {{ amount | currency:EUR }}, or {{ amount | currency:currency_code }}'; + const findings = judgeMailHoleGrammar(face); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ text: '{{ amount | currency:currency_code }}', start: face.indexOf('{{ amount | currency:currency_code') }); + expect(findings[0].refusal.rule).toBe('currency-code-literal'); + }); + + it('a triple-braced hole is judged too', () => { + expect(judgeMailHoleGrammar('{{{ amount | currency:currency_code }}}').map((f) => f.refusal.rule)) + .toEqual(['currency-code-literal']); + }); +}); diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 9d4e5d3ae02..946039d0df7 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -151,6 +151,7 @@ import { lintAutonumberFormats } from './lint-autonumber-formats.js'; import { lintViewRefs } from './lint-view-refs.js'; import { validateRetiredPermissionResidue } from './validate-retired-permission-residue.js'; import { validateEmailTemplateHolePlacements } from './validate-email-template-hole-placements.js'; +import { validateEmailTemplateHoleGrammar } from './validate-email-template-hole-grammar.js'; import { lintUniqueDeclarations, lintUnscopedDeclaredIndexes, @@ -2437,6 +2438,29 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ runtimeTypes: ['email_template'], run: (stack) => validateEmailTemplateHolePlacements(stack), }, + // Every hole of an email template's three faces is within the mail hole + // grammar — `@objectstack/formula`'s `judgeMailHoleGrammar`, the grammar the + // mail renderer parses with. A `currency` argument that is not a literal + // ISO 4217 code is refused, naming the literal-only rule; the renderer used + // to print such an amount in USD. Gating: no reading of such a hole renders + // as its author wrote it. + // + // Runtime publish gate: the same door and shape as the placement rule above + // — each written template is judged on its own, so the door's verdict and + // the whole-stack verdict are one value. A stored template is judged at its + // next publish, and until then the renderer refuses the hole at send + // (`TEMPLATE_INVALID`). MEASURED first: no shipped template, example or + // fixture in this repository writes a hole it refuses. + { + name: 'validateEmailTemplateHoleGrammar', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-email-template-hole-grammar.ts', + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['email_template'], + run: (stack) => validateEmailTemplateHoleGrammar(stack), + }, ]; // ─── Runner ───────────────────────────────────────────────────────── diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index f5d0097e4a3..5755c0d29c5 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -994,6 +994,15 @@ export { } from './validate-email-template-hole-placements.js'; export type { EmailTemplateHolePlacementFinding } from './validate-email-template-hole-placements.js'; +// Every hole of an email template is within the mail hole grammar +// (`@objectstack/formula`'s `judgeMailHoleGrammar`). The rule id is published +// for `--json` consumers (`rule-id-barrel-exports.test.ts`). +export { + validateEmailTemplateHoleGrammar, + EMAIL_TEMPLATE_HOLE_GRAMMAR, +} from './validate-email-template-hole-grammar.js'; +export type { EmailTemplateHoleGrammarFinding } from './validate-email-template-hole-grammar.js'; + export { lintAutonumberFormats } from './lint-autonumber-formats.js'; export type { AutonumberLintFinding } from './lint-autonumber-formats.js'; export { diff --git a/packages/lint/src/validate-email-template-hole-grammar.test.ts b/packages/lint/src/validate-email-template-hole-grammar.test.ts new file mode 100644 index 00000000000..859b2a317c9 --- /dev/null +++ b/packages/lint/src/validate-email-template-hole-grammar.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `validateEmailTemplateHoleGrammar` on every authoring door: the three CLI + * commands, and the runtime publish gate for an `email_template` write. + * + * Which holes the grammar refuses is `@objectstack/formula`'s to decide + * (`judgeMailHoleGrammar`, pinned in its own `template-hole-grammar.test.ts`); + * this file holds the rule: it reads all three faces of every template, + * refuses at `error` on every door, names the hole and the rule, and judges a + * written template on its own. + */ +import { describe, expect, it } from 'vitest'; + +import { AUTHORING_COMMANDS, AUTHORING_RULES, runAuthoringRules } from './authoring-rules.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; +import { + EMAIL_TEMPLATE_HOLE_GRAMMAR, + validateEmailTemplateHoleGrammar, +} from './validate-email-template-hole-grammar.js'; + +const template = (over: Record = {}) => ({ + name: 'contract.overdue', + label: 'Instalment overdue', + locale: 'en-US', + subject: 'Due {{ planned_amount | currency:EUR }}', + bodyHtml: '

{{ planned_amount | currency:EUR }} for {{ contract.name }}

', + bodyText: 'Due {{ planned_amount | currency }}', + ...over, +}); + +const CURRENCY_PATH = '{{ planned_amount | currency:currency_code }}'; + +describe('the rule, on the stack', () => { + it('a template whose every hole is within the grammar draws nothing', () => { + expect(validateEmailTemplateHoleGrammar({ emailTemplates: [template()] })).toEqual([]); + }); + + it.each(['subject', 'bodyText', 'bodyHtml'])('⭐ refuses a currency path argument in %s, naming the hole and the literal-only rule', (face) => { + const findings = validateEmailTemplateHoleGrammar({ emailTemplates: [template({ [face]: `Due ${CURRENCY_PATH}` })] }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ severity: 'error', rule: EMAIL_TEMPLATE_HOLE_GRAMMAR, path: `emailTemplates[0].${face}` }); + expect(findings[0].where).toContain('contract.overdue'); + expect(findings[0].message).toContain(CURRENCY_PATH); + expect(findings[0].message).toContain('ISO 4217'); + expect(findings[0].message).toContain('never read as a path'); + }); + + it('one finding per refused hole, located on its own template and face', () => { + const findings = validateEmailTemplateHoleGrammar({ + emailTemplates: [ + template(), + template({ name: 'contract.digest', subject: CURRENCY_PATH, bodyHtml: `

${CURRENCY_PATH} ${CURRENCY_PATH}

` }), + ], + }); + expect(findings.map((f) => f.path)).toEqual([ + 'emailTemplates[1].subject', + 'emailTemplates[1].bodyHtml', + 'emailTemplates[1].bodyHtml', + ]); + }); + + it('a name-keyed collection is located by name; no templates draws nothing', () => { + const { name: _name, ...rest } = template({ subject: CURRENCY_PATH }); + expect(validateEmailTemplateHoleGrammar({ emailTemplates: { 'contract.overdue': rest } }).map((f) => f.path)) + .toEqual(['emailTemplates.contract.overdue.subject']); + expect(validateEmailTemplateHoleGrammar({})).toEqual([]); + }); +}); + +describe('the registry entry', () => { + const entry = AUTHORING_RULES.find((r) => r.name === 'validateEmailTemplateHoleGrammar'); + + it('is gating, on all three commands and the runtime publish gate for email_template writes', () => { + expect(entry).toMatchObject({ + tier: 'gating', + commands: [...AUTHORING_COMMANDS], + surfaces: ['cli', 'runtime-publish'], + runtimeTypes: ['email_template'], + }); + }); + + it.each([...AUTHORING_COMMANDS])('os %s refuses the hole at error', (command) => { + const stack = { emailTemplates: [template({ subject: CURRENCY_PATH })] }; + const errors = runAuthoringRules(command, { normalized: stack, parsed: stack }) + .filter((f) => f.severity === 'error' && f.rule === EMAIL_TEMPLATE_HOLE_GRAMMAR); + expect(errors).toHaveLength(1); + }); +}); + +describe('the runtime publish gate, for an email_template write', () => { + it('⭐ refuses the written template, located on the written template itself', () => { + const result = runRuntimeAuthoringRules({ type: 'email_template', item: template({ bodyText: CURRENCY_PATH }) }); + expect(result.rulesRun).toContain('validateEmailTemplateHoleGrammar'); + expect(result.errors.map((f) => [f.rule, f.path])).toEqual([[EMAIL_TEMPLATE_HOLE_GRAMMAR, 'emailTemplates[0].bodyText']]); + }); + + it('publishes a template whose every hole is within the grammar', () => { + expect(runRuntimeAuthoringRules({ type: 'email_template', item: template() }).errors).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-email-template-hole-grammar.ts b/packages/lint/src/validate-email-template-hole-grammar.ts new file mode 100644 index 00000000000..6dcd1737787 --- /dev/null +++ b/packages/lint/src/validate-email-template-hole-grammar.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every hole of an email template is within the mail hole grammar. + * + * ## The rule + * + * A mail template hole is a path with at most one formatter from the shared + * whitelist, `{{ path | formatter[:arg] }}`, and a formatter argument the + * renderer can honour as written. A hole the grammar refuses never renders as + * its author meant it: a `currency` argument that is not a literal ISO 4217 + * code (`currency:currency_code`, a path) is not read as a path — the renderer + * used to print the amount in USD instead. + * + * WHICH holes are refused is not decided here: it is `@objectstack/formula`'s + * `judgeMailHoleGrammar`, the grammar the mail renderer itself parses with, + * whose formatter judgement (`judgeHoleFormatter`) the in-app template dialect + * shares. This module only walks the collection and words the finding. + * + * ## Where it runs + * + * Every authoring door: `os validate`, `os build`, `os lint`, and the runtime + * publish gate (an ACTIVE save, and a draft's promotion) for `email_template` + * writes, refused there with the existing `422 INVALID_METADATA`. A draft is + * not judged. A stored template that predates the rule is judged at its next + * publish; until then the renderer refuses the hole with `TEMPLATE_INVALID` + * rather than print a wrong value. + * + * All three faces are judged — `subject`, `bodyText` and `bodyHtml` — because + * the grammar is the same in each; where a hole may sit in the html face is the + * placement rule's question (`validate-email-template-hole-placements.ts`). + */ + +import { judgeMailHoleGrammar } from '@objectstack/formula'; + +import { collectionEntries } from './collection-entries.js'; + +type AnyRec = Record; + +export const EMAIL_TEMPLATE_HOLE_GRAMMAR = 'email-template-hole-grammar'; + +export interface EmailTemplateHoleGrammarFinding { + severity: 'error'; + rule: typeof EMAIL_TEMPLATE_HOLE_GRAMMAR; + where: string; + /** Positional config path, e.g. `emailTemplates[0].subject`. */ + path: string; + message: string; + hint: string; +} + +/** The faces a mail template renders, in the order a finding reports them. */ +const FACES = ['subject', 'bodyText', 'bodyHtml'] as const; + +export function validateEmailTemplateHoleGrammar(stack: AnyRec): EmailTemplateHoleGrammarFinding[] { + const findings: EmailTemplateHoleGrammarFinding[] = []; + for (const { rec: template, path } of collectionEntries(stack.emailTemplates, 'emailTemplates')) { + const name = typeof template.name === 'string' ? template.name : '(unnamed)'; + const locale = typeof template.locale === 'string' ? ` [${template.locale}]` : ''; + for (const face of FACES) { + const text = template[face]; + if (typeof text !== 'string') continue; + for (const hole of judgeMailHoleGrammar(text)) { + findings.push({ + severity: 'error', + rule: EMAIL_TEMPLATE_HOLE_GRAMMAR, + where: `email template "${name}"${locale}`, + path: `${path}.${face}`, + message: `${face}: ${hole.refusal.message} (offset ${hole.start})`, + hint: + 'A mail template hole is `{{ path }}` or `{{ path | formatter[:arg] }}`: one formatter, and a ' + + '`currency` argument that is a literal ISO 4217 code such as EUR. Anything more belongs in the ' + + 'caller that supplies the data.', + }); + } + } + } + return findings; +} diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts b/packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts index 35994189e7f..ce4d10c5a32 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts @@ -70,6 +70,24 @@ describe('the host threads an email_template publish to the runtime gate (#25189 expect((verdict.error as { rulesRun?: string[] }).rulesRun).toContain('validateEmailTemplateHolePlacements'); }); + it('⭐ LIT — a currency argument that is not a literal ISO 4217 code is refused in the same envelope, naming the rule', () => { + const verdict = publishTemplate(cleanTemplate({ subject: 'Due {{ amount | currency:currency_code }}' })); + + expect(verdict.error, 'the caller throws this; null here means the door judged nothing').not.toBeNull(); + expect((verdict.error as { code?: string }).code).toBe('INVALID_METADATA'); + expect((verdict.error as { status?: number }).status).toBe(422); + + const issues = (verdict.error as { issues?: Array> }).issues!; + expect(issues.map((i) => [i.rule, i.path])).toEqual([['email-template-hole-grammar', 'emailTemplates[0].subject']]); + const wire = JSON.stringify(issues); + expect(wire).toContain('{{ amount | currency:currency_code }}'); + expect(wire).toContain('ISO 4217'); + expect((verdict.error as { rulesRun?: string[] }).rulesRun).toContain('validateEmailTemplateHoleGrammar'); + + // Non-vacuous: the literal code publishes. + expect(publishTemplate(cleanTemplate({ subject: 'Due {{ amount | currency:EUR }}' })).error).toBeNull(); + }); + it('the same template publishes once the hole is moved to a supported placement', () => { const verdict = publishTemplate(cleanTemplate()); expect(verdict.error, 'the refusal is about the placement, not about the type').toBeNull(); diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index 279f67336b2..95f2c31d685 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -1461,11 +1461,27 @@ export class EmailService implements IEmailService { // parameter named `amp;callbackURL` and lost the post-verification // redirect. The derived fallback was already right: `htmlToText` decodes // the entities the HTML render introduced. - const subject = renderPlainTextTemplate(row.subject, data, renderOpts); - const html = renderTemplate(row.body_html, data, renderOpts); - const text = row.body_text - ? renderPlainTextTemplate(row.body_text, data, renderOpts) - : htmlToText(html); + // + // A hole the shared grammar refuses throws `TEMPLATE_INVALID` from the + // renderer: this names the template and its locale in front of the hole, + // and the send or render fails rather than deliver a wrong value. + let subject: string; + let html: string; + let text: string; + try { + subject = renderPlainTextTemplate(row.subject, data, renderOpts); + html = renderTemplate(row.body_html, data, renderOpts); + text = row.body_text + ? renderPlainTextTemplate(row.body_text, data, renderOpts) + : htmlToText(html); + } catch (err: any) { + const message = String(err?.message ?? err); + if (!message.startsWith('TEMPLATE_INVALID: ')) throw err; + throw new Error( + `TEMPLATE_INVALID: email template ${input.template} (locale=${row.locale}): ` + + message.slice('TEMPLATE_INVALID: '.length), + ); + } return { row, rendered: { subject, html, text } }; } diff --git a/packages/plugins/plugin-email/src/template-engine.ts b/packages/plugins/plugin-email/src/template-engine.ts index 86d358762d5..d7dfe14da5c 100644 --- a/packages/plugins/plugin-email/src/template-engine.ts +++ b/packages/plugins/plugin-email/src/template-engine.ts @@ -24,8 +24,17 @@ * whitelist — `{{ order.total | currency:EUR }}`, `{{ ts | datetime }}` — * reusing `@objectstack/formula`'s `formatValue` so dates, money, and * (ADR-0053 Phase 2) reference-timezone `datetime` render identically to - * in-app templates. An unknown formatter falls back to the raw string, - * keeping the lenient "never throw on render" contract. + * in-app templates. An unknown formatter falls back to the raw string. + * + * The hole grammar is `@objectstack/formula`'s (`mailHolePattern`, + * `parseMailHole`), the one copy the authoring rule and the placement judge + * read too. A hole that grammar REFUSES — a `currency` argument that is not a + * literal ISO 4217 code — is refused at authoring and publish; one that still + * reaches the renderer (a template stored before the rule existed, or a body + * handed to these functions directly) makes the render throw + * `TEMPLATE_INVALID`, naming the hole and the rule. That is the one way a + * render throws: it prints neither a substituted currency nor formatter + * syntax to a recipient. * * Deliberately tiny (no loops / conditionals / partials) — the design * stance is that email templates SHOULD be data-only renderings; any @@ -34,15 +43,7 @@ * runtime; we resist that until a real use case demands it. */ -import { formatValue } from '@objectstack/formula'; - -// Match a hole: open braces, a BRACE-FREE inner body, close braces. Capturing -// the inner with `[^{}]*` (a single star over a negated class — no nested -// quantifier, no overlapping `\s*` groups) keeps the matcher strictly linear: -// it can never backtrack into a polynomial-ReDoS shape. The inner body is then -// parsed with plain string ops (`parseHole`) instead of a complex pattern. -// 1=open(`{{`|`{{{`) 2=inner 3=close(`}}`|`}}}`). -const PLACEHOLDER = /(\{\{\{?)([^{}]*)(\}\}\}?)/g; +import { formatValue, mailHolePattern, parseMailHole } from '@objectstack/formula'; /** Locale + reference timezone for hole formatters (ADR-0053 Phase 2). */ export interface RenderOptions { @@ -50,31 +51,6 @@ export interface RenderOptions { timeZone?: string; } -// A hole body is a dotted path with an optional `| formatter[:arg]`. Validated -// against small, already-extracted strings (bounded input → no ReDoS). -const PATH_RE = /^[\w.]+$/; -const FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/; - -interface ParsedHole { - path: string; - formatter?: string; - arg?: string; -} - -/** Parse a hole's inner body into a path + optional formatter. Null if malformed. */ -function parseHole(inner: string): ParsedHole | null { - const pipe = inner.indexOf('|'); - if (pipe === -1) { - const path = inner.trim(); - return PATH_RE.test(path) ? { path } : null; - } - const path = inner.slice(0, pipe).trim(); - if (!PATH_RE.test(path)) return null; - const m = FILTER_RE.exec(inner.slice(pipe + 1).trim()); - if (!m) return null; - return { path, formatter: m[1], arg: m[2] }; -} - function lookup(data: Record, path: string): unknown { if (!path) return undefined; const parts = path.split('.'); @@ -137,10 +113,11 @@ function render( ): string { if (!template) return ''; return template.replace( - PLACEHOLDER, + mailHolePattern(), (match: string, open: string, inner: string, close: string) => { - const parsed = parseHole(inner); + const parsed = parseMailHole(inner, match); if (!parsed) return match; // not a path[+formatter] hole — leave verbatim + if (parsed.refusal) throw new Error(`TEMPLATE_INVALID: ${parsed.refusal.message}`); const isUnescaped = open === '{{{' && close === '}}}'; const raw = lookup(data, parsed.path); let str: string; diff --git a/packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts b/packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts new file mode 100644 index 00000000000..7932e2117f7 --- /dev/null +++ b/packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The renderer reads `@objectstack/formula`'s mail hole grammar and refuses a + * hole it refuses, rather than print a wrong value. Authoring refuses such a + * hole first (`email-template-hole-grammar`, `@objectstack/lint`); this is the + * guard for a template that reaches the renderer anyway — one stored before + * the rule existed. + * + * Measured through the surviving by-name doors, `EmailService.renderTemplate` + * and `sendTemplate`, over all three faces. + */ + +import { describe, expect, it } from 'vitest'; +import type { IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts'; +import type { EmailTemplateRow, TemplateLoader } from './index.js'; +import { EmailService } from './email-service.js'; + +function makeService(rows: EmailTemplateRow[]) { + const sent: NormalizedEmailMessage[] = []; + const transport: IEmailTransport = { + async send(message: NormalizedEmailMessage): Promise { + sent.push(message); + return { messageId: `msg-${sent.length}` }; + }, + }; + const templateLoader: TemplateLoader = { + async load(name) { + return rows.find((r) => r.name === name) ?? null; + }, + }; + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.com' }, + templateLoader, + persistence: { + async insert(r) { return { id: r.id }; }, + async update() { /* noop */ }, + }, + }); + return { svc, sent }; +} + +const row = (name: string, over: Partial): EmailTemplateRow => ({ + name, + locale: 'en-US', + subject: 'Notice', + body_html: '

Notice

', + active: true, + ...over, +}); + +const data = { planned_amount: 1234.5, currency_code: 'eur' }; + +describe('a currency argument that is not a literal ISO 4217 code', () => { + it.each([ + ['subject', { subject: 'Due {{ planned_amount | currency:currency_code }}' }], + ['body_html', { body_html: '

{{ planned_amount | currency:currency_code }}

' }], + ['body_text', { body_text: 'Due {{ planned_amount | currency:currency_code }}' }], + ])('⭐ in %s: the render is refused with TEMPLATE_INVALID, naming the template and the rule — never USD', async (_face, over) => { + const { svc } = makeService([row('contract.overdue', over)]); + const render = svc.renderTemplate({ template: 'contract.overdue', data }); + await expect(render).rejects.toThrow(/^TEMPLATE_INVALID: email template contract\.overdue \(locale=en-US\): /); + await expect(svc.renderTemplate({ template: 'contract.overdue', data })).rejects.toThrow(/ISO 4217/); + await expect(svc.renderTemplate({ template: 'contract.overdue', data })) + .rejects.toThrow('{{ planned_amount | currency:currency_code }}'); + }); + + it('sendTemplate refuses the same template and sends nothing', async () => { + const { svc, sent } = makeService([row('contract.overdue', { subject: 'Due {{ planned_amount | currency:currency_code }}' })]); + await expect(svc.sendTemplate({ template: 'contract.overdue', to: 'a@x.com', data })) + .rejects.toThrow(/^TEMPLATE_INVALID: /); + expect(sent).toEqual([]); + }); + + it('a literal code renders in that currency in every face, and no argument still renders USD', async () => { + const { svc } = makeService([row('contract.overdue', { + subject: 'Due {{ planned_amount | currency:EUR }}', + body_html: '

{{ planned_amount | currency:EUR }} / {{ planned_amount | currency }}

', + body_text: 'Due {{ planned_amount | currency:EUR }}', + })]); + const out = await svc.renderTemplate({ template: 'contract.overdue', data }); + expect(out).toEqual({ subject: 'Due €1,234.50', html: '

€1,234.50 / $1,234.50

', text: 'Due €1,234.50' }); + }); +}); From 44ccaf92152e6dfb913f49282d1b95b3e7b03b15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:31:13 +0000 Subject: [PATCH 2/6] feat(formula,plugin-email,lint)!: a template hole takes one formatter; a chained hole is refused at authoring and at render instead of printing formatter syntax `{{ x | default:0 | number:2 }}` passed every gate: the second `|` was read as part of `default`'s argument, so the hole printed `0 | number:2` for an empty value and the bare value otherwise, and `{{ x | upper | lower }}` was left on the page as written. `judgeHoleFormatter` now refuses a second formatter outside a quoted argument, for both template dialects: - formula: the in-app compile refuses it (`invalid-template`); the mail grammar recognises the chained hole even when its first formatter alone would not parse, so it is located and named, never left verbatim. - lint: `email-template-hole-grammar` refuses it on every command and the email_template publish door (422 INVALID_METADATA), naming the one-formatter rule and the caller remedy. - plugin-email: the render fails with TEMPLATE_INVALID. Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude --- .changeset/26660-mail-hole-grammar-formula.md | 13 ++--- .../26660-mail-hole-grammar-plugin-email.md | 11 ++-- .changeset/26660-mail-hole-grammar.md | 15 +++--- content/docs/automation/email-templates.mdx | 6 +++ packages/formula/src/mail-hole-grammar.ts | 15 ++++-- packages/formula/src/template-engine.ts | 51 ++++++++++++++++--- .../formula/src/template-hole-grammar.test.ts | 42 +++++++++++++++ packages/lint/src/authoring-rules.ts | 10 ++-- ...lidate-email-template-hole-grammar.test.ts | 16 ++++-- .../validate-email-template-hole-grammar.ts | 11 ++-- ...thoring-gate.email-template-writes.test.ts | 17 +++++++ .../plugin-email/src/template-engine.ts | 3 +- .../src/template-hole-grammar.render.test.ts | 34 +++++++++++++ 13 files changed, 204 insertions(+), 40 deletions(-) diff --git a/.changeset/26660-mail-hole-grammar-formula.md b/.changeset/26660-mail-hole-grammar-formula.md index 1c854f8f597..afe090c4303 100644 --- a/.changeset/26660-mail-hole-grammar-formula.md +++ b/.changeset/26660-mail-hole-grammar-formula.md @@ -2,18 +2,19 @@ '@objectstack/formula': minor --- -feat(formula)!: the `currency` template formatter's argument is a literal ISO 4217 code — a path argument is refused at compile and never formats in US dollars +feat(formula)!: a template hole takes one formatter, and the `currency` formatter's argument is a literal ISO 4217 code — both refused at compile, never rendered as formatter syntax or in US dollars -Clause-②: no (narrowing) — the in-app template dialect's compile (and so `os validate` and the publish gate for every template slot) refuses a `{{ x | currency:ARG }}` hole whose `ARG` is not three capital letters, which it accepted before; `formatValue('currency', value, ARG)` throws a `RangeError` for such an `ARG` instead of formatting in US dollars. Nothing is widened. +Clause-②: no (narrowing) — the in-app template dialect's compile (and so `os validate` and the publish gate for every template slot) refuses a hole with a second formatter (`{{ x | default:0 | number:2 }}`) and a `{{ x | currency:ARG }}` hole whose `ARG` is not three capital letters, both of which it accepted before; `formatValue('currency', value, ARG)` throws a `RangeError` for such an `ARG` instead of formatting in US dollars. Nothing is widened. Metadata-protocol: narrowed - + **BREAKING** accept-set narrowing in the template dialect, and a new shared mail hole grammar. -- **Before.** `{{ record.amount | currency:currency_code }}` compiled; `Intl` refused the argument and the formatter caught that and used US dollars, so the amount printed in a currency nobody wrote. +- **Before, a chained hole.** `{{ record.n | default:0 | number:2 }}` compiled as `default` with the argument `0 | number:2`, and rendered that text. +- **Before, a currency path.** `{{ record.amount | currency:currency_code }}` compiled; `Intl` refused the argument and the formatter caught that and used US dollars, so the amount printed in a currency nobody wrote. - **Now.** `templateEngine.compile` / `evaluate` answer a `parse` error naming the hole and the rule. `formatValue('currency', …)` throws a `RangeError` for an argument that is not a code, before it reads the value. No argument still formats in US dollars. - **Shared grammar.** `judgeHoleFormatter` is the judgement both template dialects use. The mail template grammar is exported once, `mailHolePattern`, `parseMailHole` and `judgeMailHoleGrammar`, and is what `@objectstack/plugin-email`'s renderer, `judgeMailHtmlHoles` and `@objectstack/lint` read; `judgeMailHtmlHoles` no longer keeps a copy of it. -- **Who sees it.** A template slot that wrote a path or a lowercase code as the currency argument. No template shipped in this repository does. +- **Who sees it.** A template slot that chained formatters, or wrote a path or a lowercase code as the currency argument. No template shipped in this repository does. -**The remedy.** Write the code itself (`currency:EUR`), or format the amount where the data is assembled and pass the formatted text. +**The remedy.** Keep one formatter per hole and decide anything more where the data is assembled. Write the currency code itself (`currency:EUR`), or format the amount where the data is assembled and pass the formatted text. diff --git a/.changeset/26660-mail-hole-grammar-plugin-email.md b/.changeset/26660-mail-hole-grammar-plugin-email.md index 09109eebcdd..4db97feaa8a 100644 --- a/.changeset/26660-mail-hole-grammar-plugin-email.md +++ b/.changeset/26660-mail-hole-grammar-plugin-email.md @@ -2,17 +2,18 @@ '@objectstack/plugin-email': minor --- -feat(plugin-email)!: a mail template hole whose `currency` argument is not a literal ISO 4217 code fails the render with `TEMPLATE_INVALID` instead of printing the amount in US dollars +feat(plugin-email)!: a mail template hole that chains a second formatter, or whose `currency` argument is not a literal ISO 4217 code, fails the render with `TEMPLATE_INVALID` instead of printing formatter syntax or US dollars -Clause-②: no (narrowing) — `sendTemplate` and `renderTemplate` refuse a stored template whose `subject`, `body_text` or `body_html` writes `{{ x | currency:ARG }}` with an `ARG` that is not three capital letters, which rendered before. Nothing is widened. +Clause-②: no (narrowing) — `sendTemplate` and `renderTemplate` refuse a stored template whose `subject`, `body_text` or `body_html` writes a hole with a second formatter, or `{{ x | currency:ARG }}` with an `ARG` that is not three capital letters, both of which rendered before. Nothing is widened. Metadata-protocol: narrowed **BREAKING** a render that printed a wrong currency now fails. -- **Before.** `{{ planned_amount | currency:currency_code }}` with `currency_code = 'eur'` rendered `US$1,234.50` (zh-CN), `$1,234.50` (en-US) and `1 234,50 $US` (fr-FR), in every face. -- **Now.** The render throws `TEMPLATE_INVALID: email template NAME (locale=LOCALE): …`, naming the hole and the literal-only rule; `sendTemplate` sends nothing. The authoring doors refuse the same hole first (`email-template-hole-grammar`), so this is reached only by a template stored before that rule, until its next publish. +- **Before, a chained hole.** `{{ actual_amount | default:0 | number:2 }}` rendered `0 | number:2` for an empty value and `71100` otherwise, in every face. +- **Before, a currency path.** `{{ planned_amount | currency:currency_code }}` with `currency_code = 'eur'` rendered `US$1,234.50` (zh-CN), `$1,234.50` (en-US) and `1 234,50 $US` (fr-FR), in every face. +- **Now.** The render throws `TEMPLATE_INVALID: email template NAME (locale=LOCALE): …`, naming the hole and the rule it breaks (one formatter per hole, or the literal-only currency code); `sendTemplate` sends nothing. The authoring doors refuse the same hole first (`email-template-hole-grammar`), so this is reached only by a template stored before that rule, until its next publish. - **The grammar.** The renderer parses holes with `@objectstack/formula`'s `parseMailHole`, the grammar the authoring rule and the placement judge read; it keeps no copy of its own. -**The remedy.** Republish the template with a literal code (`currency:EUR`), or format the amount in the caller and pass the formatted text as its own variable. +**The remedy.** Republish the template with one formatter per hole and a literal currency code (`currency:EUR`), or format the amount in the caller and pass the formatted text as its own variable. diff --git a/.changeset/26660-mail-hole-grammar.md b/.changeset/26660-mail-hole-grammar.md index 0dda9eee539..ff04ec7b71a 100644 --- a/.changeset/26660-mail-hole-grammar.md +++ b/.changeset/26660-mail-hole-grammar.md @@ -2,18 +2,19 @@ '@objectstack/lint': minor --- -feat(lint)!: a new gating authoring rule, `email-template-hole-grammar`, refuses an email template hole whose `currency` argument is not a literal ISO 4217 code +feat(lint)!: a new gating authoring rule, `email-template-hole-grammar`, refuses an email template hole that chains a second formatter or whose `currency` argument is not a literal ISO 4217 code -Clause-②: no (narrowing) — `os validate`, `os build`, `os lint` and the metadata publish door (`422 INVALID_METADATA`) refuse an `email_template` whose `subject`, `bodyText` or `bodyHtml` writes `{{ x | currency:ARG }}` with an `ARG` that is not three capital letters, which they accepted before. Nothing is widened. +Clause-②: no (narrowing) — `os validate`, `os build`, `os lint` and the metadata publish door (`422 INVALID_METADATA`) refuse an `email_template` whose `subject`, `bodyText` or `bodyHtml` writes a hole with a second formatter (`{{ x | default:0 | number:2 }}`) or `{{ x | currency:ARG }}` with an `ARG` that is not three capital letters, both of which they accepted before. Nothing is widened. Metadata-protocol: narrowed - + **BREAKING** accept-set narrowing at every authoring door for `email_template`. -- **Before.** `{{ planned_amount | currency:currency_code }}` passed every gate. The argument is not read as a path, so the amount rendered in US dollars whatever the record's currency (`$1,234.50` for a record in euros). -- **Now.** The rule refuses that hole at `error`, naming it, the face and the template: the argument is a literal code in three capital letters (`currency:EUR`), never a path. A lowercase or empty argument is refused the same way. `currency` with no argument is unchanged (US dollars). +- **Before, a chained hole.** `{{ actual_amount | default:0 | number:2 }}` passed every gate. The second `|` was read as part of `default`'s argument, so the hole printed the text `0 | number:2` for an empty value and the bare `71100` otherwise; `{{ x | upper | lower }}` stayed on the page as written. +- **Before, a currency path.** `{{ planned_amount | currency:currency_code }}` passed every gate. The argument is not read as a path, so the amount rendered in US dollars whatever the record's currency (`$1,234.50` for a record in euros). +- **Now.** The rule refuses both at `error`, naming the hole, the face and the template. A hole takes one formatter; anything more is decided in the caller that supplies the data. The currency argument is a literal code in three capital letters (`currency:EUR`), never a path. A lowercase or empty argument is refused the same way. `currency` with no argument is unchanged (US dollars). - **Which holes.** The grammar is `@objectstack/formula`'s `judgeMailHoleGrammar`, the one the mail renderer parses with; all three faces are judged. -- **Who sees it.** A template that wrote a path or a lowercase code as the currency argument. No template shipped in this repository does. +- **Who sees it.** A template that chained formatters, or wrote a path or a lowercase code as the currency argument. No template shipped in this repository does. -**The remedy.** Write the code itself (`currency:EUR`). To show each record's own currency, format the amount in the caller that supplies the data and pass the formatted text as its own variable. +**The remedy.** Keep one formatter per hole and pass any further decided value (a default, a second format) as its own variable. Write the currency code itself (`currency:EUR`). To show each record's own currency, format the amount in the caller that supplies the data and pass the formatted text as its own variable. diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index 974934fa0ba..f73ab4e0bdd 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -82,6 +82,12 @@ never needs triple braces. same way they do in-app. `datetime` honours the reference timezone the caller passes; calendar-day `date` values are timezone-naive. +**One formatter per hole.** `{{ amount | default:0 | number:2 }}` is refused by +`os validate`, `os build`, `os lint` and the metadata publish door +(`email-template-hole-grammar`): a hole takes one formatter, and there is no +chaining. Decide the value in the caller that supplies the data — a default, a +second format — and pass the finished value as its own variable. + **The `currency` argument is a literal code.** `currency:EUR` formats in euros, and `currency` with no argument formats in US dollars. The argument is an ISO 4217 code in three capital letters, never a path: `currency:currency_code` does diff --git a/packages/formula/src/mail-hole-grammar.ts b/packages/formula/src/mail-hole-grammar.ts index 1c49fbbd0e3..3feb62f6eb0 100644 --- a/packages/formula/src/mail-hole-grammar.ts +++ b/packages/formula/src/mail-hole-grammar.ts @@ -20,8 +20,10 @@ * What follows the first `|` is judged by `judgeHoleFormatter` * (`template-engine.ts`), the function the in-app template dialect judges its * holes with too, so the two dialects refuse the same formatter parts with the - * same sentence. A refused hole is a hole — it is found, located and named — - * and the renderer refuses to render it rather than print a wrong value. + * same sentence: one formatter per hole, and a `currency` argument that is a + * literal ISO 4217 code. A refused hole is a hole — it is found, located and + * named — and the renderer refuses to render it rather than print a wrong + * value or formatter syntax. */ import { judgeHoleFormatter, type TemplateHoleGrammarRefusal } from './template-engine'; @@ -69,7 +71,14 @@ export function parseMailHole(inner: string, hole = `{{${inner}}}`): ParsedMailH if (!PATH_RE.test(path)) return null; const formatterPart = inner.slice(pipe + 1).trim(); const m = FILTER_RE.exec(formatterPart); - if (!m) return null; + if (!m) { + // `{{ x | upper | lower }}` matches no single formatter shape, and the + // renderer used to leave it on the page as written: a chained hole is + // still a hole, refused by the one-formatter rule. + const name = formatterPart.split(/[:|]/, 1)[0].trim(); + const refusal = judgeHoleFormatter(hole, formatterPart, name, undefined); + return refusal ? { path, formatter: name, refusal } : null; + } const formatter = m[1]; const arg = m[2]; const refusal = judgeHoleFormatter(hole, formatterPart, formatter, arg); diff --git a/packages/formula/src/template-engine.ts b/packages/formula/src/template-engine.ts index 310ab8703a5..c9852e7b557 100644 --- a/packages/formula/src/template-engine.ts +++ b/packages/formula/src/template-engine.ts @@ -145,14 +145,18 @@ export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS); // and by `@objectstack/lint`'s email template rule). Each dialect parses its // own path; what follows the first `|` is judged here, once. // +// - One formatter per hole. A second `|` used to be read as part of the first +// formatter's argument, so `{{ x | default:0 | number:2 }}` rendered the +// text `0 | number:2` — formatter syntax printed to the reader — and +// `{{ x | upper | lower }}` was left on the page as written. // - The `currency` argument is a literal ISO 4217 code in capitals. A path -// (`currency:currency_code`) is not read: `Intl` refused it and the -// formatter used to substitute USD, so the amount printed in a currency -// nobody wrote. Reading a code from the data is a grammar widening this -// function does not make. +// (`currency:currency_code`) is not read: `Intl` refused it and the +// formatter used to substitute USD, so the amount printed in a currency +// nobody wrote. Reading a code from the data is a grammar widening this +// function does not make. /** The rule a refused hole breaks. */ -export type TemplateHoleGrammarRule = 'currency-code-literal'; +export type TemplateHoleGrammarRule = 'one-formatter' | 'currency-code-literal'; /** A hole the shared grammar refuses, with the sentence an author is shown. */ export interface TemplateHoleGrammarRefusal { @@ -177,21 +181,49 @@ function currencyArgumentRefusal(hole: string, arg: string): string { ); } +/** + * True when the formatter part (the text after a hole's first `|`) carries a + * second `|` outside a quoted argument — a second formatter. A `|` inside a + * closed quoted argument (`default:'a | b'`) is the argument's own text. + */ +function chainsASecondFormatter(formatterPart: string): boolean { + const colon = formatterPart.indexOf(':'); + if (colon === -1) return formatterPart.includes('|'); + if (formatterPart.slice(0, colon).includes('|')) return true; + const arg = formatterPart.slice(colon + 1).trim(); + const quote = arg[0]; + if (quote === "'" || quote === '"') { + const close = arg.indexOf(quote, 1); + if (close !== -1) return arg.slice(close + 1).includes('|'); + } + return arg.includes('|'); +} + /** * Judge one hole's formatter against the grammar both template dialects share. * * @param hole the hole as written, braces included — the refusal names it - * @param _formatterPart the text after the hole's first `|`, trimmed + * @param formatterPart the text after the hole's first `|`, trimmed * @param formatter the formatter name the dialect parsed * @param arg the argument the dialect parsed, quotes removed; `undefined` when none was written * @returns the refusal, or `null` when the hole is within the grammar */ export function judgeHoleFormatter( hole: string, - _formatterPart: string, + formatterPart: string, formatter: string, arg: string | undefined, ): TemplateHoleGrammarRefusal | null { + if (chainsASecondFormatter(formatterPart)) { + return { + rule: 'one-formatter', + message: + `the hole ${hole} chains a second formatter: a hole takes one formatter, ` + + '`{{ path | formatter[:arg] }}`, and the text after the second `|` is never applied. Keep one ' + + 'formatter here, and do any further formatting or branching in the caller that supplies the data, ' + + 'passing the finished value as its own variable.', + }; + } if (formatter === 'currency' && arg !== undefined && !isCurrencyCode(arg)) { return { rule: 'currency-code-literal', message: currencyArgumentRefusal(hole, arg) }; } @@ -285,8 +317,11 @@ function parseHole(inner: string): ParsedHole | null { name = filterPart.slice(0, colon).trim(); arg = filterPart.slice(colon + 1).trim().replace(/^['"]|['"]$/g, ''); } - if (!FORMATTERS[name]) return null; const refusal = judgeHoleFormatter(`{{${inner}}}`, filterPart, name, arg); + // A chained hole is refused by the one-formatter rule before its first + // formatter's name is looked up (`upper | lower` names no formatter). + if (refusal?.rule === 'one-formatter') return { path, refusal }; + if (!FORMATTERS[name]) return null; return { path, filter: { name, arg }, ...(refusal ? { refusal } : {}) }; } diff --git a/packages/formula/src/template-hole-grammar.test.ts b/packages/formula/src/template-hole-grammar.test.ts index 77a5eeae433..e338959ad87 100644 --- a/packages/formula/src/template-hole-grammar.test.ts +++ b/packages/formula/src/template-hole-grammar.test.ts @@ -6,6 +6,8 @@ * the mail grammar (`parseMailHole` / `judgeMailHoleGrammar`) that the mail * renderer, the placement judge and the lint rule read. * + * One formatter per hole: a second `|` used to be swallowed into the first + * formatter's argument (`{{ x | default:0 | number:2 }}` printed `0 | number:2`). * The `currency` argument is a literal ISO 4217 code. A path is never read as * one: `Intl` refused it, and the formatter used to substitute USD. */ @@ -18,6 +20,46 @@ import { validateExpression } from './validate'; const evaluate = (source: string, record: Record) => templateEngine.evaluate({ dialect: 'template', source }, { record, locale: 'en-US' }); +describe('one formatter per hole', () => { + it.each([ + ['a second formatter after an argument', 'default:0 | number:2'], + ['a second formatter after a bare one', 'upper | lower'], + ['a second formatter after a closed quoted argument', "default:'N/A' | upper"], + ['three formatters', 'trim | upper | lower'], + ])('refuses %s, naming the one-formatter rule and the caller remedy', (_label, part) => { + const refusal = judgeHoleFormatter(`{{ x | ${part} }}`, part, part.split(/[:|]/)[0].trim(), undefined); + expect(refusal?.rule).toBe('one-formatter'); + expect(refusal?.message).toContain(`{{ x | ${part} }}`); + expect(refusal?.message).toContain('a hole takes one formatter'); + expect(refusal?.message).toContain('in the caller that supplies the data'); + }); + + it('a `|` inside a closed quoted argument is the argument\'s own text', () => { + expect(judgeHoleFormatter("{{ x | default:'a | b' }}", "default:'a | b'", 'default', 'a | b')).toBeNull(); + expect(judgeHoleFormatter('{{ x | default:"a | b" }}', 'default:"a | b"', 'default', 'a | b')).toBeNull(); + }); + + it('the in-app dialect refuses a chained hole at compile, with the rule — never the literal text', () => { + for (const source of ['{{ record.n | default:0 | number:2 }}', '{{ record.n | upper | lower }}']) { + const r = templateEngine.compile(source); + expect(r.ok, source).toBe(false); + if (r.ok) continue; + expect(r.error.message).toContain('a hole takes one formatter'); + expect(evaluate(source, { n: null }).ok).toBe(false); + } + expect(validateExpression('template', { dialect: 'template', source: 'Paid {{ record.n | default:0 | number:2 }}' }) + .errors.map((e) => e.code)).toEqual(['invalid-template']); + }); + + it('the mail grammar parses a chained hole as a hole, carrying the refusal, in both shapes', () => { + expect(parseMailHole(' actual_amount | default:0 | number:2 ')?.refusal?.rule).toBe('one-formatter'); + expect(parseMailHole(' actual_amount | upper | lower ')).toMatchObject({ path: 'actual_amount', refusal: { rule: 'one-formatter' } }); + const face = 'Paid {{ actual_amount | default:0 | number:2 }} of {{ planned_amount | number:2 }}'; + expect(judgeMailHoleGrammar(face).map((f) => [f.text, f.start, f.refusal.rule])) + .toEqual([['{{ actual_amount | default:0 | number:2 }}', 5, 'one-formatter']]); + }); +}); + describe('the currency argument is a literal ISO 4217 code', () => { it.each([ ['a path', 'currency_code'], diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 946039d0df7..d59c11ac2b8 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -2440,10 +2440,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ }, // Every hole of an email template's three faces is within the mail hole // grammar — `@objectstack/formula`'s `judgeMailHoleGrammar`, the grammar the - // mail renderer parses with. A `currency` argument that is not a literal - // ISO 4217 code is refused, naming the literal-only rule; the renderer used - // to print such an amount in USD. Gating: no reading of such a hole renders - // as its author wrote it. + // mail renderer parses with. A hole that chains a second formatter is + // refused, naming the one-formatter rule and the "branch in the caller" + // remedy (the renderer printed `0 | number:2`); a `currency` argument that + // is not a literal ISO 4217 code is refused, naming the literal-only rule + // (the renderer printed the amount in USD). Gating: no reading of such a + // hole renders as its author wrote it. // // Runtime publish gate: the same door and shape as the placement rule above // — each written template is judged on its own, so the door's verdict and diff --git a/packages/lint/src/validate-email-template-hole-grammar.test.ts b/packages/lint/src/validate-email-template-hole-grammar.test.ts index 859b2a317c9..359d4b422fa 100644 --- a/packages/lint/src/validate-email-template-hole-grammar.test.ts +++ b/packages/lint/src/validate-email-template-hole-grammar.test.ts @@ -30,6 +30,7 @@ const template = (over: Record = {}) => ({ }); const CURRENCY_PATH = '{{ planned_amount | currency:currency_code }}'; +const CHAINED = '{{ actual_amount | default:0 | number:2 }}'; describe('the rule, on the stack', () => { it('a template whose every hole is within the grammar draws nothing', () => { @@ -46,6 +47,15 @@ describe('the rule, on the stack', () => { expect(findings[0].message).toContain('never read as a path'); }); + it.each(['subject', 'bodyText', 'bodyHtml'])('⭐ refuses a chained hole in %s, naming the one-formatter rule and the caller remedy', (face) => { + const findings = validateEmailTemplateHoleGrammar({ emailTemplates: [template({ [face]: `Paid ${CHAINED}` })] }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ severity: 'error', rule: EMAIL_TEMPLATE_HOLE_GRAMMAR, path: `emailTemplates[0].${face}` }); + expect(findings[0].message).toContain(CHAINED); + expect(findings[0].message).toContain('a hole takes one formatter'); + expect(findings[0].message).toContain('in the caller that supplies the data'); + }); + it('one finding per refused hole, located on its own template and face', () => { const findings = validateEmailTemplateHoleGrammar({ emailTemplates: [ @@ -80,11 +90,11 @@ describe('the registry entry', () => { }); }); - it.each([...AUTHORING_COMMANDS])('os %s refuses the hole at error', (command) => { - const stack = { emailTemplates: [template({ subject: CURRENCY_PATH })] }; + it.each([...AUTHORING_COMMANDS])('os %s refuses both holes at error', (command) => { + const stack = { emailTemplates: [template({ subject: CURRENCY_PATH, bodyText: CHAINED })] }; const errors = runAuthoringRules(command, { normalized: stack, parsed: stack }) .filter((f) => f.severity === 'error' && f.rule === EMAIL_TEMPLATE_HOLE_GRAMMAR); - expect(errors).toHaveLength(1); + expect(errors.map((f) => f.path)).toEqual(['emailTemplates[0].subject', 'emailTemplates[0].bodyText']); }); }); diff --git a/packages/lint/src/validate-email-template-hole-grammar.ts b/packages/lint/src/validate-email-template-hole-grammar.ts index 6dcd1737787..e7f117c6208 100644 --- a/packages/lint/src/validate-email-template-hole-grammar.ts +++ b/packages/lint/src/validate-email-template-hole-grammar.ts @@ -8,9 +8,14 @@ * A mail template hole is a path with at most one formatter from the shared * whitelist, `{{ path | formatter[:arg] }}`, and a formatter argument the * renderer can honour as written. A hole the grammar refuses never renders as - * its author meant it: a `currency` argument that is not a literal ISO 4217 - * code (`currency:currency_code`, a path) is not read as a path — the renderer - * used to print the amount in USD instead. + * its author meant it: + * + * - a second formatter (`{{ x | default:0 | number:2 }}`) — the renderer used + * to read it as part of the first formatter's argument and print the text + * `0 | number:2`, or leave the whole hole on the page; + * - a `currency` argument that is not a literal ISO 4217 code + * (`currency:currency_code`, a path) — it is not read as a path, and the + * renderer used to print the amount in USD instead. * * WHICH holes are refused is not decided here: it is `@objectstack/formula`'s * `judgeMailHoleGrammar`, the grammar the mail renderer itself parses with, diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts b/packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts index ce4d10c5a32..b5446abfb81 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts @@ -88,6 +88,23 @@ describe('the host threads an email_template publish to the runtime gate (#25189 expect(publishTemplate(cleanTemplate({ subject: 'Due {{ amount | currency:EUR }}' })).error).toBeNull(); }); + it('⭐ LIT — a hole that chains a second formatter is refused in the same envelope, naming the one-formatter rule', () => { + const verdict = publishTemplate(cleanTemplate({ bodyHtml: '

{{ actual_amount | default:0 | number:2 }}

' })); + + expect(verdict.error, 'the caller throws this; null here means the door judged nothing').not.toBeNull(); + expect((verdict.error as { code?: string }).code).toBe('INVALID_METADATA'); + expect((verdict.error as { status?: number }).status).toBe(422); + + const issues = (verdict.error as { issues?: Array> }).issues!; + expect(issues.map((i) => [i.rule, i.path])).toEqual([['email-template-hole-grammar', 'emailTemplates[0].bodyHtml']]); + const wire = JSON.stringify(issues); + expect(wire).toContain('{{ actual_amount | default:0 | number:2 }}'); + expect(wire).toContain('a hole takes one formatter'); + + // Non-vacuous: one formatter publishes. + expect(publishTemplate(cleanTemplate({ bodyHtml: '

{{ actual_amount | number:2 }}

' })).error).toBeNull(); + }); + it('the same template publishes once the hole is moved to a supported placement', () => { const verdict = publishTemplate(cleanTemplate()); expect(verdict.error, 'the refusal is about the placement, not about the type').toBeNull(); diff --git a/packages/plugins/plugin-email/src/template-engine.ts b/packages/plugins/plugin-email/src/template-engine.ts index d7dfe14da5c..c1fd7f805ac 100644 --- a/packages/plugins/plugin-email/src/template-engine.ts +++ b/packages/plugins/plugin-email/src/template-engine.ts @@ -28,7 +28,8 @@ * * The hole grammar is `@objectstack/formula`'s (`mailHolePattern`, * `parseMailHole`), the one copy the authoring rule and the placement judge - * read too. A hole that grammar REFUSES — a `currency` argument that is not a + * read too. A hole that grammar REFUSES — a second formatter + * (`{{ x | default:0 | number:2 }}`), or a `currency` argument that is not a * literal ISO 4217 code — is refused at authoring and publish; one that still * reaches the renderer (a template stored before the rule existed, or a body * handed to these functions directly) makes the render throw diff --git a/packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts b/packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts index 7932e2117f7..2757e3c4ba0 100644 --- a/packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts +++ b/packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts @@ -52,6 +52,40 @@ const row = (name: string, over: Partial): EmailTemplateRow => const data = { planned_amount: 1234.5, currency_code: 'eur' }; +describe('a hole that chains a second formatter', () => { + it.each([ + ['subject', { subject: 'Paid {{ actual_amount | default:0 | number:2 }}' }], + ['body_html', { body_html: '

{{ actual_amount | default:0 | number:2 }}

' }], + ['body_text', { body_text: 'Paid {{ actual_amount | upper | lower }}' }], + ])('⭐ in %s: the render is refused with TEMPLATE_INVALID, naming the one-formatter rule — never the literal text', async (_face, over) => { + for (const actual_amount of [null, 71100]) { + const { svc } = makeService([row('contract.paid', over)]); + const render = svc.renderTemplate({ template: 'contract.paid', data: { actual_amount } }); + await expect(render).rejects.toThrow(/^TEMPLATE_INVALID: email template contract\.paid \(locale=en-US\): /); + await expect(svc.renderTemplate({ template: 'contract.paid', data: { actual_amount } })) + .rejects.toThrow('a hole takes one formatter'); + } + }); + + it('sendTemplate refuses the same template and sends nothing', async () => { + const { svc, sent } = makeService([row('contract.paid', { body_html: '

{{ actual_amount | default:0 | number:2 }}

' })]); + await expect(svc.sendTemplate({ template: 'contract.paid', to: 'a@x.com', data: { actual_amount: null } })) + .rejects.toThrow(/^TEMPLATE_INVALID: /); + expect(sent).toEqual([]); + }); + + it('one formatter per hole renders, and a `|` inside a quoted argument is its text', async () => { + const { svc } = makeService([row('contract.paid', { + subject: "Paid {{ actual_amount | default:'n/a | none' }}", + body_html: '

{{ actual_amount | number:2 }}

', + })]); + expect(await svc.renderTemplate({ template: 'contract.paid', data: { actual_amount: null } })) + .toMatchObject({ subject: 'Paid n/a | none', html: '

' }); + expect(await svc.renderTemplate({ template: 'contract.paid', data: { actual_amount: 71100 } })) + .toMatchObject({ subject: 'Paid 71100', html: '

71,100.00

' }); + }); +}); + describe('a currency argument that is not a literal ISO 4217 code', () => { it.each([ ['subject', { subject: 'Due {{ planned_amount | currency:currency_code }}' }], From 9d428aaed9a9e952e002dfde5da1d35b74e692e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:59:40 +0000 Subject: [PATCH 3/6] docs: the CLI transcripts print 55 author-time rules with email-template-hole-grammar registered `check:docs-transcript-drift` derives the count from `authoringRulesFor()`; the new gating rule moves it from 54 to 55 for build and validate. Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 2 +- content/docs/deployment/validating-metadata.mdx | 2 +- content/docs/getting-started/build-with-claude-code.mdx | 2 +- content/docs/ui/react-pages.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 658482d777e..1b7de9ac0b9 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -758,7 +758,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (54)... + → Running author-time rules (55)... → Checking that every required capability has a provider installable in this edition... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index e673791984b..b34daf7eeb4 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -752,7 +752,7 @@ A clean run walks the registry and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Running author-time rules (54)... + → Running author-time rules (55)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index c545ed7395e..9884c08e4c3 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -305,7 +305,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (54)... + → Running author-time rules (55)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 6b500416a86..eaca78960b5 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -397,7 +397,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (54)... + → Running author-time rules (55)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... From d6a898e3886820cb3dd0b5d349903b8bc3b85f1f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:45:37 +0000 Subject: [PATCH 4/6] wip: grade TEMPLATE_INVALID permanent in the messaging channels Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude --- .../service-messaging/src/email-channel.test.ts | 14 ++++++++++++++ .../service-messaging/src/email-channel.ts | 15 ++++++++++----- .../service-messaging/src/inbox-channel.test.ts | 15 +++++++++++++++ .../service-messaging/src/inbox-channel.ts | 14 +++++++++----- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/packages/services/service-messaging/src/email-channel.test.ts b/packages/services/service-messaging/src/email-channel.test.ts index 7893288694f..29525178a5b 100644 --- a/packages/services/service-messaging/src/email-channel.test.ts +++ b/packages/services/service-messaging/src/email-channel.test.ts @@ -362,6 +362,20 @@ describe('email channel', () => { expect(ch.classifyError?.(r.error)).toBe('permanent'); }); + it('grades TEMPLATE_INVALID permanent — a template whose hole the mail grammar refuses fails every attempt until republished', async () => { + const email = fakeTemplateEmail(new Error("TEMPLATE_INVALID: email template crm.large_deal_won (locale=en-US): the hole {{ amount | currency:currency_code }} gives the currency formatter the argument 'currency_code', which is not an ISO 4217 currency code")); + const data = fakeData({ users: { user_1: 'ada@example.com' } }); + const ch = createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + }); + const r = await ch.send(silentCtx(), templateDelivery({ template: 'crm.large_deal_won' })); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/^TEMPLATE_INVALID: /); + expect(ch.classifyError?.(r.error)).toBe('permanent'); + }); + it("a sendTemplate result of status:'failed' (transport failure) reports ok:false and stays retryable", async () => { const email = fakeTemplateEmail({ id: 'row', status: 'failed', error: 'smtp down' }); const data = fakeData({ users: { user_1: 'ada@example.com' } }); diff --git a/packages/services/service-messaging/src/email-channel.ts b/packages/services/service-messaging/src/email-channel.ts index 27d17e92d36..92922cc5a04 100644 --- a/packages/services/service-messaging/src/email-channel.ts +++ b/packages/services/service-messaging/src/email-channel.ts @@ -362,9 +362,10 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel return { ok: true, externalId: id != null ? String(id) : undefined }; } catch (err) { // sendTemplate's own failure vocabulary (TEMPLATE_NOT_FOUND / - // TEMPLATE_INACTIVE / MISSING_VARIABLES) arrives as a thrown - // Error — keep the code at the front of the row's error so - // classifyError() below can grade it permanent. + // TEMPLATE_INACTIVE / MISSING_VARIABLES / TEMPLATE_INVALID) + // arrives as a thrown Error — keep the code at the front of + // the row's error so classifyError() below can grade it + // permanent. return { ok: false, error: (err as Error)?.message ?? String(err) }; } } @@ -406,7 +407,11 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel // permanent (→ dead immediately, with the code on the delivery // row) instead of burning the whole retry schedule first. These // are `IEmailService.sendTemplate`'s own error codes plus this - // channel's missing-capability refusal above. + // channel's missing-capability refusal above. `TEMPLATE_INVALID` + // is a template whose hole the mail grammar refuses (a second + // formatter, a currency argument that is not a literal ISO 4217 + // code): stored before the authoring rule existed, it fails every + // attempt until it is republished. // // [commit 879b51270] `transport_not_configured` joins them, and the grade is // driven rather than assumed: in the shipping composition the same @@ -417,7 +422,7 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel // answer one condition differently, burning the whole ladder // against a transport that no attempt can install. const msg = typeof err === 'string' ? err : String((err as Error)?.message ?? err ?? ''); - if (/\b(TEMPLATE_NOT_FOUND|TEMPLATE_INACTIVE|MISSING_VARIABLES|TEMPLATE_UNSUPPORTED)\b/.test(msg)) { + if (/\b(TEMPLATE_NOT_FOUND|TEMPLATE_INACTIVE|MISSING_VARIABLES|TEMPLATE_INVALID|TEMPLATE_UNSUPPORTED)\b/.test(msg)) { return 'permanent'; } if (msg.startsWith(`${TRANSPORT_NOT_CONFIGURED}:`)) return 'permanent'; diff --git a/packages/services/service-messaging/src/inbox-channel.test.ts b/packages/services/service-messaging/src/inbox-channel.test.ts index a1864e4f618..24afc680972 100644 --- a/packages/services/service-messaging/src/inbox-channel.test.ts +++ b/packages/services/service-messaging/src/inbox-channel.test.ts @@ -376,6 +376,21 @@ describe('inbox channel', () => { expect(ch.classifyError?.(new Error('db down'))).toBe('retryable'); }); + it('grades TEMPLATE_INVALID permanent — a template whose hole the mail grammar refuses fails every attempt until republished', async () => { + const data = fakeData(undefined, userWithLocale('zh-CN')); + const r = fakeRenderer(() => { + throw new Error("TEMPLATE_INVALID: email template crm.large_deal_won (locale=zh-CN): the hole {{ amount | currency:currency_code }} gives the currency formatter the argument 'currency_code', which is not an ISO 4217 currency code"); + }); + const ch = createInboxChannel({ getData: () => data.engine, getEmail: () => r.email }); + + const result = await ch.send(silentCtx(), templateDelivery()); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/^TEMPLATE_INVALID: /); + expect(data.inserts).toHaveLength(0); + expect(ch.classifyError?.(result.error)).toBe('permanent'); + }); + it('never consults the renderer for a non-template delivery', async () => { const data = fakeData(); const r = fakeRenderer(); diff --git a/packages/services/service-messaging/src/inbox-channel.ts b/packages/services/service-messaging/src/inbox-channel.ts index a463ba83828..28c2c48c518 100644 --- a/packages/services/service-messaging/src/inbox-channel.ts +++ b/packages/services/service-messaging/src/inbox-channel.ts @@ -194,9 +194,10 @@ export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel bodyMd = rendered.text; } catch (err) { // renderTemplate's failure vocabulary (TEMPLATE_NOT_FOUND / - // TEMPLATE_INACTIVE / MISSING_VARIABLES) arrives as a thrown - // Error — keep the code at the front of the row's error so - // classifyError() below can grade it permanent. + // TEMPLATE_INACTIVE / MISSING_VARIABLES / TEMPLATE_INVALID) + // arrives as a thrown Error — keep the code at the front of + // the row's error so classifyError() below can grade it + // permanent. return { ok: false, error: (err as Error)?.message ?? String(err) }; } } @@ -245,9 +246,12 @@ export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel // edits the template/node, so grade it permanent instead of // burning the retry schedule. These are // `IEmailService.renderTemplate`'s own error codes plus this - // channel's missing-capability refusal above. + // channel's missing-capability refusal above. `TEMPLATE_INVALID` + // is a template whose hole the mail grammar refuses: stored before + // the authoring rule existed, it fails every attempt until it is + // republished. const msg = typeof err === 'string' ? err : String((err as Error)?.message ?? err ?? ''); - if (/\b(TEMPLATE_NOT_FOUND|TEMPLATE_INACTIVE|MISSING_VARIABLES|TEMPLATE_UNSUPPORTED)\b/.test(msg)) { + if (/\b(TEMPLATE_NOT_FOUND|TEMPLATE_INACTIVE|MISSING_VARIABLES|TEMPLATE_INVALID|TEMPLATE_UNSUPPORTED)\b/.test(msg)) { return 'permanent'; } // A failed local insert is almost always transient (lock/timeout). From dfe802626637eacda4e689a2972aa3733b4276a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:53:37 +0000 Subject: [PATCH 5/6] fix(service-messaging): grade TEMPLATE_INVALID permanent in the email and inbox channels The mail renderer now fails a stored template whose hole the grammar refuses with TEMPLATE_INVALID. Graded retryable, such a delivery spent its whole retry ladder before going dead; it now goes dead at once, like the other template failures. Pinned once per channel, each ablated. Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude --- .changeset/26660-mail-hole-grammar-messaging.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/26660-mail-hole-grammar-messaging.md diff --git a/.changeset/26660-mail-hole-grammar-messaging.md b/.changeset/26660-mail-hole-grammar-messaging.md new file mode 100644 index 00000000000..40d6c37622e --- /dev/null +++ b/.changeset/26660-mail-hole-grammar-messaging.md @@ -0,0 +1,10 @@ +--- +'@objectstack/service-messaging': patch +--- + +fix(service-messaging): the email and inbox channels grade a `TEMPLATE_INVALID` template failure permanent, like the other template failures + +Clause-②: no +Metadata-protocol: none + +`@objectstack/plugin-email`'s `sendTemplate` and `renderTemplate` now fail with `TEMPLATE_INVALID` for a stored template whose hole the mail grammar refuses (a second formatter, or a `currency` argument that is not a literal ISO 4217 code). Retrying cannot succeed until the template is republished, so `classifyError` on both channels grades it `permanent`, as it already grades `TEMPLATE_NOT_FOUND`, `TEMPLATE_INACTIVE`, `MISSING_VARIABLES` and `TEMPLATE_UNSUPPORTED`: the delivery goes dead at once with the code on its row, instead of spending the whole retry schedule first. Every other failure keeps its grade. From 898b6a1fe1cf57c1663fb88b4229f853616e4ec6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:16:45 +0000 Subject: [PATCH 6/6] docs(changeset): state the currency argument's before-account per arm, and name what is added instead of "nothing is widened" A lowercase code formatted in that currency before and is refused now; a path or empty argument fell back to US dollars. The narrowing changesets name their additive exports (plugin-email adds none). Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude --- .changeset/26660-mail-hole-grammar-formula.md | 4 ++-- .changeset/26660-mail-hole-grammar-plugin-email.md | 2 +- .changeset/26660-mail-hole-grammar.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/26660-mail-hole-grammar-formula.md b/.changeset/26660-mail-hole-grammar-formula.md index afe090c4303..44ad469daf8 100644 --- a/.changeset/26660-mail-hole-grammar-formula.md +++ b/.changeset/26660-mail-hole-grammar-formula.md @@ -4,7 +4,7 @@ feat(formula)!: a template hole takes one formatter, and the `currency` formatter's argument is a literal ISO 4217 code — both refused at compile, never rendered as formatter syntax or in US dollars -Clause-②: no (narrowing) — the in-app template dialect's compile (and so `os validate` and the publish gate for every template slot) refuses a hole with a second formatter (`{{ x | default:0 | number:2 }}`) and a `{{ x | currency:ARG }}` hole whose `ARG` is not three capital letters, both of which it accepted before; `formatValue('currency', value, ARG)` throws a `RangeError` for such an `ARG` instead of formatting in US dollars. Nothing is widened. +Clause-②: no (narrowing) — the in-app template dialect's compile (and so `os validate` and the publish gate for every template slot) refuses a hole with a second formatter (`{{ x | default:0 | number:2 }}`) and a `{{ x | currency:ARG }}` hole whose `ARG` is not three capital letters, both of which it accepted before; `formatValue('currency', value, ARG)` now throws a `RangeError` for such an `ARG`: a path or another argument `Intl` refuses, and an empty one, used to fall back to US dollars, and a lowercase code (`eur`), which `Intl` formatted in that currency, is now refused too. No accept set is widened; the additions are exports only: `judgeHoleFormatter`, `mailHolePattern`, `parseMailHole`, `judgeMailHoleGrammar`, and the types `TemplateHoleGrammarRefusal`, `TemplateHoleGrammarRule`, `ParsedMailHole` and `MailHoleGrammarFinding`. Metadata-protocol: narrowed @@ -13,7 +13,7 @@ Metadata-protocol: narrowed - **Before, a chained hole.** `{{ record.n | default:0 | number:2 }}` compiled as `default` with the argument `0 | number:2`, and rendered that text. - **Before, a currency path.** `{{ record.amount | currency:currency_code }}` compiled; `Intl` refused the argument and the formatter caught that and used US dollars, so the amount printed in a currency nobody wrote. -- **Now.** `templateEngine.compile` / `evaluate` answer a `parse` error naming the hole and the rule. `formatValue('currency', …)` throws a `RangeError` for an argument that is not a code, before it reads the value. No argument still formats in US dollars. +- **Now.** `templateEngine.compile` / `evaluate` answer a `parse` error naming the hole and the rule. `formatValue('currency', …)` throws a `RangeError` for an argument that is not a code, before it reads the value. A lowercase code (`currency:eur`), which formatted in euros before, is refused the same way. No argument still formats in US dollars. - **Shared grammar.** `judgeHoleFormatter` is the judgement both template dialects use. The mail template grammar is exported once, `mailHolePattern`, `parseMailHole` and `judgeMailHoleGrammar`, and is what `@objectstack/plugin-email`'s renderer, `judgeMailHtmlHoles` and `@objectstack/lint` read; `judgeMailHtmlHoles` no longer keeps a copy of it. - **Who sees it.** A template slot that chained formatters, or wrote a path or a lowercase code as the currency argument. No template shipped in this repository does. diff --git a/.changeset/26660-mail-hole-grammar-plugin-email.md b/.changeset/26660-mail-hole-grammar-plugin-email.md index 4db97feaa8a..10078a0ca39 100644 --- a/.changeset/26660-mail-hole-grammar-plugin-email.md +++ b/.changeset/26660-mail-hole-grammar-plugin-email.md @@ -4,7 +4,7 @@ feat(plugin-email)!: a mail template hole that chains a second formatter, or whose `currency` argument is not a literal ISO 4217 code, fails the render with `TEMPLATE_INVALID` instead of printing formatter syntax or US dollars -Clause-②: no (narrowing) — `sendTemplate` and `renderTemplate` refuse a stored template whose `subject`, `body_text` or `body_html` writes a hole with a second formatter, or `{{ x | currency:ARG }}` with an `ARG` that is not three capital letters, both of which rendered before. Nothing is widened. +Clause-②: no (narrowing) — `sendTemplate` and `renderTemplate` refuse a stored template whose `subject`, `body_text` or `body_html` writes a hole with a second formatter, or `{{ x | currency:ARG }}` with an `ARG` that is not three capital letters, both of which rendered before. No accept set is widened, and the package adds no export. Metadata-protocol: narrowed diff --git a/.changeset/26660-mail-hole-grammar.md b/.changeset/26660-mail-hole-grammar.md index ff04ec7b71a..aeea5582a39 100644 --- a/.changeset/26660-mail-hole-grammar.md +++ b/.changeset/26660-mail-hole-grammar.md @@ -4,7 +4,7 @@ feat(lint)!: a new gating authoring rule, `email-template-hole-grammar`, refuses an email template hole that chains a second formatter or whose `currency` argument is not a literal ISO 4217 code -Clause-②: no (narrowing) — `os validate`, `os build`, `os lint` and the metadata publish door (`422 INVALID_METADATA`) refuse an `email_template` whose `subject`, `bodyText` or `bodyHtml` writes a hole with a second formatter (`{{ x | default:0 | number:2 }}`) or `{{ x | currency:ARG }}` with an `ARG` that is not three capital letters, both of which they accepted before. Nothing is widened. +Clause-②: no (narrowing) — `os validate`, `os build`, `os lint` and the metadata publish door (`422 INVALID_METADATA`) refuse an `email_template` whose `subject`, `bodyText` or `bodyHtml` writes a hole with a second formatter (`{{ x | default:0 | number:2 }}`) or `{{ x | currency:ARG }}` with an `ARG` that is not three capital letters, both of which they accepted before. No accept set is widened; the additions are exports only: `validateEmailTemplateHoleGrammar`, `EMAIL_TEMPLATE_HOLE_GRAMMAR`, the type `EmailTemplateHoleGrammarFinding`, and the rule id `email-template-hole-grammar`. Metadata-protocol: narrowed