diff --git a/.changeset/26948-mail-unknown-formatter-formula.md b/.changeset/26948-mail-unknown-formatter-formula.md new file mode 100644 index 00000000000..63f5ab83068 --- /dev/null +++ b/.changeset/26948-mail-unknown-formatter-formula.md @@ -0,0 +1,19 @@ +--- +'@objectstack/formula': minor +--- + +feat(formula)!: the mail hole grammar refuses a hole whose formatter the whitelist does not name, by name, listing the known formatters + +Clause-②: no (narrowing) — `judgeHoleFormatter` gains a third rule, `known-formatter`: a hole that names a formatter `TEMPLATE_FORMATTERS` does not hold (`{{ amount | curency:EUR }}`, `{{ due | dat:long }}`) is refused, so `parseMailHole` carries the refusal and `judgeMailHoleGrammar` reports the hole, where both accepted it before. The in-app dialect, which already refused an unknown formatter in its own parse, now also refuses an inherited object key it used to call as a formatter (`{{ x | toString }}`, which printed `[object Object]`). No accept set is widened and no export is added or removed; `TemplateHoleGrammarRule` gains the member `'known-formatter'`. +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing in the shared hole grammar. + +- **Before.** `parseMailHole(' amount | curency:EUR ')` answered a hole with no refusal, and the mail renderer printed the raw value for it (`1234.5`, an ISO timestamp), because `formatValue` knows no such formatter. +- **Now.** The hole carries a `known-formatter` refusal whose sentence names the hole and lists `TEMPLATE_FORMATTERS`. Text after the `|` that is not a single word (`{{ x | two words }}`) still names no formatter and is left as written. A chained hole is still refused by the one-formatter rule first. +- **In-app.** `templateEngine.compile` keeps its own sentence for an unknown formatter. It now also refuses `toString`, `constructor` and the other inherited object keys, which it used to accept and run as formatters. +- **Who sees it.** A template whose hole misspells a formatter or names one that does not exist. No template shipped in this repository does. + +**The remedy.** Correct the formatter's name to one the refusal lists, or format the value in the caller that supplies the data and pass the finished text as its own variable. diff --git a/.changeset/26948-mail-unknown-formatter-lint.md b/.changeset/26948-mail-unknown-formatter-lint.md new file mode 100644 index 00000000000..42b5be837c7 --- /dev/null +++ b/.changeset/26948-mail-unknown-formatter-lint.md @@ -0,0 +1,18 @@ +--- +'@objectstack/lint': minor +--- + +feat(lint)!: `email-template-hole-grammar` refuses an email template hole whose formatter the whitelist does not name, naming the hole and listing the known formatters + +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 naming a formatter `TEMPLATE_FORMATTERS` does not hold (`{{ amount | curency:EUR }}`, `{{ due | dat:long }}`), which they accepted before. No accept set is widened, and no export or rule id is added or removed. +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing at every authoring door for `email_template`. + +- **Before.** `{{ amount | curency:EUR }}` passed every gate, and the email went out with `1234.5` where money was meant. The in-app template dialect already refused such a hole; mail did not. +- **Now.** The rule refuses it at `error`, naming the hole, the face and the template, and listing the known formatters. The judgement is `@objectstack/formula`'s `judgeMailHoleGrammar`, the grammar the mail renderer parses with. +- **Who sees it.** A template whose hole misspells a formatter or names one that does not exist. No template shipped in this repository does. + +**The remedy.** Correct the formatter's name to one the finding lists, or format the value in the caller that supplies the data and pass the finished text as its own variable. diff --git a/.changeset/26948-mail-unknown-formatter-plugin-email.md b/.changeset/26948-mail-unknown-formatter-plugin-email.md new file mode 100644 index 00000000000..6fa34aaa0a1 --- /dev/null +++ b/.changeset/26948-mail-unknown-formatter-plugin-email.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-email': minor +--- + +feat(plugin-email)!: a mail template hole whose formatter the whitelist does not name fails the render with `TEMPLATE_INVALID` instead of printing the raw value + +Clause-②: no (narrowing) — `sendTemplate` and `renderTemplate` refuse a stored template whose `subject`, `body_text` or `body_html` writes a hole naming a formatter `TEMPLATE_FORMATTERS` does not hold (`{{ amount | curency:EUR }}`), which rendered the raw value before. No accept set is widened, and the package adds no export. +Metadata-protocol: narrowed + + + +**BREAKING** a render that printed a raw value where a formatted one was meant now fails. + +- **Before.** `{{ amount | curency:EUR }}` with `amount = 1234.5` rendered `1234.5` in every face, and `{{ due | dat:long }}` rendered the stored timestamp as written: the renderer's fallback for a formatter it did not know was the raw value. +- **Now.** The render throws `TEMPLATE_INVALID: email template NAME (locale=LOCALE): the hole … names the formatter 'curency', which is not a template formatter …`, listing the known formatters; `sendTemplate` sends nothing, and the messaging channels grade the failure permanent as they grade every `TEMPLATE_INVALID`. 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 raw fallback is gone.** `renderTemplate` and `renderPlainTextTemplate` throw `TEMPLATE_INVALID` for such a hole too, when handed a body directly. + +**The remedy.** Republish the template with the formatter's name corrected to one the refusal lists. diff --git a/.changeset/26948-mail-unknown-formatter-spec.md b/.changeset/26948-mail-unknown-formatter-spec.md new file mode 100644 index 00000000000..f20e9349967 --- /dev/null +++ b/.changeset/26948-mail-unknown-formatter-spec.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): the `email_template` face descriptions and the `IEmailService` `TEMPLATE_INVALID` TSDoc name a formatter the template engine does not know among the refused holes + +Clause-②: no +Metadata-protocol: none + +- **The face descriptions.** `subject`, `bodyHtml` and `bodyText` now say that a hole's formatter is one the template engine knows, and that an unknown name (a misspelled `curency`) is refused, naming the known formatters. The `currency` sentence is unchanged. The refusal itself ships in `@objectstack/formula`, `@objectstack/lint` and `@objectstack/plugin-email`; the spec only states the grammar, so no accept set of any schema moves. +- **The TSDoc.** The `TEMPLATE_INVALID` error of `sendTemplate` and `renderTemplate` now names all three holes `email-template-hole-grammar` refuses: a second formatter, a formatter the whitelist does not name, and a `currency` argument that is neither a literal ISO 4217 code nor a marked `@path`. + +No type, schema shape or export changes. diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index 62168409e35..99836d4d5a0 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -82,6 +82,14 @@ 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 formatter is one the whitelist names.** `{{ amount | curency:EUR }}` is +refused by `os validate`, `os build`, `os lint` and the metadata publish door +(`email-template-hole-grammar`, a `422 INVALID_METADATA` at the publish door), +naming the hole and listing the known formatters (`TEMPLATE_FORMATTERS`, +exported from `@objectstack/formula`). A template stored before this rule fails its render with +`TEMPLATE_INVALID` and sends nothing, rather than print the raw value (`1234.5`) +where a formatted one was meant; republish it with the name corrected. + **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 diff --git a/content/docs/references/system/email-template.mdx b/content/docs/references/system/email-template.mdx index f1ebb19a883..a42d91c745e 100644 --- a/content/docs/references/system/email-template.mdx +++ b/content/docs/references/system/email-template.mdx @@ -49,9 +49,9 @@ const result = EmailTemplateDefinitionSchema.parse(data); | **label** | `string` | ✅ | Display label | | **category** | `never` | optional | [REMOVED] `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key the resolver matches EXACTLY. A call that NAMES a locale gets exactly one retry rung, the literal `en-US`, with no language-subtag folding: `en` and `en-US` are different ROWS of one bundle and neither reaches the other, so a bundle with no `en-US` row has no fallback floor for those calls and every recipient locale it does not carry a row for raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale is the other case and does not dead-letter: it drops to the bundle's lowest locale tag and renders that silently. Your stack's own `i18n.defaultLocale` is the wrong tag here unless it is spelled `en-US`. | -| **subject** | `string` | ✅ | Subject template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). | -| **bodyHtml** | `string` | ✅ | HTML body template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). | -| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted). Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). | +| **subject** | `string` | ✅ | Subject template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused), and that formatter is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). | +| **bodyHtml** | `string` | ✅ | HTML body template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused), and that formatter is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). | +| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted). Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused), and that formatter is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). | | **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'url' \| 'user' \| 'record'>; required?: boolean; description?: string }[]` | optional (default: `[]`) | | | **fromOverride** | `{ name?: string; address: string }` | optional | | | **replyTo** | `string` | optional | | diff --git a/packages/formula/src/mail-hole-grammar.ts b/packages/formula/src/mail-hole-grammar.ts index b86b72b4769..3621c9c0cba 100644 --- a/packages/formula/src/mail-hole-grammar.ts +++ b/packages/formula/src/mail-hole-grammar.ts @@ -20,8 +20,9 @@ * 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 + * same sentence: one formatter per hole, a formatter the whitelist names + * (`TEMPLATE_FORMATTERS`), 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. * diff --git a/packages/formula/src/template-engine.ts b/packages/formula/src/template-engine.ts index b4d729d36d3..c38c74eded6 100644 --- a/packages/formula/src/template-engine.ts +++ b/packages/formula/src/template-engine.ts @@ -166,6 +166,7 @@ export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS); /** The rule a refused hole breaks. */ export type TemplateHoleGrammarRule = | 'one-formatter' + | 'known-formatter' | 'currency-code-literal' | 'currency-code-path-mail-only' | 'currency-code-path-undeclared'; @@ -187,6 +188,9 @@ export interface TemplateHoleGrammarRefusal { message: string; } +/** A formatter name as a hole writes one; any other text after the `|` names no formatter. */ +const WORD_RE = /^\w+$/; + /** An ISO 4217 alphabetic code: three capital letters, the form `Intl` formats with. */ const CURRENCY_CODE_RE = /^[A-Z]{3}$/; @@ -284,6 +288,12 @@ function chainsASecondFormatter(formatterPart: string): boolean { /** * Judge one hole's formatter against the grammar both template dialects share. * + * A formatter the hole names must be one {@link TEMPLATE_FORMATTERS} holds + * (`known-formatter`). The mail renderer used to print the raw value for any + * other name, so `{{ amount | curency:EUR }}` sent `1234.5` where money was + * meant; the in-app dialect refuses such a hole in its own parse, before this + * rule's sentence is read, and keeps its own wording. + * * @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 @@ -309,6 +319,15 @@ export function judgeHoleFormatter( + 'passing the finished value as its own variable.', }; } + if (WORD_RE.test(formatter) && !TEMPLATE_FORMATTERS.includes(formatter)) { + return { + rule: 'known-formatter', + message: + `the hole ${hole} names the formatter '${formatter}', which is not a template formatter, so it would ` + + `never format the value. Known formatters: ${TEMPLATE_FORMATTERS.join(', ')}. Correct the name, or ` + + 'format the value in the caller that supplies the data and pass the finished text as its own variable.', + }; + } if (formatter === 'currency' && arg !== undefined && !isCurrencyCode(arg)) { if (currencyCodePath(formatterPart) === undefined) { return { rule: 'currency-code-literal', message: currencyArgumentRefusal(hole, arg, dialect) }; diff --git a/packages/formula/src/template-unknown-formatter.test.ts b/packages/formula/src/template-unknown-formatter.test.ts new file mode 100644 index 00000000000..a9828c885e3 --- /dev/null +++ b/packages/formula/src/template-unknown-formatter.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A hole's formatter is one the whitelist names (`known-formatter`), judged by + * `judgeHoleFormatter` for the mail grammar (`parseMailHole` / + * `judgeMailHoleGrammar`), which the mail renderer, the lint rule and the + * publish door read. The mail renderer used to print the raw value for any + * other name (`{{ amount | curency:EUR }}` sent `1234.5`); the in-app dialect + * already refused such a hole in its own parse, and keeps doing so. + */ +import { describe, expect, it } from 'vitest'; + +import { judgeMailHoleGrammar, parseMailHole } from './mail-hole-grammar'; +import { judgeHoleFormatter, TEMPLATE_FORMATTERS, templateEngine } from './template-engine'; + +describe('a formatter the whitelist does not name', () => { + it('⭐ is refused by name, the refusal naming the hole and listing every known formatter', () => { + const refusal = judgeHoleFormatter('{{ amount | curency:EUR }}', 'curency:EUR', 'curency', 'EUR'); + expect(refusal?.rule).toBe('known-formatter'); + expect(refusal?.message).toContain('{{ amount | curency:EUR }}'); + expect(refusal?.message).toContain("the formatter 'curency'"); + expect(refusal?.message).toContain(`Known formatters: ${TEMPLATE_FORMATTERS.join(', ')}.`); + }); + + it('every whitelisted formatter passes the rule', () => { + expect(TEMPLATE_FORMATTERS.length).toBeGreaterThan(0); + for (const name of TEMPLATE_FORMATTERS) { + const arg = name === 'currency' ? 'EUR' : undefined; + expect(judgeHoleFormatter(`{{ x | ${name} }}`, name, name, arg), name).toBeNull(); + } + }); + + it('an inherited object key is not a formatter', () => { + for (const name of ['toString', 'constructor', 'hasOwnProperty']) { + expect(judgeHoleFormatter(`{{ x | ${name} }}`, name, name, undefined)?.rule, name).toBe('known-formatter'); + } + }); + + it('a chained hole is refused by the one-formatter rule first', () => { + expect(judgeHoleFormatter('{{ x | curency | upper }}', 'curency | upper', 'curency', undefined)?.rule) + .toBe('one-formatter'); + }); + + it('text after the `|` that is not a word names no formatter, and the mail grammar leaves it verbatim', () => { + expect(judgeHoleFormatter('{{ x | two words }}', 'two words', 'two words', undefined)).toBeNull(); + expect(parseMailHole(' x | two words ')).toBeNull(); + }); +}); + +describe('the mail grammar', () => { + it('⭐ parses the hole as a hole carrying the refusal', () => { + expect(parseMailHole(' amount | curency:EUR ')).toMatchObject({ + path: 'amount', + formatter: 'curency', + arg: 'EUR', + refusal: { rule: 'known-formatter' }, + }); + expect(parseMailHole(' due | dat:long ')?.refusal?.rule).toBe('known-formatter'); + expect(parseMailHole(' due | Date ')?.refusal?.rule).toBe('known-formatter'); + }); + + it('locates each refused hole of a face, triple-braced included, and passes the known ones', () => { + const face = 'Due {{ amount | currency:EUR }} on {{ due | dat:long }} at {{{ link | lowr }}}'; + expect(judgeMailHoleGrammar(face).map((f) => [f.text, f.start, f.refusal.rule])).toEqual([ + ['{{ due | dat:long }}', 35, 'known-formatter'], + ['{{{ link | lowr }}}', 59, 'known-formatter'], + ]); + expect(judgeMailHoleGrammar('Due {{ amount | currency:EUR }} on {{ due | date:long }}')).toEqual([]); + }); +}); + +describe('the in-app dialect', () => { + it('still refuses an unknown formatter at compile with its own sentence', () => { + const r = templateEngine.compile('{{ record.amount | curency:EUR }}'); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.error.message).toContain('invalid template hole'); + expect(r.error.message).toContain(`Known formatters: ${TEMPLATE_FORMATTERS.join(', ')}.`); + }); + + it('refuses an inherited object key it used to call as a formatter', () => { + const r = templateEngine.compile('{{ record.amount | toString }}'); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.error.message).toContain("the formatter 'toString'"); + }); +}); diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 31a4752796e..ee5df30a1d0 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -2474,7 +2474,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // 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 + // remedy (the renderer printed `0 | number:2`); a formatter name outside + // `TEMPLATE_FORMATTERS` is refused, listing the known formatters + // (`known-formatter`; the renderer printed the raw value); a `currency` argument that // is neither a literal ISO 4217 code nor a marked `@path` into the send's // data is refused, naming `CURRENCY_ARGUMENT_RULE` (the renderer printed the // amount in USD); and a marked path the template's declared `variables` do diff --git a/packages/lint/src/validate-email-template-hole-grammar.ts b/packages/lint/src/validate-email-template-hole-grammar.ts index cb6b78672ce..6a77922ffed 100644 --- a/packages/lint/src/validate-email-template-hole-grammar.ts +++ b/packages/lint/src/validate-email-template-hole-grammar.ts @@ -10,6 +10,9 @@ * renderer can honour as written. A hole the grammar refuses never renders as * its author meant it: * + * - a formatter the whitelist (`TEMPLATE_FORMATTERS`) does not name + * (`{{ amount | curency:EUR }}`) — the renderer used to print the raw value + * (`1234.5`) where a formatted one was meant; * - 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; diff --git a/packages/lint/src/validate-email-template-hole-grammar.unknown-formatter.test.ts b/packages/lint/src/validate-email-template-hole-grammar.unknown-formatter.test.ts new file mode 100644 index 00000000000..bc8acef20cd --- /dev/null +++ b/packages/lint/src/validate-email-template-hole-grammar.unknown-formatter.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `email-template-hole-grammar` refuses a hole whose formatter the whitelist + * (`TEMPLATE_FORMATTERS`) does not name, on every authoring door: the three + * CLI commands and the runtime publish gate for an `email_template` write. + * Which names are known is `@objectstack/formula`'s to decide (pinned in its + * `template-unknown-formatter.test.ts`); this file holds the rule's reading. + */ +import { describe, expect, it } from 'vitest'; + +import { TEMPLATE_FORMATTERS } from '@objectstack/formula'; + +import { AUTHORING_COMMANDS, 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: 'invoice.due', + label: 'Invoice due', + locale: 'en-US', + subject: 'Due {{ amount | currency:EUR }}', + bodyHtml: '

