From d1fffa100aadfe1cde9a3b9e1345213d7f789e95 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:49:03 +0000 Subject: [PATCH 1/2] feat(formula, plugin-email, lint): a mail template formats an amount in the record's own currency with currency:@path 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`). The `@` mark keeps the two forms apart for every input, so every refusal of a bare path stays as it was. - formula: the mail grammar admits the marked form (`codePath` on the parsed hole) and, given the template's declared variables, refuses a path none of them declares; the in-app dialect refuses the form by name (`currency-code-path-mail-only`); `CURRENCY_ARGUMENT_RULE` is the one sentence every reader quotes. - plugin-email: the renderer resolves the path and refuses a value that is not a code (missing, null, lowercase) with TEMPLATE_INVALID, never a default currency. - lint: email-template-hole-grammar passes the template's declared variables. - spec: the email_template face descriptions state the grammar. Claude-Session: https://claude.ai/code/session_01WGufziJbRgRHSN9KrbF46h Co-authored-by: Claude --- .../26660-currency-code-path-formula.md | 18 ++ .changeset/26660-currency-code-path-lint.md | 12 ++ .../26660-currency-code-path-plugin-email.md | 14 ++ .changeset/26660-currency-code-path-spec.md | 12 ++ content/docs/automation/email-templates.mdx | 39 +++- .../docs/references/system/email-template.mdx | 10 +- packages/formula/src/index.ts | 14 +- packages/formula/src/mail-hole-grammar.ts | 64 +++++- .../src/template-currency-code-path.test.ts | 182 ++++++++++++++++++ packages/formula/src/template-engine.ts | 114 ++++++++++- ...emplate-hole-grammar.currency-path.test.ts | 95 +++++++++ .../validate-email-template-hole-grammar.ts | 29 ++- ...thoring-gate.email-template-writes.test.ts | 26 +++ .../plugins/plugin-email/src/email-service.ts | 8 +- ...template-currency-code-path.render.test.ts | 134 +++++++++++++ .../plugin-email/src/template-engine.ts | 26 ++- .../spec/src/system/email-template.zod.ts | 28 ++- skills/objectstack-formula/SKILL.md | 4 +- 18 files changed, 779 insertions(+), 50 deletions(-) create mode 100644 .changeset/26660-currency-code-path-formula.md create mode 100644 .changeset/26660-currency-code-path-lint.md create mode 100644 .changeset/26660-currency-code-path-plugin-email.md create mode 100644 .changeset/26660-currency-code-path-spec.md create mode 100644 packages/formula/src/template-currency-code-path.test.ts create mode 100644 packages/lint/src/validate-email-template-hole-grammar.currency-path.test.ts create mode 100644 packages/plugins/plugin-email/src/template-currency-code-path.render.test.ts diff --git a/.changeset/26660-currency-code-path-formula.md b/.changeset/26660-currency-code-path-formula.md new file mode 100644 index 00000000000..3ec7f321fb6 --- /dev/null +++ b/.changeset/26660-currency-code-path-formula.md @@ -0,0 +1,18 @@ +--- +'@objectstack/formula': minor +--- + +feat(formula): the mail hole grammar admits `currency:@path`, which reads the ISO 4217 code from the send's data; the in-app template dialect refuses that form by name + +Clause-②: yes +Metadata-protocol: added + +A mail template can now format each record's amount in that record's own currency: `{{ planned_amount | currency:@currency_code }}`. The rule, exported as `CURRENCY_ARGUMENT_RULE`: 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`). + +- **Widened:** `parseMailHole` and `judgeMailHoleGrammar` admit `currency:@path` when the path is a dotted path of word characters with no empty segment, written right after the `@` and not in quotes. The parsed hole carries the path as `codePath`. +- **Unchanged refusals:** a bare argument is still never read as a path, so `currency:currency_code`, `currency:record.currency_code`, a lowercase or empty code and a double-quoted code stay refused (`currency-code-literal`); so do `currency:@`, `currency:@ x`, `currency:'@x'` and `currency:@a..b`. In the mail dialect that refusal now names the marked form as the way to read a code from the data. +- **Declared variables:** `judgeMailHoleGrammar(face, { variables })` refuses a marked path that none of the template's declared variables names, itself or as a root it lies under (`currency-code-path-undeclared`), when the template declares any. +- **In-app dialect:** `templateEngine` (flow text slots, action and bulk-action copy, `titleFormat`, `promptTemplate`) refuses `currency:@path` at compile and evaluate with its own rule, `currency-code-path-mail-only`: the doors that judge that text check a hole's path against the roots a slot binds and never read an argument. It was refused there before too, as `currency-code-literal`. +- **The value:** `currencyCodeValueRefusal(hole, path, value)` names a resolved value that is not an ISO 4217 code in three capital letters exactly as stored (missing, `null`, `eur`, `' EUR'`, a number). `formatValue('currency', …)` still never reads a path and still throws for a non-code argument; no argument still formats in US dollars. + +New exports: `CURRENCY_ARGUMENT_RULE`, `currencyCodePath`, `currencyCodeValueRefusal`, and the types `TemplateHoleDialect` and `MailHoleGrammarOptions`. `judgeHoleFormatter` takes an optional fifth argument, the dialect (`'in-app'` by default, `'mail'`), and `TemplateHoleGrammarRule` gains `currency-code-path-mail-only` and `currency-code-path-undeclared`. diff --git a/.changeset/26660-currency-code-path-lint.md b/.changeset/26660-currency-code-path-lint.md new file mode 100644 index 00000000000..cde14ebfa7d --- /dev/null +++ b/.changeset/26660-currency-code-path-lint.md @@ -0,0 +1,12 @@ +--- +'@objectstack/lint': minor +--- + +feat(lint): `email-template-hole-grammar` accepts `currency:@path` and refuses a marked path the template's declared variables do not declare + +Clause-②: yes +Metadata-protocol: added + +`os validate`, `os build`, `os lint` and the `email_template` publish door accept a mail template hole such as `{{ planned_amount | currency:@currency_code }}`, which reads the ISO 4217 code from the send's data, when its path is well-formed. When the template declares `variables`, a marked path that none of them declares, itself or as a root it lies under (`contract` declares `contract.currency_code`), is refused at `error` (a `422 INVALID_METADATA` at the publish door), naming the hole and the path. A template that declares no variables has the path judged at render. + +Everything the rule refused before stays refused: a bare path (`currency:currency_code`), a lowercase or empty code, a quoted or empty marked path, and a second formatter. The finding's hint now quotes the argument rule, `CURRENCY_ARGUMENT_RULE` from `@objectstack/formula`. diff --git a/.changeset/26660-currency-code-path-plugin-email.md b/.changeset/26660-currency-code-path-plugin-email.md new file mode 100644 index 00000000000..fc6b49792a7 --- /dev/null +++ b/.changeset/26660-currency-code-path-plugin-email.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-email': minor +--- + +feat(plugin-email): a mail template formats an amount in the record's own currency with `currency:@path` + +Clause-②: yes +Metadata-protocol: added + +`{{ planned_amount | currency:@currency_code }}` formats the amount in the currency whose ISO 4217 code the send's `data` holds at `currency_code`, so one template, in every locale row of its bundle, serves records in many currencies. With `currency_code: 'EUR'` it renders `€1,234.50` in `en-US`; with `'CNY'` it renders `CN¥1,234.50` in `en-US` and `¥1,234.50` in `zh-CN`. The path is resolved like a hole's own path, in all three faces, double- or triple-braced. + +The value at the path must be the code in three capital letters, as stored. A missing or `null` value, a lowercase code (`eur`) or any other value makes `renderTemplate` and `sendTemplate` throw `TEMPLATE_INVALID: email template NAME (locale=L): …`, naming the hole, the path and the value, and send nothing. The amount is never formatted in a default currency in its place. + +Unchanged: a literal code (`currency:EUR`) formats in that code, no argument formats in US dollars, and a bare path (`currency:currency_code`) is still refused with `TEMPLATE_INVALID`. diff --git a/.changeset/26660-currency-code-path-spec.md b/.changeset/26660-currency-code-path-spec.md new file mode 100644 index 00000000000..f47a3480892 --- /dev/null +++ b/.changeset/26660-currency-code-path-spec.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': minor +--- + +docs(spec): the `email_template` faces state the mail hole grammar, including `currency:@path`, which reads the ISO 4217 code from the send's data + +Clause-②: yes +Metadata-protocol: added + +`EmailTemplateDefinitionSchema`'s `subject`, `bodyHtml` and `bodyText` descriptions now state the hole grammar a mail template is rendered and judged with: holes are `{{ path }}` or `{{ path | formatter[:arg] }}` over the send's `data`, with one formatter per hole, and 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`). The `variables[].name` description adds that, when a template declares any variable, the path of a `currency:@path` argument must be one of them or lie under one. + +Only descriptions change; no key, type or default does. The form itself is judged by `@objectstack/formula`, refused or accepted at authoring by `@objectstack/lint` and rendered by `@objectstack/plugin-email`. diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index f73ab4e0bdd..62168409e35 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -88,14 +88,37 @@ never needs triple braces. 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. +**The `currency` argument is a literal code or a marked path.** 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`). `currency` +with no argument formats in US dollars. + +The marked path is how one template shows each record's amount in that record's +own currency: + +```ts +subject: 'Instalment overdue: {{ planned_amount | currency:@currency_code }}', +``` + +With `data` holding `planned_amount: 1234.5` and `currency_code: 'EUR'` it renders +`€1,234.50` in `en-US`; with `currency_code: 'CNY'` it renders `CN¥1,234.50`. The +value at the path must be the code in capitals, as stored: a missing or `null` +value, a lowercase code (`eur`) or anything else makes the render fail with +`TEMPLATE_INVALID`, naming the template, its locale, the hole and the value. The +amount is never formatted in a default currency in its place. + +A bare argument is never read as a path: `currency:currency_code` is refused, +and so are a lowercase or empty code, a quoted path and an `@` with no path +after it. `os validate`, `os build`, `os lint` and the metadata publish door +refuse these (`email-template-hole-grammar`, a `422 INVALID_METADATA` at the +publish door), naming the hole and the rule. When the template declares +`variables`, the same doors also refuse a marked path that none of them +declares (a variable `contract` declares `contract.currency_code`). + +The `@` form belongs to mail templates. In-app template text (flow +notifications, action and bulk-action copy) takes a literal code only and +refuses `currency:@path` by name. **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 diff --git a/content/docs/references/system/email-template.mdx b/content/docs/references/system/email-template.mdx index 2aa1fa74d21..1640c68b4a6 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 | -| **bodyHtml** | `string` | ✅ | HTML body template | -| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted) | +| **subject** | `string` | ✅ | Subject template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with one formatter from the template engine whitelist per hole. 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 one formatter from the template engine whitelist per hole. 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 one formatter from the template engine whitelist per hole. 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 | | @@ -71,7 +71,7 @@ const result = EmailTemplateDefinitionSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Variable name as referenced in placeholders (snake_case or dotted path) | +| **name** | `string` | ✅ | Variable name as referenced in placeholders (snake_case or dotted path). When the template declares any variable, the path of a `currency:@path` argument must be one of them or lie under one. | | **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'url' \| 'user' \| 'record'>` | optional (default: `"string"`) | | | **required** | `boolean` | optional (default: `false`) | | | **description** | `string` | optional | Author hint shown in Studio | @@ -93,7 +93,7 @@ const result = EmailTemplateDefinitionSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Variable name as referenced in placeholders (snake_case or dotted path) | +| **name** | `string` | ✅ | Variable name as referenced in placeholders (snake_case or dotted path). When the template declares any variable, the path of a `currency:@path` argument must be one of them or lie under one. | | **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'url' \| 'user' \| 'record'>` | optional (default: `"string"`) | | | **required** | `boolean` | optional (default: `false`) | | | **description** | `string` | optional | Author hint shown in Studio | diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index d7c29f891cd..9a4645d8efa 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -63,12 +63,20 @@ export type { ParseCelToAstOptions, } from './cel-engine'; export { cronEngine } from './cron-engine'; -export { templateEngine, TEMPLATE_FORMATTERS, formatValue, judgeHoleFormatter } from './template-engine'; -export type { TemplateHoleGrammarRefusal, TemplateHoleGrammarRule } from './template-engine'; +export { + templateEngine, + TEMPLATE_FORMATTERS, + formatValue, + judgeHoleFormatter, + CURRENCY_ARGUMENT_RULE, + currencyCodePath, + currencyCodeValueRefusal, +} from './template-engine'; +export type { TemplateHoleDialect, 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'; +export type { ParsedMailHole, MailHoleGrammarFinding, MailHoleGrammarOptions } 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 index 3feb62f6eb0..b86b72b4769 100644 --- a/packages/formula/src/mail-hole-grammar.ts +++ b/packages/formula/src/mail-hole-grammar.ts @@ -24,9 +24,19 @@ * 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. + * + * One argument form is the mail dialect's own: `currency:@path` reads the + * ISO 4217 code from the send's data (`CURRENCY_ARGUMENT_RULE`), so one + * template formats each record's amount in that record's currency. The path + * after the `@` is the same dotted path a hole names. Here it is parsed + * ({@link ParsedMailHole}.`codePath`), checked against the template's declared + * `variables` when it declares any ({@link judgeMailHoleGrammar}), and the + * renderer resolves it and refuses a value that is not a code + * (`currencyCodeValueRefusal`) — it never formats the amount in a default + * currency in its place. */ -import { judgeHoleFormatter, type TemplateHoleGrammarRefusal } from './template-engine'; +import { CURRENCY_ARGUMENT_RULE, currencyCodePath, judgeHoleFormatter, type TemplateHoleGrammarRefusal } from './template-engine'; /** 1=open (`{{` or `{{{`), 2=brace-free inner body, 3=close (`}}` or `}}}`). */ const MAIL_HOLE_SOURCE = String.raw`(\{\{\{?)([^{}]*)(\}\}\}?)`; @@ -52,6 +62,12 @@ export interface ParsedMailHole { formatter?: string; /** The formatter's argument, quotes removed; absent when none was written. */ arg?: string; + /** + * Set for `currency:@path`: the path into the send's data the currency code is + * read from (`currency_code`). The renderer resolves it; `arg` keeps the text + * as written (`@currency_code`). + */ + codePath?: string; /** Set when the grammar refuses the hole: it is not rendered, and authoring refuses it. */ refusal?: TemplateHoleGrammarRefusal; } @@ -76,20 +92,42 @@ export function parseMailHole(inner: string, hole = `{{${inner}}}`): ParsedMailH // 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); + const refusal = judgeHoleFormatter(hole, formatterPart, name, undefined, 'mail'); return refusal ? { path, formatter: name, refusal } : null; } const formatter = m[1]; const arg = m[2]; - const refusal = judgeHoleFormatter(hole, formatterPart, formatter, arg); + const refusal = judgeHoleFormatter(hole, formatterPart, formatter, arg, 'mail'); + const codePath = refusal ? undefined : currencyCodePath(formatterPart); return { path, formatter, ...(arg !== undefined ? { arg } : {}), + ...(codePath !== undefined ? { codePath } : {}), ...(refusal ? { refusal } : {}), }; } +/** + * True when a `currency:@path` path is one the template declares: a declared + * variable names the path itself, or a root it lies under (`contract` + * declares `contract.currency_code`). + */ +function isDeclaredPath(path: string, variables: readonly string[]): boolean { + return variables.some((name) => name === path || path.startsWith(`${name}.`)); +} + +/** What {@link judgeMailHoleGrammar} judges a face against, beyond its own text. */ +export interface MailHoleGrammarOptions { + /** + * The names of the template's declared `variables`. When the template + * declares any, a `currency:@path` hole whose path none of them declares is + * refused (`currency-code-path-undeclared`). Absent or empty, the path is + * judged at render, where a code that does not resolve is refused. + */ + variables?: readonly string[]; +} + /** One hole of a mail template face that the grammar refuses. */ export interface MailHoleGrammarFinding { /** Offset of the hole's first brace in the face. */ @@ -100,12 +138,28 @@ export interface MailHoleGrammarFinding { } /** Every hole in one mail template face (`subject`, `bodyText` or `bodyHtml`) the grammar refuses, in order. */ -export function judgeMailHoleGrammar(face: string): MailHoleGrammarFinding[] { +export function judgeMailHoleGrammar(face: string, options: MailHoleGrammarOptions = {}): MailHoleGrammarFinding[] { if (typeof face !== 'string' || face === '') return []; + const variables = options.variables ?? []; 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 }); + if (parsed?.refusal) { + findings.push({ start: m.index ?? 0, text: m[0], refusal: parsed.refusal }); + } else if (parsed?.codePath !== undefined && variables.length > 0 && !isDeclaredPath(parsed.codePath, variables)) { + findings.push({ + start: m.index ?? 0, + text: m[0], + refusal: { + rule: 'currency-code-path-undeclared', + message: + `the hole ${m[0]} reads its currency code from \`${parsed.codePath}\`, which this template's ` + + `\`variables\` do not declare (it declares ${variables.map((v) => `\`${v}\``).join(', ')}). ` + + `${CURRENCY_ARGUMENT_RULE} Declare \`${parsed.codePath}\` beside them, or read the code from a path ` + + 'they declare.', + }, + }); + } } return findings; } diff --git a/packages/formula/src/template-currency-code-path.test.ts b/packages/formula/src/template-currency-code-path.test.ts new file mode 100644 index 00000000000..bb43955e192 --- /dev/null +++ b/packages/formula/src/template-currency-code-path.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `currency:@path` — a mail template reads the ISO 4217 code from the send's + * data, so one template formats each record's amount in that record's + * currency. The `@` mark keeps the form apart from the literal code + * (`currency:EUR`) for every input; a bare path (`currency:currency_code`) + * stays refused, pinned in `template-hole-grammar.test.ts`. + * + * The mail dialect admits the form; the in-app dialect refuses it by name + * (`currency-code-path-mail-only`): its doors judge a hole's path against the + * roots a slot binds and never read an argument. + */ +import { EmailTemplateDefinitionSchema, EmailTemplateDefinitionVariableSchema } from '@objectstack/spec/system'; +import { describe, expect, it } from 'vitest'; + +import { judgeMailHoleGrammar, parseMailHole } from './mail-hole-grammar'; +import { + CURRENCY_ARGUMENT_RULE, + currencyCodePath, + currencyCodeValueRefusal, + formatValue, + judgeHoleFormatter, + templateEngine, +} from './template-engine'; +import { validateExpression } from './validate'; + +describe('the marked path form', () => { + it.each([ + ['currency:@currency_code', 'currency_code'], + ['currency: @contract.currency_code', 'contract.currency_code'], + ['currency:@line_1.code', 'line_1.code'], + ])('reads %s as the path %s', (part, path) => { + expect(currencyCodePath(part)).toBe(path); + }); + + it.each([ + ['a bare path', 'currency:currency_code'], + ['a literal code', 'currency:EUR'], + ['a mark with no path', 'currency:@'], + ['a space after the mark', 'currency:@ currency_code'], + ['a quoted marked path', "currency:'@currency_code'"], + ['an empty segment', 'currency:@a..b'], + ['a trailing dot', 'currency:@a.'], + ['a character outside a path', 'currency:@currency-code'], + ['another formatter', 'default:@currency_code'], + ['no argument', 'currency'], + ])('reads %s as no path', (_label, part) => { + expect(currencyCodePath(part)).toBeUndefined(); + }); +}); + +describe('the mail dialect', () => { + it('⭐ admits currency:@path and parses the path the renderer resolves', () => { + expect(judgeHoleFormatter('{{ x | currency:@currency_code }}', 'currency:@currency_code', 'currency', '@currency_code', 'mail')) + .toBeNull(); + expect(parseMailHole(' planned_amount | currency:@currency_code ')).toEqual({ + path: 'planned_amount', + formatter: 'currency', + arg: '@currency_code', + codePath: 'currency_code', + }); + expect(judgeMailHoleGrammar('Due {{ planned_amount | currency:@contract.currency_code }}')).toEqual([]); + expect(judgeMailHoleGrammar('{{{ planned_amount | currency:@currency_code }}}')).toEqual([]); + }); + + it('a literal code carries no path', () => { + expect(parseMailHole(' planned_amount | currency:EUR ')).toEqual({ path: 'planned_amount', formatter: 'currency', arg: 'EUR' }); + }); + + it.each([ + ['a mark with no path', '@'], + ['a space after the mark', '@ currency_code'], + ['a quoted marked path', "'@currency_code'"], + ['an empty segment', '@a..b'], + ])('refuses %s as currency-code-literal, quoting the argument rule', (_label, arg) => { + const [finding] = judgeMailHoleGrammar(`Due {{ amount | currency:${arg} }}`); + expect(finding?.refusal.rule).toBe('currency-code-literal'); + expect(finding?.refusal.message).toContain(CURRENCY_ARGUMENT_RULE); + }); + + it('a bare path is still refused, and the mail refusal names the marked form', () => { + const [finding] = judgeMailHoleGrammar('Due {{ amount | currency:currency_code }}'); + expect(finding?.refusal.rule).toBe('currency-code-literal'); + expect(finding?.refusal.message).toContain('never read as a path'); + expect(finding?.refusal.message).toContain('`currency:@currency_code`'); + }); +}); + +describe('declared variables', () => { + const face = 'Due {{ planned_amount | currency:@contract.currency_code }}'; + + it('⭐ refuses a marked path no declared variable names, naming the hole, the path and the declarations', () => { + const findings = judgeMailHoleGrammar(face, { variables: ['planned_amount', 'currency'] }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ text: '{{ planned_amount | currency:@contract.currency_code }}', start: 4 }); + expect(findings[0].refusal.rule).toBe('currency-code-path-undeclared'); + expect(findings[0].refusal.message).toContain('`contract.currency_code`'); + expect(findings[0].refusal.message).toContain('`planned_amount`, `currency`'); + expect(findings[0].refusal.message).toContain(CURRENCY_ARGUMENT_RULE); + }); + + it('admits the path itself, or a root it lies under', () => { + expect(judgeMailHoleGrammar(face, { variables: ['contract.currency_code'] })).toEqual([]); + expect(judgeMailHoleGrammar(face, { variables: ['contract'] })).toEqual([]); + }); + + it('a template that declares none has its path judged at render instead', () => { + expect(judgeMailHoleGrammar(face, { variables: [] })).toEqual([]); + expect(judgeMailHoleGrammar(face)).toEqual([]); + }); + + it('a literal code is never judged against the declarations', () => { + expect(judgeMailHoleGrammar('Due {{ amount | currency:EUR }}', { variables: ['other'] })).toEqual([]); + }); +}); + +describe('the value the path holds', () => { + const hole = '{{ amount | currency:@currency_code }}'; + + it('admits an ISO 4217 code in three capital letters, exactly as stored', () => { + expect(currencyCodeValueRefusal(hole, 'currency_code', 'EUR')).toBeNull(); + expect(currencyCodeValueRefusal(hole, 'currency_code', 'CNY')).toBeNull(); + }); + + it.each([ + ['missing', undefined, 'missing'], + ['null', null, 'null'], + ['a lowercase code', 'eur', "'eur'"], + ['a padded code', ' EUR', "' EUR'"], + ['a number', 978, 'number 978'], + ['an object', { code: 'EUR' }, 'object {"code":"EUR"}'], + ])('⭐ refuses %s, naming the hole, the path and the value', (_label, value, shown) => { + const message = currencyCodeValueRefusal(hole, 'currency_code', value); + expect(message).toContain(hole); + expect(message).toContain('`currency_code`'); + expect(message).toContain(`is ${shown},`); + expect(message).toContain(CURRENCY_ARGUMENT_RULE); + }); +}); + +describe('the in-app dialect refuses the marked form by name', () => { + it('the judge refuses it by default', () => { + const refusal = judgeHoleFormatter('{{ x | currency:@currency_code }}', 'currency:@currency_code', 'currency', '@currency_code'); + expect(refusal?.rule).toBe('currency-code-path-mail-only'); + expect(refusal?.message).toContain('only a mail template reads'); + }); + + it('⭐ compile, the authoring validator and evaluate all refuse it — never a formatted amount', () => { + const source = 'Due {{ record.amount | currency:@record.currency_code }}'; + const compiled = templateEngine.compile(source); + expect(compiled.ok).toBe(false); + if (!compiled.ok) { + expect(compiled.error.kind).toBe('parse'); + expect(compiled.error.message).toContain('{{ record.amount | currency:@record.currency_code }}'); + expect(compiled.error.message).toContain('only a mail template reads'); + } + const validated = validateExpression('template', { dialect: 'template', source }); + expect(validated.errors.map((e) => e.code)).toEqual(['invalid-template']); + const evaluated = templateEngine.evaluate( + { dialect: 'template', source }, + { record: { amount: 1234.5, currency_code: 'EUR' }, locale: 'en-US' }, + ); + expect(evaluated.ok).toBe(false); + }); +}); + +describe('the formatter never reads a path', () => { + it('throws for the marked form handed to it unresolved, instead of formatting in USD', () => { + expect(() => formatValue('currency', 1234.5, '@currency_code', { locale: 'en-US' })).toThrow(RangeError); + }); +}); + +describe('the spec states the same rule', () => { + it.each(['subject', 'bodyHtml', 'bodyText'] as const)('the email_template %s description quotes CURRENCY_ARGUMENT_RULE', (face) => { + expect(EmailTemplateDefinitionSchema.shape[face].description).toContain(CURRENCY_ARGUMENT_RULE); + }); + + it('the variables description names the declared-path check', () => { + expect(EmailTemplateDefinitionVariableSchema.shape.name.description).toContain('`currency:@path`'); + }); +}); diff --git a/packages/formula/src/template-engine.ts b/packages/formula/src/template-engine.ts index c9852e7b557..b4d729d36d3 100644 --- a/packages/formula/src/template-engine.ts +++ b/packages/formula/src/template-engine.ts @@ -72,7 +72,9 @@ const FORMATTERS: Record = { // 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. + // throws rather than format the amount in a currency nobody wrote. The mail + // dialect's `currency:@path` form is resolved by the mail renderer, which + // hands this formatter the code it read; this formatter never reads a path. currency: (v, arg, locale) => { if (arg !== undefined && !isCurrencyCode(arg)) { throw new RangeError(currencyArgumentRefusal(`currency:${arg}`, arg)); @@ -149,14 +151,34 @@ export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS); // 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 +// - The `currency` argument is a literal ISO 4217 code in capitals. A bare +// 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. +// nobody wrote. +// - A mail template may instead read the code from the send's data, marked +// with `@` (`currency:@currency_code`, `CURRENCY_ARGUMENT_RULE`). The mark +// keeps the two forms apart for every input: a literal code never starts +// with `@`, and no path does. The in-app dialect refuses the marked form by +// name: its doors judge the roots of a hole's path against the slot's +// closed scope and do not read an argument, so a path there would reach +// the page unjudged. /** The rule a refused hole breaks. */ -export type TemplateHoleGrammarRule = 'one-formatter' | 'currency-code-literal'; +export type TemplateHoleGrammarRule = + | 'one-formatter' + | 'currency-code-literal' + | 'currency-code-path-mail-only' + | 'currency-code-path-undeclared'; + +/** + * The `currency` argument rule, in the one sentence the mail renderer, the + * `email-template-hole-grammar` authoring rule and the spec's + * `email_template` face descriptions all quote. + */ +export const CURRENCY_ARGUMENT_RULE = + '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`).'; /** A hole the shared grammar refuses, with the sentence an author is shown. */ export interface TemplateHoleGrammarRefusal { @@ -172,7 +194,17 @@ function isCurrencyCode(arg: string): boolean { return CURRENCY_CODE_RE.test(arg.trim()); } -function currencyArgumentRefusal(hole: string, arg: string): string { +/** The dialect a hole is judged in: the in-app template engine's (the default) or a mail template's. */ +export type TemplateHoleDialect = 'in-app' | 'mail'; + +function currencyArgumentRefusal(hole: string, arg: string, dialect: TemplateHoleDialect = 'in-app'): string { + if (dialect === 'mail') { + return ( + `the hole ${hole} gives the currency formatter the argument '${arg.trim()}', which is neither an ISO 4217 ` + + 'currency code nor a marked path: a bare argument is never read as a path to a value. ' + + `${CURRENCY_ARGUMENT_RULE} Write the code itself, or mark the path that holds it with \`@\`.` + ); + } 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 ' @@ -181,6 +213,56 @@ function currencyArgumentRefusal(hole: string, arg: string): string { ); } +/** The `@` mark, then a dotted path of word characters with no empty segment. */ +const CURRENCY_CODE_PATH_RE = /^@(\w+(?:\.\w+)*)$/; + +/** + * The path a `currency` hole reads its code from (`currency:@currency_code` → + * `currency_code`), or `undefined` when the formatter part is not that form. + * The form is read from the text as written: an argument in quotes + * (`currency:'@x'`) is not a path, and neither is a mark with no path after + * it, a space after the mark, or an empty segment (`@a..b`). + * + * @param formatterPart the text after the hole's first `|`, trimmed + */ +export function currencyCodePath(formatterPart: string): string | undefined { + const colon = formatterPart.indexOf(':'); + if (colon === -1 || formatterPart.slice(0, colon).trim() !== 'currency') return undefined; + return CURRENCY_CODE_PATH_RE.exec(formatterPart.slice(colon + 1).trim())?.[1]; +} + +function describeCodeValue(value: unknown): string { + if (value === undefined) return 'missing'; + if (value === null) return 'null'; + if (typeof value === 'string') return `'${value}'`; + try { + return `${typeof value} ${JSON.stringify(value)}`; + } catch { + return typeof value; + } +} + +/** + * Why the value a `currency:@path` hole read from the send's data cannot be + * the currency it formats in, or `null` when it is an ISO 4217 code in three + * capital letters, exactly as stored. A missing or null value, a lowercase + * code (`eur`), a padded one or any other value is refused: the amount is then + * formatted in no currency at all, never in a default one. + * + * @param hole the hole as written, braces included + * @param path the path after the `@` mark + * @param value what the path resolved to in the send's data + */ +export function currencyCodeValueRefusal(hole: string, path: string, value: unknown): string | null { + if (typeof value === 'string' && CURRENCY_CODE_RE.test(value)) return null; + return ( + `the hole ${hole} reads the currency code from \`${path}\`, whose value in this send's data is ` + + `${describeCodeValue(value)}, not an ISO 4217 code in three capital letters, so the amount is not ` + + `formatted in any currency. ${CURRENCY_ARGUMENT_RULE} Supply the code in capitals (\`EUR\`) under ` + + `\`${path}\`.` + ); +} + /** * 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 @@ -206,6 +288,8 @@ function chainsASecondFormatter(formatterPart: string): boolean { * @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 + * @param dialect `'mail'` admits the `currency:@path` form ({@link CURRENCY_ARGUMENT_RULE}); the + * in-app dialect (the default) refuses it by name * @returns the refusal, or `null` when the hole is within the grammar */ export function judgeHoleFormatter( @@ -213,6 +297,7 @@ export function judgeHoleFormatter( formatterPart: string, formatter: string, arg: string | undefined, + dialect: TemplateHoleDialect = 'in-app', ): TemplateHoleGrammarRefusal | null { if (chainsASecondFormatter(formatterPart)) { return { @@ -225,7 +310,20 @@ export function judgeHoleFormatter( }; } if (formatter === 'currency' && arg !== undefined && !isCurrencyCode(arg)) { - return { rule: 'currency-code-literal', message: currencyArgumentRefusal(hole, arg) }; + if (currencyCodePath(formatterPart) === undefined) { + return { rule: 'currency-code-literal', message: currencyArgumentRefusal(hole, arg, dialect) }; + } + if (dialect !== 'mail') { + return { + rule: 'currency-code-path-mail-only', + message: + `the hole ${hole} reads its currency code from the data (\`currency:@path\`), a form only a mail ` + + 'template reads: here the `currency` argument is a literal ISO 4217 code in three capital letters ' + + '(`currency:EUR`), because the doors that judge this text check a hole\'s path against the roots ' + + 'it binds and never read an argument. Write the code itself, or format the amount in the caller ' + + 'that supplies the data and pass the formatted text as its own variable.', + }; + } } return null; } diff --git a/packages/lint/src/validate-email-template-hole-grammar.currency-path.test.ts b/packages/lint/src/validate-email-template-hole-grammar.currency-path.test.ts new file mode 100644 index 00000000000..e8aedf71a91 --- /dev/null +++ b/packages/lint/src/validate-email-template-hole-grammar.currency-path.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `validateEmailTemplateHoleGrammar` and `currency:@path`, the mail form that + * reads the ISO 4217 code from the send's data: accepted on every authoring + * door when the path is well-formed, refused when the template declares + * `variables` and none of them declares the path. Everything the rule refused + * before stays refused (`validate-email-template-hole-grammar.test.ts`). + */ +import { CURRENCY_ARGUMENT_RULE } from '@objectstack/formula'; +import { describe, expect, it } from 'vitest'; + +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 MARKED = '{{ planned_amount | currency:@contract.currency_code }}'; + +const template = (over: Record = {}) => ({ + name: 'contract.overdue', + label: 'Instalment overdue', + locale: 'en-US', + subject: `Due ${MARKED}`, + bodyHtml: `

${MARKED} for {{ contract.name }}

`, + bodyText: 'Due {{ planned_amount | currency:@currency_code }}', + ...over, +}); + +describe('the marked path form', () => { + it('⭐ a template that declares no variables publishes it in every face', () => { + expect(validateEmailTemplateHoleGrammar({ emailTemplates: [template()] })).toEqual([]); + }); + + it('a template whose variables declare the path, or a root it lies under, publishes it', () => { + const variables = [{ name: 'planned_amount' }, { name: 'contract' }, { name: 'currency_code' }]; + expect(validateEmailTemplateHoleGrammar({ emailTemplates: [template({ variables })] })).toEqual([]); + }); + + it.each(['subject', 'bodyText', 'bodyHtml'])('⭐ refuses in %s a path the declared variables do not declare, naming the hole and the rule', (face) => { + const stack = { + emailTemplates: [template({ + subject: 'Notice', + bodyHtml: '

Notice

', + bodyText: 'Notice', + [face]: `Due ${MARKED}`, + variables: [{ name: 'planned_amount' }, { name: 'currency_code' }], + })], + }; + const findings = validateEmailTemplateHoleGrammar(stack); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ severity: 'error', rule: EMAIL_TEMPLATE_HOLE_GRAMMAR, path: `emailTemplates[0].${face}` }); + expect(findings[0].message).toContain(MARKED); + expect(findings[0].message).toContain('do not declare'); + expect(findings[0].message).toContain('`contract.currency_code`'); + }); + + it.each([ + ['a mark with no path', '{{ amount | currency:@ }}'], + ['a quoted marked path', "{{ amount | currency:'@currency_code' }}"], + ['an empty segment', '{{ amount | currency:@a..b }}'], + ])('refuses %s as before', (_label, hole) => { + const findings = validateEmailTemplateHoleGrammar({ emailTemplates: [template({ subject: hole })] }); + expect(findings.map((f) => f.path)).toEqual(['emailTemplates[0].subject']); + expect(findings[0].message).toContain(hole); + }); + + it('the finding\'s hint quotes the argument rule', () => { + const [finding] = validateEmailTemplateHoleGrammar({ emailTemplates: [template({ subject: '{{ a | currency:currency_code }}' })] }); + expect(finding.hint).toContain(CURRENCY_ARGUMENT_RULE); + }); + + it.each([...AUTHORING_COMMANDS])('os %s accepts the form, and refuses the undeclared path at error', (command) => { + const accepted = { emailTemplates: [template()] }; + expect(runAuthoringRules(command, { normalized: accepted, parsed: accepted }) + .filter((f) => f.rule === EMAIL_TEMPLATE_HOLE_GRAMMAR)).toEqual([]); + const refused = { emailTemplates: [template({ variables: [{ name: 'planned_amount' }] })] }; + expect(runAuthoringRules(command, { normalized: refused, parsed: refused }) + .filter((f) => f.severity === 'error' && f.rule === EMAIL_TEMPLATE_HOLE_GRAMMAR) + .map((f) => f.path)).toEqual(['emailTemplates[0].subject', 'emailTemplates[0].bodyText', 'emailTemplates[0].bodyHtml']); + }); +}); + +describe('the runtime publish gate, for an email_template write', () => { + it('⭐ publishes the marked form, and refuses an undeclared path on the written template', () => { + expect(runRuntimeAuthoringRules({ type: 'email_template', item: template() }).errors).toEqual([]); + const result = runRuntimeAuthoringRules({ + type: 'email_template', + item: template({ subject: 'Notice', bodyHtml: '

Notice

', variables: [{ name: 'planned_amount' }] }), + }); + expect(result.errors.map((f) => [f.rule, f.path])).toEqual([[EMAIL_TEMPLATE_HOLE_GRAMMAR, 'emailTemplates[0].bodyText']]); + }); +}); diff --git a/packages/lint/src/validate-email-template-hole-grammar.ts b/packages/lint/src/validate-email-template-hole-grammar.ts index e7f117c6208..cb6b78672ce 100644 --- a/packages/lint/src/validate-email-template-hole-grammar.ts +++ b/packages/lint/src/validate-email-template-hole-grammar.ts @@ -13,9 +13,13 @@ * - 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. + * - a `currency` argument that is neither a literal ISO 4217 code nor a + * marked path (`currency:currency_code`, a bare path) — it is not read as a + * path, and the renderer used to print the amount in USD instead; + * - a marked path (`currency:@currency_code`, which reads the code from the + * send's data) that the template's declared `variables` do not declare, + * when it declares any. A template that declares none has its path judged + * at render, where a value that is not a code is refused. * * WHICH holes are refused is not decided here: it is `@objectstack/formula`'s * `judgeMailHoleGrammar`, the grammar the mail renderer itself parses with, @@ -36,7 +40,7 @@ * placement rule's question (`validate-email-template-hole-placements.ts`). */ -import { judgeMailHoleGrammar } from '@objectstack/formula'; +import { CURRENCY_ARGUMENT_RULE, judgeMailHoleGrammar } from '@objectstack/formula'; import { collectionEntries } from './collection-entries.js'; @@ -57,15 +61,25 @@ export interface EmailTemplateHoleGrammarFinding { /** The faces a mail template renders, in the order a finding reports them. */ const FACES = ['subject', 'bodyText', 'bodyHtml'] as const; +/** The names a template's `variables` declare; a malformed entry declares nothing. */ +function declaredVariableNames(variables: unknown): string[] { + if (!Array.isArray(variables)) return []; + return variables.flatMap((v) => + v && typeof v === 'object' && typeof (v as AnyRec).name === 'string' && (v as AnyRec).name !== '' + ? [(v as AnyRec).name as string] + : []); +} + 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}]` : ''; + const variables = declaredVariableNames(template.variables); for (const face of FACES) { const text = template[face]; if (typeof text !== 'string') continue; - for (const hole of judgeMailHoleGrammar(text)) { + for (const hole of judgeMailHoleGrammar(text, { variables })) { findings.push({ severity: 'error', rule: EMAIL_TEMPLATE_HOLE_GRAMMAR, @@ -73,9 +87,8 @@ export function validateEmailTemplateHoleGrammar(stack: AnyRec): EmailTemplateHo 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.', + 'A mail template hole is `{{ path }}` or `{{ path | formatter[:arg] }}`, with one formatter. ' + + `${CURRENCY_ARGUMENT_RULE} Anything more belongs in the caller that supplies the data.`, }); } } 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 b5446abfb81..4e31325491e 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 @@ -105,6 +105,32 @@ describe('the host threads an email_template publish to the runtime gate (#25189 expect(publishTemplate(cleanTemplate({ bodyHtml: '

{{ actual_amount | number:2 }}

' })).error).toBeNull(); }); + it('⭐ LIT — currency:@path publishes when the declared variables cover its path, and an undeclared one is refused in the same envelope', () => { + // `cleanTemplate` declares `user.name` and `link`: `user.currency_code` lies under no declared name. + const verdict = publishTemplate(cleanTemplate({ subject: 'Due {{ amount | currency:@user.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']]); + expect(JSON.stringify(issues)).toContain('{{ amount | currency:@user.currency_code }}'); + + // Non-vacuous: the path the variables declare publishes, and so does any path when none is declared. + const declared = cleanTemplate({ + subject: 'Due {{ amount | currency:@currency_code }}', + variables: [{ name: 'user.name', type: 'string' }, { name: 'link', type: 'url' }, { name: 'currency_code', type: 'string' }], + }); + expect(publishTemplate(declared).error).toBeNull(); + // (The `{{{link}}}` href needs its `url` declaration, so the no-variables body drops it.) + const undeclared = cleanTemplate({ + subject: 'Due {{ amount | currency:@user.currency_code }}', + bodyHtml: '

Hi {{user.name}}

', + variables: [], + }); + expect(publishTemplate(undeclared).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 95f2c31d685..824fcacdbc6 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -1462,9 +1462,11 @@ export class EmailService implements IEmailService { // redirect. The derived fallback was already right: `htmlToText` decodes // the entities the HTML render introduced. // - // 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. + // A hole the shared grammar refuses, and a `currency:@path` hole whose + // path holds no ISO 4217 code in this send's data, throw + // `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; diff --git a/packages/plugins/plugin-email/src/template-currency-code-path.render.test.ts b/packages/plugins/plugin-email/src/template-currency-code-path.render.test.ts new file mode 100644 index 00000000000..dd9d045f620 --- /dev/null +++ b/packages/plugins/plugin-email/src/template-currency-code-path.render.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `{{ amount | currency:@currency_code }}` — one mail template formats each + * record's amount in the currency whose ISO 4217 code the send's data holds. + * Measured through the by-name doors, `EmailService.renderTemplate` and + * `sendTemplate`, over all three faces, for records in two currencies and a + * bundle in two locales. + * + * A code that is not there, or is not a code in three capital letters, is + * refused with `TEMPLATE_INVALID` naming the template, the locale, the hole and + * the value: the amount is never formatted in a default currency. + */ + +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, locale) { + return rows.find((r) => r.name === name && (locale === undefined || r.locale === locale)) ?? 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 faces = { + subject: 'Instalment overdue: {{ planned_amount | currency:@currency_code }}', + body_html: '

{{ contract.name }}: {{ planned_amount | currency:@contract.currency_code }}

', + body_text: 'Due {{{ planned_amount | currency:@currency_code }}}', +}; + +/** One bundle, two locale rows, the same holes. */ +const bundle: EmailTemplateRow[] = ['en-US', 'zh-CN'].map((locale) => ({ + name: 'contract.overdue', + locale, + active: true, + ...faces, +})); + +const record = (code: unknown) => ({ + planned_amount: 1234.5, + currency_code: code, + contract: { name: 'C-1', currency_code: code }, +}); + +describe('one template, records in many currencies', () => { + it.each([ + ['en-US', 'EUR', '€1,234.50'], + ['en-US', 'CNY', 'CN¥1,234.50'], + ['zh-CN', 'EUR', '€1,234.50'], + ['zh-CN', 'CNY', '¥1,234.50'], + ])('⭐ %s, a %s record: every face formats in the record\'s own currency', async (locale, code, money) => { + const { svc } = makeService(bundle); + expect(await svc.renderTemplate({ template: 'contract.overdue', locale, data: record(code) })).toEqual({ + subject: `Instalment overdue: ${money}`, + html: `

C-1: ${money}

`, + text: `Due ${money}`, + }); + }); + + it('sendTemplate delivers each record in its own currency through the same template', async () => { + const { svc, sent } = makeService(bundle); + await svc.sendTemplate({ template: 'contract.overdue', to: 'a@x.com', locale: 'en-US', data: record('EUR') }); + await svc.sendTemplate({ template: 'contract.overdue', to: 'b@x.com', locale: 'zh-CN', data: record('CNY') }); + expect(sent.map((m) => m.subject)).toEqual(['Instalment overdue: €1,234.50', 'Instalment overdue: ¥1,234.50']); + }); + + it('a null amount renders empty, as every formatted hole does, once the code resolves', async () => { + const { svc } = makeService(bundle); + const out = await svc.renderTemplate({ template: 'contract.overdue', locale: 'en-US', data: { ...record('EUR'), planned_amount: null } }); + expect(out.subject).toBe('Instalment overdue: '); + }); +}); + +describe('a code the data does not hold is refused, never formatted in USD', () => { + it.each([ + ['an unresolvable path', { planned_amount: 1234.5, contract: { name: 'C-1' } }, 'missing'], + ['a lowercase code', record('eur'), "'eur'"], + ['a null code', record(null), 'null'], + ])('⭐ %s: TEMPLATE_INVALID naming the template, the locale, the hole and the value', async (_label, data, shown) => { + for (const locale of ['en-US', 'zh-CN']) { + const { svc, sent } = makeService(bundle); + const err = await svc.renderTemplate({ template: 'contract.overdue', locale, data }).then( + () => { throw new Error('rendered'); }, + (e: Error) => e, + ); + expect(err.message).toMatch(new RegExp(`^TEMPLATE_INVALID: email template contract\\.overdue \\(locale=${locale}\\): `)); + expect(err.message).toContain('{{ planned_amount | currency:@currency_code }}'); + expect(err.message).toContain(`is ${shown},`); + expect(err.message).not.toMatch(/\$|US\$|USD 1/); + await expect(svc.sendTemplate({ template: 'contract.overdue', to: 'a@x.com', locale, data })) + .rejects.toThrow(/^TEMPLATE_INVALID: /); + expect(sent).toEqual([]); + } + }); + + it('the html face is judged on its own path too', async () => { + const { svc } = makeService([{ ...bundle[0], subject: 'Notice', body_text: 'Notice' }]); + await expect(svc.renderTemplate({ template: 'contract.overdue', data: { planned_amount: 1, contract: { currency_code: 'usd' } } })) + .rejects.toThrow(/^TEMPLATE_INVALID: .*currency:@contract\.currency_code.*'usd'/); + }); +}); + +describe('the literal form and its refusals are unchanged', () => { + it('a literal code still formats in that code whatever the data holds', async () => { + const { svc } = makeService([{ name: 't', locale: 'en-US', active: true, subject: '{{ a | currency:EUR }}', body_html: '

' }]); + expect((await svc.renderTemplate({ template: 't', data: { a: 1, currency_code: 'CNY' } })).subject).toBe('€1.00'); + }); + + it('a bare path is still refused at render', async () => { + const { svc } = makeService([{ name: 't', locale: 'en-US', active: true, subject: '{{ a | currency:currency_code }}', body_html: '

' }]); + await expect(svc.renderTemplate({ template: 't', data: { a: 1, currency_code: 'EUR' } })) + .rejects.toThrow(/^TEMPLATE_INVALID: .*never read as a path/); + }); +}); diff --git a/packages/plugins/plugin-email/src/template-engine.ts b/packages/plugins/plugin-email/src/template-engine.ts index c1fd7f805ac..f10fc19e132 100644 --- a/packages/plugins/plugin-email/src/template-engine.ts +++ b/packages/plugins/plugin-email/src/template-engine.ts @@ -33,9 +33,16 @@ * 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. + * `TEMPLATE_INVALID`, naming the hole and the rule. + * + * `{{ amount | currency:@currency_code }}` formats the amount in the currency + * whose ISO 4217 code the send's `data` holds at that path, so one template + * serves records in many currencies. The path is resolved here, like a hole's + * own path; a value that is not a code in three capital letters — missing, + * `null`, lowercase (`eur`), anything else — makes the render throw + * `TEMPLATE_INVALID` too, naming the hole, the path and the value + * (`currencyCodeValueRefusal`). Those are the ways 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 @@ -44,7 +51,7 @@ * runtime; we resist that until a real use case demands it. */ -import { formatValue, mailHolePattern, parseMailHole } from '@objectstack/formula'; +import { currencyCodeValueRefusal, formatValue, mailHolePattern, parseMailHole } from '@objectstack/formula'; /** Locale + reference timezone for hole formatters (ADR-0053 Phase 2). */ export interface RenderOptions { @@ -123,9 +130,18 @@ function render( const raw = lookup(data, parsed.path); let str: string; if (parsed.formatter) { + // `currency:@path`: the code is the send's data at that path, judged + // before the amount is read, as a literal argument is. + let arg = parsed.arg; + if (parsed.codePath !== undefined) { + const code = lookup(data, parsed.codePath); + const refusal = currencyCodeValueRefusal(match, parsed.codePath, code); + if (refusal) throw new Error(`TEMPLATE_INVALID: ${refusal}`); + arg = code as string; + } // Formatted holes render '' for a missing value (the formula formatters // treat null as empty), so they never emit "undefined". - const formatted = formatValue(parsed.formatter, raw, parsed.arg, { + const formatted = formatValue(parsed.formatter, raw, arg, { locale: opts.locale, timeZone: opts.timeZone, }); diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index 2df82881ed1..0dcf0dc1acb 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -95,8 +95,26 @@ const EMAIL_TEMPLATE_IS_SYSTEM_REMOVED = + '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.'; +/** + * The hole grammar of the three faces (`subject`, `bodyHtml`, `bodyText`), as + * their descriptions state it. The `currency` sentence is + * `CURRENCY_ARGUMENT_RULE` from `@objectstack/formula` word for word — the + * 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. + */ +const MAIL_TEMPLATE_HOLES = + "Holes are `{{ path }}` or `{{ path | formatter[:arg] }}` over the send's `data`, with one formatter from " + + 'the template engine whitelist per hole. 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('Variable name as referenced in placeholders (snake_case or dotted path)'), + name: z.string().describe( + 'Variable name as referenced in placeholders (snake_case or dotted path). When the template declares any ' + + 'variable, the path of a `currency:@path` argument must be one of them or lie under one.', + ), type: z.enum(['string', 'number', 'boolean', 'date', 'url', 'user', 'record']).default('string'), required: z.boolean().default(false), description: z.string().optional().describe('Author hint shown in Studio'), @@ -189,17 +207,19 @@ export const EmailTemplateDefinitionSchema = lazySchema(() => strictObject({ ), /** Subject line; supports `{{var.path}}` placeholders. */ - subject: z.string().describe('Subject template'), + subject: z.string().describe(`Subject template. ${MAIL_TEMPLATE_HOLES}`), /** HTML body; supports `{{var.path}}` placeholders. */ - bodyHtml: z.string().describe('HTML body template'), + bodyHtml: z.string().describe(`HTML body template. ${MAIL_TEMPLATE_HOLES}`), /** * Plain-text body. When omitted the service strips tags from * `bodyHtml` to derive a text alternative — recommended for spam * scoring but optional. */ - bodyText: z.string().optional().describe('Plain-text body template (auto-derived from HTML when omitted)'), + bodyText: z.string().optional().describe( + `Plain-text body template (auto-derived from HTML when omitted). ${MAIL_TEMPLATE_HOLES}`, + ), /** * Declared variables; rendered as form hints in the Studio diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index cddf66a37a5..66e00eb73e7 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -445,7 +445,9 @@ a bare string (auto-wrapped) or their helper, and read the same variable scope. `shared/expression.zod.ts` declares both surfaces and their carriers. Missing template paths render as the empty string. Move logic into a CEL field: -a template holds a path and a formatter, nothing else. +a template holds a path and a formatter, nothing else. A `currency` argument is a +literal code (`currency:EUR`); only an email template may read it from the send's +data instead, `{{ amount | currency:@currency_code }}`. --- From 49258decf331b8a12c16270a505d99bf68ab5b27 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 22:57:27 +0000 Subject: [PATCH 2/2] fix(spec, lint): describe the mail hole grammar without promising an unknown-formatter refusal The email_template face descriptions said a hole takes "one formatter from the template engine whitelist", but no door refuses an unknown mail formatter today: the renderer prints such a hole raw. They now say "at most one formatter per hole (a second, chained formatter is refused)". The authoring-rules comment for email-template-hole-grammar names the marked `currency:@path` form and the undeclared-path check instead of a literal-only rule. Reference docs regenerated. Claude-Session: https://claude.ai/code/session_01WGufziJbRgRHSN9KrbF46h Co-authored-by: Claude --- content/docs/references/system/email-template.mdx | 6 +++--- packages/lint/src/authoring-rules.ts | 8 +++++--- packages/spec/src/system/email-template.zod.ts | 11 ++++++----- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/content/docs/references/system/email-template.mdx b/content/docs/references/system/email-template.mdx index 1640c68b4a6..f1ebb19a883 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 one formatter from the template engine whitelist per hole. 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 one formatter from the template engine whitelist per hole. 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 one formatter from the template engine whitelist per hole. 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). 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`). | | **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/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 246157994fd..7db21de2f43 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -2459,9 +2459,11 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // 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. + // 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 + // not cover is refused (`currency-code-path-undeclared`). Gating: no reading + // of such a hole renders as its author wrote it. // // Runtime publish gate: the same door and shape as the placement rule above // — each written template is judged on its own, so the door's verdict and diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index 0dcf0dc1acb..d9c5a4e4d8c 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -102,13 +102,14 @@ 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. + * renders it. It promises no refusal of an unknown formatter name: none + * exists today (the mail renderer prints such a hole raw; #26948). */ const MAIL_TEMPLATE_HOLES = - "Holes are `{{ path }}` or `{{ path | formatter[:arg] }}` over the send's `data`, with one formatter from " - + 'the template engine whitelist per hole. 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`).'; + "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`)."; export const EmailTemplateDefinitionVariableSchema = lazySchema(() => z.object({ name: z.string().describe(