diff --git a/.changeset/26660-mail-hole-grammar-formula.md b/.changeset/26660-mail-hole-grammar-formula.md new file mode 100644 index 00000000000..44ad469daf8 --- /dev/null +++ b/.changeset/26660-mail-hole-grammar-formula.md @@ -0,0 +1,20 @@ +--- +'@objectstack/formula': minor +--- + +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)` 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 + + + +**BREAKING** accept-set narrowing in the template dialect, and a new shared mail hole grammar. + +- **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. 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. + +**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-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. 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..10078a0ca39 --- /dev/null +++ b/.changeset/26660-mail-hole-grammar-plugin-email.md @@ -0,0 +1,19 @@ +--- +'@objectstack/plugin-email': minor +--- + +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. No accept set is widened, and the package adds no export. +Metadata-protocol: narrowed + + + +**BREAKING** a render that printed a wrong currency now fails. + +- **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 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 new file mode 100644 index 00000000000..aeea5582a39 --- /dev/null +++ b/.changeset/26660-mail-hole-grammar.md @@ -0,0 +1,20 @@ +--- +'@objectstack/lint': minor +--- + +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. 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 + + + +**BREAKING** accept-set narrowing at every authoring door for `email_template`. + +- **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 chained formatters, or wrote a path or a lowercase code as the currency argument. No template shipped in this repository does. + +**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 863d635f68a..f73ab4e0bdd 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -82,6 +82,21 @@ 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 +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 +109,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/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index c131f64ddd4..44429ab3713 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)... diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index 7e363d5acff..d7c29f891cd 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..3feb62f6eb0 --- /dev/null +++ b/packages/formula/src/mail-hole-grammar.ts @@ -0,0 +1,111 @@ +// 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: 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'; + +/** 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) { + // `{{ 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); + 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..c9852e7b557 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,99 @@ 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. +// +// - 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. + +/** The rule a refused hole breaks. */ +export type TemplateHoleGrammarRule = 'one-formatter' | '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.' + ); +} + +/** + * 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 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 (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) }; + } + 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 +278,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; } /** @@ -219,8 +317,12 @@ function parseHole(inner: string): ParsedHole | null { name = filterPart.slice(0, colon).trim(); arg = filterPart.slice(colon + 1).trim().replace(/^['"]|['"]$/g, ''); } + 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 } }; + return { path, filter: { name, arg }, ...(refusal ? { refusal } : {}) }; } function compileTemplate(source: string): EvalResult { @@ -246,6 +348,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..e338959ad87 --- /dev/null +++ b/packages/formula/src/template-hole-grammar.test.ts @@ -0,0 +1,158 @@ +// 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. + * + * 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. + */ +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('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'], + ['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 b457458c9b1..246157994fd 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -152,6 +152,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, @@ -2453,6 +2454,31 @@ 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 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 + // 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 0577a2a013d..08c3052704c 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -1001,6 +1001,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..359d4b422fa --- /dev/null +++ b/packages/lint/src/validate-email-template-hole-grammar.test.ts @@ -0,0 +1,111 @@ +// 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 }}'; +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', () => { + 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.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: [ + 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 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.map((f) => f.path)).toEqual(['emailTemplates[0].subject', 'emailTemplates[0].bodyText']); + }); +}); + +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..e7f117c6208 --- /dev/null +++ b/packages/lint/src/validate-email-template-hole-grammar.ts @@ -0,0 +1,84 @@ +// 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 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, + * 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..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 @@ -70,6 +70,41 @@ 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('⭐ 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/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..c1fd7f805ac 100644 --- a/packages/plugins/plugin-email/src/template-engine.ts +++ b/packages/plugins/plugin-email/src/template-engine.ts @@ -24,8 +24,18 @@ * 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 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 + * `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 +44,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 +52,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 +114,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..2757e3c4ba0 --- /dev/null +++ b/packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts @@ -0,0 +1,119 @@ +// 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 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 }}' }], + ['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' }); + }); +}); 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).