{{ amount | currency:EUR }} by {{ due | date:long }}

', + bodyText: 'Due {{ due | date:long }}', + ...over, +}); + +const MISSPELLED = '{{ amount | curency:EUR }}'; + +describe('the rule, on the stack', () => { + it.each(['subject', 'bodyText', 'bodyHtml'])('⭐ refuses a misspelled formatter in %s, naming the hole and the known formatters', (face) => { + const findings = validateEmailTemplateHoleGrammar({ emailTemplates: [template({ [face]: `Due ${MISSPELLED}` })] }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ severity: 'error', rule: EMAIL_TEMPLATE_HOLE_GRAMMAR, path: `emailTemplates[0].${face}` }); + expect(findings[0].where).toContain('invoice.due'); + expect(findings[0].message).toContain(MISSPELLED); + expect(findings[0].message).toContain(`Known formatters: ${TEMPLATE_FORMATTERS.join(', ')}.`); + }); + + it('a template whose every formatter is known draws nothing', () => { + expect(validateEmailTemplateHoleGrammar({ emailTemplates: [template()] })).toEqual([]); + }); + + it.each([...AUTHORING_COMMANDS])('os %s refuses it at error', (command) => { + const stack = { emailTemplates: [template({ bodyText: 'Due {{ due | dat:long }}' })] }; + 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].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({ subject: MISSPELLED }) }); + expect(result.rulesRun).toContain('validateEmailTemplateHoleGrammar'); + expect(result.errors.map((f) => [f.rule, f.path])).toEqual([[EMAIL_TEMPLATE_HOLE_GRAMMAR, 'emailTemplates[0].subject']]); + }); +}); diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.email-template-unknown-formatter.test.ts b/packages/metadata-protocol/src/runtime-authoring-gate.email-template-unknown-formatter.test.ts new file mode 100644 index 00000000000..00a8db7d520 --- /dev/null +++ b/packages/metadata-protocol/src/runtime-authoring-gate.email-template-unknown-formatter.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The HOST half of the email template publish door, for a hole whose + * formatter the whitelist does not name: the shipped caller threads the + * `email_template` write to the runtime gate, and the refusal arrives in the + * ADR-0112 envelope (`422 INVALID_METADATA`) a Studio / REST `/meta` / MCP + * author's client reads. + * + * Harness: none — `evaluateRuntimeAuthoringGate` is pure. It RETURNS the error + * and its caller throws it, so the refusal is asserted on the envelope, `code` + * AND `status` together, plus the `issues` array. + */ +import { describe, expect, it } from 'vitest'; + +import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js'; + +const template = (over: Record = {}) => ({ + name: 'invoice.due', + label: 'Invoice due', + locale: 'en-US', + subject: 'Due {{ amount | currency:EUR }}', + bodyHtml: '

Due {{ amount | currency:EUR }}

', + variables: [{ name: 'amount', type: 'number' }], + ...over, +}); + +const publishTemplate = (body: unknown) => + evaluateRuntimeAuthoringGate({ + type: 'email_template', + name: 'invoice.due', + state: 'active', + body, + objects: [], + permissions: [], + books: [], + datasets: [], + }); + +describe('the email_template publish door, for a formatter the whitelist does not name', () => { + it('⭐ LIT — a misspelled formatter is refused in the ADR-0112 envelope, naming the hole and the known formatters', () => { + const verdict = publishTemplate(template({ bodyHtml: '

Due {{ amount | curency:EUR }}

' })); + + 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('{{ amount | curency:EUR }}'); + expect(wire).toContain('Known formatters: '); + + // Non-vacuous: the corrected name publishes. + expect(publishTemplate(template()).error).toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-email/src/template-engine.test.ts b/packages/plugins/plugin-email/src/template-engine.test.ts index 30b1f458e33..627997f1815 100644 --- a/packages/plugins/plugin-email/src/template-engine.test.ts +++ b/packages/plugins/plugin-email/src/template-engine.test.ts @@ -95,8 +95,9 @@ describe('template-engine', () => { expect(renderTemplate('{{{ s | upper }}}', { s: 'a&b' })).toBe('A&B'); }); - it('falls back to the raw value for an unknown formatter (no throw)', () => { - expect(renderTemplate('{{ x | bogus }}', { x: 'hi' })).toBe('hi'); + it('refuses an unknown formatter with TEMPLATE_INVALID, never the raw value', () => { + expect(() => renderTemplate('{{ x | bogus }}', { x: 'hi' })).toThrow(/^TEMPLATE_INVALID: /); + expect(() => renderPlainTextTemplate('{{ x | bogus }}', { x: 'hi' })).toThrow("the formatter 'bogus'"); }); it('renders a missing formatted value as empty (never "undefined")', () => { diff --git a/packages/plugins/plugin-email/src/template-engine.ts b/packages/plugins/plugin-email/src/template-engine.ts index f10fc19e132..1c917a9740f 100644 --- a/packages/plugins/plugin-email/src/template-engine.ts +++ b/packages/plugins/plugin-email/src/template-engine.ts @@ -24,12 +24,13 @@ * 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. + * in-app templates. * * 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 + * (`{{ x | default:0 | number:2 }}`), a formatter the whitelist does not name + * (`{{ amount | curency:EUR }}`), 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 @@ -145,9 +146,15 @@ function render( locale: opts.locale, timeZone: opts.timeZone, }); - str = formatted !== undefined - ? formatted - : raw == null ? '' : (typeof raw === 'string' ? raw : String(raw)); // unknown formatter → raw + // The grammar refuses a formatter the whitelist does not name, so + // `formatValue` knows this one; if the two ever disagree the render + // fails rather than print the raw value where a formatted one was meant. + if (formatted === undefined) { + throw new Error( + `TEMPLATE_INVALID: the hole ${match} names the formatter '${parsed.formatter}', which is not a template formatter.`, + ); + } + str = formatted; } else { if (raw == null) return ''; str = typeof raw === 'string' ? raw : String(raw); diff --git a/packages/plugins/plugin-email/src/template-unknown-formatter.render.test.ts b/packages/plugins/plugin-email/src/template-unknown-formatter.render.test.ts new file mode 100644 index 00000000000..466b9352e8b --- /dev/null +++ b/packages/plugins/plugin-email/src/template-unknown-formatter.render.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A mail hole whose formatter the whitelist does not name is refused at + * render with `TEMPLATE_INVALID`. The renderer used to print the raw value in + * its place (`{{ amount | curency:EUR }}` sent `1234.5`). Authoring refuses + * such a hole first (`email-template-hole-grammar`, `@objectstack/lint`); this + * is the guard for a template stored before that rule. + * + * Measured through the 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 = { amount: 1234.5, due: '2026-10-28' }; + +describe('a hole whose formatter the whitelist does not name', () => { + it.each([ + ['subject', { subject: 'Due {{ amount | curency:EUR }}' }], + ['body_html', { body_html: '

{{ amount | curency:EUR }}

' }], + ['body_text', { body_text: 'Due {{ due | dat:long }}' }], + ])('⭐ in %s: the render is refused with TEMPLATE_INVALID, naming the template and the formatter — never the raw value', async (_face, over) => { + const { svc } = makeService([row('invoice.due', over)]); + await expect(svc.renderTemplate({ template: 'invoice.due', data })) + .rejects.toThrow(/^TEMPLATE_INVALID: email template invoice\.due \(locale=en-US\): the hole \{\{ /); + await expect(svc.renderTemplate({ template: 'invoice.due', data })).rejects.toThrow('which is not a template formatter'); + await expect(svc.renderTemplate({ template: 'invoice.due', data })).rejects.toThrow('Known formatters: '); + }); + + it('sendTemplate refuses the same template and sends nothing', async () => { + const { svc, sent } = makeService([row('invoice.due', { subject: 'Due {{ amount | curency:EUR }}' })]); + await expect(svc.sendTemplate({ template: 'invoice.due', to: 'a@x.com', data })) + .rejects.toThrow(/^TEMPLATE_INVALID: /); + expect(sent).toEqual([]); + }); + + it('the corrected name renders in every face', async () => { + const { svc } = makeService([row('invoice.due', { + subject: 'Due {{ amount | currency:EUR }}', + body_html: '

{{ amount | currency:EUR }}

', + body_text: 'Due {{ due | date:iso }}', + })]); + expect(await svc.renderTemplate({ template: 'invoice.due', data })) + .toEqual({ subject: 'Due €1,234.50', html: '

€1,234.50

', text: 'Due 2026-10-28' }); + }); +}); diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index e7f8098e18c..8120a39c38c 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -319,8 +319,9 @@ export interface IEmailService { * - `TEMPLATE_INACTIVE` — the template exists but `active=false`. * - `MISSING_VARIABLES` — declared `required` variables absent from `data`. * - `TEMPLATE_INVALID` — the stored template has a hole the authoring rules - * refuse (rule `email-template-hole-grammar`: a second formatter, or a - * `currency` argument that is not a literal ISO 4217 code). It is refused + * refuse (rule `email-template-hole-grammar`: a second formatter, a + * formatter the whitelist does not name, or a `currency` argument that is + * neither a literal ISO 4217 code nor a marked `@path`). It is refused * at render, as `TEMPLATE_INVALID: email template (locale=): * `, so nothing is delivered. Permanent: retrying cannot * succeed until the template is re-published without that hole. @@ -344,8 +345,9 @@ export interface IEmailService { * - `TEMPLATE_INACTIVE` — the template exists but `active=false`. * - `MISSING_VARIABLES` — declared `required` variables absent from `data`. * - `TEMPLATE_INVALID` — the stored template has a hole the authoring rules - * refuse (rule `email-template-hole-grammar`: a second formatter, or a - * `currency` argument that is not a literal ISO 4217 code). It is refused + * refuse (rule `email-template-hole-grammar`: a second formatter, a + * formatter the whitelist does not name, or a `currency` argument that is + * neither a literal ISO 4217 code nor a marked `@path`). It is refused * at render, as `TEMPLATE_INVALID: email template (locale=): * `, so nothing is rendered. Permanent: retrying cannot * succeed until the template is re-published without that hole. diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index d9c5a4e4d8c..c2269ae362b 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -102,14 +102,15 @@ const EMAIL_TEMPLATE_IS_SYSTEM_REMOVED = * mail renderer and the `email-template-hole-grammar` authoring rule quote the * same constant, and the formula package pins this text against it. The spec * only states the grammar: the formula package judges it and plugin-email - * renders it. It promises no refusal of an unknown formatter name: none - * exists today (the mail renderer prints such a hole raw; #26948). + * renders it. A formatter name the template engine does not know is refused + * by the same grammar (`known-formatter`), at authoring and at send. */ const MAIL_TEMPLATE_HOLES = "Holes are `{{ path }}` or `{{ path | formatter[:arg] }}` over the send's `data`, with at most one formatter " - + 'per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 ' - + "code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the " - + "send's data whose value is such a code (`currency:@currency_code`)."; + + 'per hole (a second, chained formatter is refused), and that formatter is one the template engine knows ' + + '(an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` ' + + 'argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template ' + + "only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`)."; export const EmailTemplateDefinitionVariableSchema = lazySchema(() => z.object({ name: z.string().describe(