diff --git a/.changeset/25189-email-template-hole-placement.md b/.changeset/25189-email-template-hole-placement.md new file mode 100644 index 00000000000..ccb13c6def4 --- /dev/null +++ b/.changeset/25189-email-template-hole-placement.md @@ -0,0 +1,26 @@ +--- +'@objectstack/lint': patch +'@objectstack/formula': patch +'@objectstack/spec': patch +--- + +fix(lint)!: `os build`, `os validate`, `os lint` and the email template publish door refuse a hole in an HTML body that sits where the mail renderer's encoding does not suit it (#25189) + +Clause-②: no (narrowing: an email template whose `bodyHtml` places a hole outside the supported placements is refused at the three CLI commands and at the publish door, where it passed) +Metadata-protocol: none + + + +**BREAKING** (an authoring accept set narrows), shipped as `patch` because the change is not to the metadata protocol (maintainer ruling A on #25105). + +The mail renderer encodes a double-braced hole for HTML text and leaves a triple-braced hole unencoded. The template's author decides where each hole sits, and that one encoding is right for some placements and not for others. A new gating authoring rule, `email-template-hole-placement` (`validateEmailTemplateHolePlacements`), classifies every hole in a template's `bodyHtml` by where it sits in the markup and refuses each one outside the supported placements. + +- **Supported:** a double-braced hole in element text, or in a quoted attribute value that is read as plain text; and a triple-braced hole only when it reads a variable the template declares with `type: 'url'` and forms the whole value of a double-quoted `href`. +- **Refused:** every other placement, by class. These are attributes read as a URL, event-handler attributes, `style` attributes, attributes read as markup, unquoted attribute values, holes inside a tag, script- and style-like elements, comments, and SVG or MathML content. A triple-braced hole outside the one supported shape is refused as well. +- **FROM:** such a template passed `os build`, `os validate` and `os lint`, and an active `email_template` write answered 200. +- **TO:** the CLI commands report an `error` finding at `emailTemplates[i].bodyHtml`, and the write answers the existing `422 INVALID_METADATA` with the same issue. The message names the hole, its offset and its placement, and the hint names the supported placements. No new error code is added. +- **Unchanged:** `subject` and `bodyText` are not judged, because the renderer writes them as plain text. A draft save is not judged. A stored template is not migrated, not refused on read and not judged at send or at boot. It keeps rendering until its next publish, and that publish is judged. +- **Measured before crossing:** none of the 76 HTML bodies in the HotCRM reference application, and none of the templates this repository ships, is refused. The five bodies the rule did refuse were documentation examples and test fixtures, and each now uses the supported link shape. +- **`@objectstack/formula` (patch):** the classifier the rule reads, `judgeMailHtmlHoles`, is exported with its supported-placement list and labels. `@objectstack/plugin-email`'s built-in template pin reads the same classifier and holds its hole grammar to the renderer's. +- **`@objectstack/spec` (patch):** one conversion's example templates now declare their link variable with `type: 'url'` and place it in an `href`. No schema, conversion behaviour or export changes. +- **The fix for an author:** move the hole into element text or a quoted plain-text attribute. For a link, declare the variable with `type: 'url'` and write it triple-braced as the whole double-quoted `href` value. diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index 7d9d5aac588..863d635f68a 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -74,14 +74,26 @@ never needs triple braces. - `{{path.to.value}}` — dotted-path lookup against the send's `data` object, **HTML-escaped in `bodyHtml`**, verbatim in `subject` and `bodyText`. -- `{{{path.to.value}}}` — the same value, *not* escaped even in `bodyHtml`. Use - it only for pre-rendered HTML fragments such as a URL you are dropping into - `href`. +- `{{{path.to.value}}}` — the same value, *not* escaped even in `bodyHtml`. It is + admitted in exactly one place: a variable declared with `type: 'url'`, written + as the whole value of a double-quoted `href` (``). - `{{ order.total | currency:EUR }}` / `{{ ts | datetime }}` — an optional formatter from the shared formula whitelist, so money and dates render the same way they do in-app. `datetime` honours the reference timezone the caller passes; calendar-day `date` values are timezone-naive. +**Where a hole may sit in `bodyHtml`.** The renderer's escaping is right for +element text and for a quoted attribute value that holds plain text, so those +are the supported placements for `{{path}}`. A hole anywhere else — inside a +`script` or `style` element, a comment, a tag outside any attribute value, an +unquoted attribute value, an attribute read as a URL (`href`, `src`, …), an +event handler, a style or markup, or SVG or MathML content — is refused by `os validate`, `os build`, +`os lint` and the metadata publish door, and so is a `{{{path}}}` hole outside +the `href` shape above. The refusal (`email-template-hole-placement`, a +`422 INVALID_METADATA` at the publish door) names the placement and the +supported ones. A template already stored keeps sending; it is judged the next +time it is published. + Two properties of the renderer to author around: - **A missing placeholder renders as an empty string.** Rendering never throws. @@ -118,7 +130,8 @@ export const passwordResetEn = defineEmailTemplateDefinition({ label: 'Password Reset', locale: 'en-US', subject: 'Reset your password', - bodyHtml: '

Use {{{reset_url}}} within {{ttl_minutes}} minutes.

', + bodyHtml: '

Use this link within {{ttl_minutes}} minutes.

', + variables: [{ name: 'reset_url', type: 'url', required: true }], }); export const passwordResetZh = defineEmailTemplateDefinition({ @@ -126,7 +139,8 @@ export const passwordResetZh = defineEmailTemplateDefinition({ label: 'Password Reset', locale: 'zh-CN', subject: '重置您的密码', - bodyHtml: '

请在 {{ttl_minutes}} 分钟内使用 {{{reset_url}}}。

', + bodyHtml: '

请在 {{ttl_minutes}} 分钟内使用此链接。

', + variables: [{ name: 'reset_url', type: 'url', required: true }], }); ``` diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 81a1be5fad4..73d6fc799fb 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -756,7 +756,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (50)... + → Running author-time rules (51)... → Checking that every required capability has a provider installable in this edition... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 6711a28076c..1900358136c 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -481,6 +481,7 @@ orthogonal to both, and no cell here can carry it; it is written out in | Platform-schedule `create_record` organization (#6285) | — | — | — | ✓ᶠ | | Public-form anonymous intake on this deployment's tenancy posture — advisory only (#21476) | — | — | — | ✓ᵛ | | Autonumber `{field}` interpolation | ✓ | ✓ | ✓ | ✓ᵒ | +| Email template HTML hole placements — a hole in a placement the mail renderer's encoding does not suit (#25189) | ✓ | ✓ | ✓ | ✓ᵉ | | View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | — | | Flow authoring anti-patterns (#1874) | ✓ | ✓ | ✓ | ✓ᶠ | | Flow trigger readiness — a flow that looks armed and never launches (#5762) | ✓ | ✓ | ✓ | ✓ᶠ | @@ -495,10 +496,11 @@ orthogonal to both, and no cell here can carry it; it is written out in **Every superscript in the `runtime publish` column is one metadata type whose writes that rule inspects: `✓ᶠ` `flow`, `✓ᵛ` `view`, `✓ᵈ` `dashboard`, `✓ᵒ` `object`, `✓ᵖ` `page`, `✓ˢ` `seed`, `✓ᵇ` `book`, `✓ᵖᵉ` `permission`, -`✓ᵖᵒ` `position`, `✓ᵃ` `app`** — the two `ᵖ`-initial pairs carry a second -letter because `page` already holds `ᵖ` on its own. Read the codes -**longest-first**: `ᵖᵒ` is `position`, never `page` followed by `object`. A cell -lists one superscript per declared type, in the order the rule declares them, and that +`✓ᵖᵒ` `position`, `✓ᵃ` `app`, `✓ᵉ` `email_template`** — the two +`ᵖ`-initial pairs carry a second letter because `page` already holds `ᵖ` on its +own. Read the codes **longest-first**: `ᵖᵒ` is `position`, never `page` +followed by `object`, and `ᵖᵉ` is `permission`, never `page` followed by +`email_template`. A cell lists one superscript per declared type, in the order the rule declares them, and that rule's own `runtimeTypes` in `AUTHORING_RULES` is the authority — the set has grown a type at a time (#4463 shipped P1 as `flow` and four rule families, #7220 moved the whole `views[]` visibility-predicate family across in one edit, #7529 @@ -506,8 +508,9 @@ put widget-binding integrity on `dashboard`, #8307 → #8310 walked the ADR-0090 security-posture block across `seed`, then `permission` and `book`, then `object`, #4716 crossed the five remaining **gating** object rules — functional completeness, managed API methods, autonumber formats, and both -validation-rule enforceability checks — onto `object`, and #19370 took the -vocabulary freeze across all five of its own write types at once), so read the +validation-rule enforceability checks — onto `object`, #19370 took the +vocabulary freeze across all five of its own write types at once, and #25189 +put the email template hole placement rule on `email_template`), so read the rule rather than assuming a save of some other type reaches storage unjudged. That last move is also why the vocabulary freeze is a row of its own. It was @@ -732,7 +735,7 @@ A clean run walks the registry and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Running author-time rules (50)... + → Running author-time rules (51)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 3c0b902c7e7..9ee4a6b60e8 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -305,7 +305,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (50)... + → Running author-time rules (51)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index bec9c89b2ed..39b15dfe5b1 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -390,7 +390,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (50)... + → Running author-time rules (51)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index aeae3a5e2cb..8a41367803f 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -64,6 +64,18 @@ export type { } from './cel-engine'; export { cronEngine } from './cron-engine'; export { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-engine'; +// #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 +// authoring rule, and `@objectstack/plugin-email`'s built-in template pin, +// which also holds this grammar to the renderer's. +export { + judgeMailHtmlHoles, + MAIL_HTML_SUPPORTED_PLACEMENTS, + MAIL_HTML_PLACEMENT_LABELS, + MAIL_HTML_SUPPORTED_SHAPES, +} from './mail-html-hole-placement'; +export type { MailHtmlHole, MailHtmlHolePlacement, MailHtmlHoleJudgeOptions } from './mail-html-hole-placement'; export { registerStdLib, buildScope, registerPermissionPredicate, registerCapabilityPredicate } from './stdlib'; export type { PermissionBinding } from './stdlib'; // objectui#4421 / batch #147 — the permission predicate's data door. `can` reads diff --git a/packages/formula/src/mail-html-hole-placement.test.ts b/packages/formula/src/mail-html-hole-placement.test.ts new file mode 100644 index 00000000000..f11bfd4d416 --- /dev/null +++ b/packages/formula/src/mail-html-hole-placement.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25189 — the mail HTML hole placement judge, one placement class at a time. + * + * Each case names the class it asserts; the bodies carry holes and markup + * only, never a value. The judge's grammar parity with the renderer is pinned + * in `plugin-email` (`text-field-html-outlets.pin.test.ts`), where the + * renderer is. + */ +import { describe, expect, it } from 'vitest'; + +import { + judgeMailHtmlHoles, + MAIL_HTML_PLACEMENT_LABELS, + MAIL_HTML_SUPPORTED_PLACEMENTS, + type MailHtmlHolePlacement, +} from './mail-html-hole-placement'; + +const one = (html: string, urlVariables: string[] = []) => { + const holes = judgeMailHtmlHoles(html, { urlVariables }); + expect(holes, html).toHaveLength(1); + return holes[0]; +}; + +describe('the supported placements', () => { + it.each([ + ['

{{ who }}

', 'element-text'], + ['{{who}}', 'element-text'], + ['
{{ who | upper }}
', 'element-text'], + ['{{who}}', 'element-text'], + ['', 'element-text'], + ['

x

', 'quoted-attribute'], + ["

x

", 'quoted-attribute'], + ['x', 'quoted-attribute'], + ] as const)('%s → %s, supported', (html, placement) => { + for (const hole of judgeMailHtmlHoles(html)) { + expect(hole.placement).toBe(placement); + expect(hole.supported).toBe(true); + expect(hole.raw).toBe(false); + } + }); + + it('a triple-braced hole is supported as a declared url variable forming the whole double-quoted href', () => { + for (const html of ['go', 'go', 'go']) { + const hole = one(html, ['url']); + expect(hole).toMatchObject({ raw: true, placement: 'url-attribute', supported: true, path: 'url' }); + } + }); + + it('the supported list is the two encoded placements', () => { + expect([...MAIL_HTML_SUPPORTED_PLACEMENTS]).toEqual(['element-text', 'quoted-attribute']); + }); +}); + +describe('the refused placements, by class', () => { + it.each([ + ['', 'raw-text-element'], + ['', 'raw-text-element'], + ['', 'raw-text-element'], + ['{{x}}', 'raw-text-element'], + ['<!-- {{x}} -->', 'comment'], + ['<!--[if mso]><p>{{x}}</p><![endif]-->', 'comment'], + ['<!DOCTYPE {{x}}>', 'comment'], + ['<? {{x}} ?>', 'comment'], + ['<td width={{x}}>a</td>', 'unquoted-attribute'], + ['<td width=1{{x}}>a</td>', 'unquoted-attribute'], + ['<p {{x}}>a</p>', 'tag'], + ['<{{x}}>', 'tag'], + ['</{{x}}>', 'tag'], + ['<p title="a"{{x}}>a</p>', 'tag'], + ['<p data-{{x}}="1">a</p>', 'tag'], + ['<a href="{{x}}">a</a>', 'url-attribute'], + ['<a href="https://example.test/{{x}}">a</a>', 'url-attribute'], + ['<img src="{{x}}">', 'url-attribute'], + ['<form action="{{x}}"></form>', 'url-attribute'], + ['<td background="{{x}}">a</td>', 'url-attribute'], + ['<a xlink:href="{{x}}">a</a>', 'url-attribute'], + ['<meta http-equiv="refresh" content="{{x}}">', 'url-attribute'], + ['<p onclick="{{x}}">a</p>', 'event-handler-attribute'], + ['<p style="color: {{x}}">a</p>', 'style-attribute'], + ['<iframe srcdoc="{{x}}"></iframe>', 'markup-attribute'], + ['<svg><text>{{x}}</text></svg>', 'foreign-content'], + ['<svg width="{{x}}"></svg>', 'foreign-content'], + ['<math><mi>{{x}}</mi></math>', 'foreign-content'], + ] as const)('%s → %s, refused', (html, placement) => { + const hole = one(html); + expect(hole.placement).toBe(placement); + expect(hole.supported).toBe(false); + }); + + it('a triple-braced hole anywhere but the href shape is refused', () => { + const refused: Array<[string, string[]]> = [ + ['<p>{{{url}}}</p>', ['url']], + ['<p title="{{{url}}}">a</p>', ['url']], + ['<a href="{{{url}}}">a</a>', []], + ['<a href="{{{other}}}">a</a>', ['url']], + ['<a href="{{{ url | upper }}}">a</a>', ['url']], + ["<a href='{{{url}}}'>a</a>", ['url']], + ['<a href="{{{url}}}/next">a</a>', ['url']], + ['<a href="https://example.test/{{{url}}}">a</a>', ['url']], + ['<a href={{{url}}}>a</a>', ['url']], + ['<img src="{{{url}}}">', ['url']], + ['<a data-href="{{{url}}}">a</a>', ['url']], + ['<svg><a href="{{{url}}}">a</a></svg>', ['url']], + ]; + for (const [html, urlVariables] of refused) { + const hole = one(html, urlVariables); + expect(hole.raw, html).toBe(true); + expect(hole.supported, html).toBe(false); + } + }); + + it('a hole after a closed raw-text element, comment or foreign root is judged by the markup that follows', () => { + const holes = judgeMailHtmlHoles( + '<style>p{}</style><p>{{a}}</p><!-- c --><b>{{b}}</b><svg></svg><i>{{c}}</i><script></SCRIPT ><u>{{d}}</u>', + ); + expect(holes.map((h) => [h.path, h.placement, h.supported])).toEqual([ + ['a', 'element-text', true], + ['b', 'element-text', true], + ['c', 'element-text', true], + ['d', 'element-text', true], + ]); + }); + + it('text inside a raw-text element that only looks like a closing tag does not close it', () => { + expect(one('<script></scriptx>{{x}}</script>').placement).toBe('raw-text-element'); + expect(one('<style></style-x>{{x}}</style>').placement).toBe('raw-text-element'); + }); + + it('an abruptly closed comment ends where the parser ends it', () => { + expect(one('<!--><p>{{x}}</p>').placement).toBe('element-text'); + expect(one('<!---><p>{{x}}</p>').placement).toBe('element-text'); + expect(one('<!-- a --!><p>{{x}}</p>').placement).toBe('element-text'); + }); + + it('every refused class has an author-facing label', () => { + const classes: MailHtmlHolePlacement[] = [ + 'element-text', 'quoted-attribute', 'url-attribute', 'event-handler-attribute', 'style-attribute', + 'markup-attribute', 'unquoted-attribute', 'tag', 'raw-text-element', 'comment', 'foreign-content', + ]; + expect(Object.keys(MAIL_HTML_PLACEMENT_LABELS).sort()).toEqual([...classes].sort()); + }); +}); + +describe('what is and is not a hole — the renderer\'s grammar', () => { + it('text the renderer leaves verbatim is markup, not a hole', () => { + expect(judgeMailHtmlHoles('<p>{{ not a path }}</p>')).toEqual([]); + expect(judgeMailHtmlHoles('<p>{x}</p>')).toEqual([]); + expect(judgeMailHtmlHoles('')).toEqual([]); + }); + + it('a verbatim brace pair still moves the markup around it', () => { + // The pair is not a hole, so the renderer leaves it in the output — and the + // tag it opens there is where the hole after it sits. + const holes = judgeMailHtmlHoles('<p>{{ <b }}{{x}}</p>'); + expect(holes).toHaveLength(1); + expect(holes[0]).toMatchObject({ path: 'x', placement: 'tag', supported: false }); + }); + + it('a hole\'s span, braces included, and its form', () => { + expect(judgeMailHtmlHoles('a {{ x.y | currency:EUR }} b {{{z}}} c {{{w}}')).toEqual([ + expect.objectContaining({ start: 2, text: '{{ x.y | currency:EUR }}', path: 'x.y', raw: false }), + expect.objectContaining({ text: '{{{z}}}', path: 'z', raw: true }), + expect.objectContaining({ text: '{{{w}}', path: 'w', raw: false }), + ]); + }); +}); diff --git a/packages/formula/src/mail-html-hole-placement.ts b/packages/formula/src/mail-html-hole-placement.ts new file mode 100644 index 00000000000..2a6320f91be --- /dev/null +++ b/packages/formula/src/mail-html-hole-placement.ts @@ -0,0 +1,481 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Where a hole may sit in the HTML face of a mail template — the one judge + * `@objectstack/lint`'s authoring rule and `@objectstack/plugin-email`'s + * built-in template pin both read (ruling `6335348115` on #25082, letters + * F1 A2 and F3 C2). + * + * ## What the renderer does, and what that decides + * + * `plugin-email`'s `renderTemplate` encodes a double-braced hole ONE way: the + * five characters an HTML parser reads as markup in text and in a quoted + * attribute value (`&`, `<`, `>`, `"`, `'`) become character references. A + * triple-braced hole is not encoded at all. The template's author decides + * where each hole sits, so the encoding is only right where the placement + * needs exactly that encoding and nothing more: + * + * - **element text** — the text content of an element, and of the two + * elements whose text the parser reads with references decoded and no + * markup inside (`title`, `textarea`); + * - **a quoted attribute value** whose content is plain text: double- or + * single-quoted, on an attribute that is not read as a URL, a script, a + * style sheet or markup. + * + * Every other placement needs an encoding the renderer does not apply, so a + * hole there is refused — context-strict, by the placement the hole occupies + * in the parsed template, never by a search for a syntax pattern. + * + * The one unencoded form keeps the shape the built-in auth templates already + * use, for every author: a triple-braced hole is accepted only as a variable + * the template declares with `type: 'url'`, forming the WHOLE value of a + * double-quoted `href`. Anywhere else a triple-braced hole is refused. + * + * ## Where it runs, and where it does not + * + * The verdict is the author-time door's: `os validate`, `os build`, `os lint` + * and the runtime publish gate refuse a refused hole. Nothing here runs at + * send — a stored template keeps rendering until its next publish judges it + * (ruling item 4) — and nothing here encodes a value. + * + * ## Grammar parity with the renderer + * + * {@link HOLE} and {@link parseHole} are the renderer's own hole grammar, + * spelled again here because the two packages share no module that could + * hold it (`plugin-email` depends on this package; this package depends on + * neither of its consumers). A copy that drifted would judge holes the + * renderer never substitutes, or miss ones it does, so the copy is pinned: + * `plugin-email`'s `text-field-html-outlets.pin.test.ts` renders a grammar + * corpus and every built-in body through the real renderer and requires the + * spans this judge finds to be exactly the spans the renderer replaces. + * + * ## Limits, stated so they are not read as more + * + * - The judge classifies the TEMPLATE, with each hole standing for a value the + * renderer encoded. It says nothing about a value's content and does not + * replace the renderer's encoding. + * - Foreign content (`svg`, `math`) is refused whole rather than classified: + * the parser leaves it on rules of its own, and over-refusing there is the + * direction that cannot let a misjudged hole through. + * - A hole whose placement depends on a value outside the template — an + * attribute another hole names, an element another hole opens — is refused + * with the placement the template gives it. + */ + +// ─── The renderer's hole grammar (pinned to `plugin-email`'s template-engine.ts) ─── + +/** 1=open (`{{` or `{{{`), 2=brace-free inner body, 3=close (`}}` or `}}}`). */ +const HOLE = /(\{\{\{?)([^{}]*)(\}\}\}?)/g; +const PATH_RE = /^[\w.]+$/; +const FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/; + +interface ParsedHole { + path: string; + formatter?: string; +} + +/** A hole's inner body as the renderer parses it; null when the renderer leaves the text verbatim. */ +function parseHole(inner: string): ParsedHole | null { + const pipe = inner.indexOf('|'); + if (pipe === -1) { + const path = inner.trim(); + return PATH_RE.test(path) ? { path } : null; + } + const path = inner.slice(0, pipe).trim(); + if (!PATH_RE.test(path)) return null; + const m = FILTER_RE.exec(inner.slice(pipe + 1).trim()); + if (!m) return null; + return { path, formatter: m[1] }; +} + +// ─── Placement classes ────────────────────────────────────────────── + +/** + * Every placement a hole can occupy in an HTML body, as a closed set. Two are + * supported for an encoded hole ({@link MAIL_HTML_SUPPORTED_PLACEMENTS}); a + * triple-braced hole is supported only in the `href` shape described above, + * which is a `url-attribute` placement. + */ +export type MailHtmlHolePlacement = + | 'element-text' + | 'quoted-attribute' + | 'url-attribute' + | 'event-handler-attribute' + | 'style-attribute' + | 'markup-attribute' + | 'unquoted-attribute' + | 'tag' + | 'raw-text-element' + | 'comment' + | 'foreign-content'; + +/** The placements whose encoding the renderer applies to a double-braced hole. */ +export const MAIL_HTML_SUPPORTED_PLACEMENTS: readonly MailHtmlHolePlacement[] = [ + 'element-text', + 'quoted-attribute', +]; + +/** How each placement is named to an author — one noun phrase, the finding's vocabulary. */ +export const MAIL_HTML_PLACEMENT_LABELS: Readonly<Record<MailHtmlHolePlacement, string>> = { + 'element-text': 'element text', + 'quoted-attribute': 'a quoted plain-text attribute value', + 'url-attribute': 'the value of an attribute read as a URL', + 'event-handler-attribute': 'the value of an event-handler attribute', + 'style-attribute': 'the value of a style attribute', + 'markup-attribute': 'the value of an attribute read as markup', + 'unquoted-attribute': 'an unquoted attribute value', + tag: 'a tag, outside any attribute value', + 'raw-text-element': 'the content of an element whose text is not parsed as HTML text (script, style and their kind)', + comment: 'a comment or markup declaration', + 'foreign-content': 'SVG or MathML content', +}; + +/** The supported set, as the sentence a refusal names it with. */ +export const MAIL_HTML_SUPPORTED_SHAPES = + 'a double-braced hole in element text or in a quoted plain-text attribute value, and a triple-braced ' + + "hole only as a variable declared with type 'url' forming the whole value of a double-quoted href"; + +/** One hole the renderer substitutes, with the placement the template gives it. */ +export interface MailHtmlHole { + /** Offset of the hole's first brace in the body. */ + start: number; + /** Offset one past the hole's last brace. */ + end: number; + /** The hole as written, braces included. */ + text: string; + /** The dotted path the hole reads. */ + path: string; + /** True for the triple-braced form, which the renderer does not encode. */ + raw: boolean; + placement: MailHtmlHolePlacement; + /** Whether the renderer's handling of this hole is right for its placement. */ + supported: boolean; +} + +export interface MailHtmlHoleJudgeOptions { + /** The template's variables declared with `type: 'url'` — the paths a triple-braced `href` hole may read. */ + urlVariables?: Iterable<string>; +} + +/** + * Every hole the renderer substitutes in `html`, in body order, each with its + * placement and verdict. Text the renderer leaves verbatim (a brace pair whose + * body is not a path) is not a hole and is read as the template's own markup. + */ +export function judgeMailHtmlHoles(html: string, options: MailHtmlHoleJudgeOptions = {}): MailHtmlHole[] { + if (typeof html !== 'string' || html === '') return []; + const urlVariables = new Set(options.urlVariables ?? []); + const found: Array<Omit<MailHtmlHole, 'placement' | 'supported'> & { formatter?: string }> = []; + for (const m of html.matchAll(HOLE)) { + const parsed = parseHole(m[2]); + if (!parsed) continue; + const start = m.index ?? 0; + found.push({ + start, + end: start + m[0].length, + text: m[0], + path: parsed.path, + raw: m[1] === '{{{' && m[3] === '}}}', + ...(parsed.formatter !== undefined ? { formatter: parsed.formatter } : {}), + }); + } + if (found.length === 0) return []; + + const contexts = classifyHolePositions(html, found); + return found.map((hole, i) => { + const context = contexts[i]; + const supported = hole.raw + ? context.wholeDoubleQuotedHref && hole.formatter === undefined && urlVariables.has(hole.path) + : MAIL_HTML_SUPPORTED_PLACEMENTS.includes(context.placement); + return { + start: hole.start, + end: hole.end, + text: hole.text, + path: hole.path, + raw: hole.raw, + placement: context.placement, + supported, + }; + }); +} + +// ─── The placement scan ───────────────────────────────────────────── + +/** Elements whose content the parser reads as raw text: no references decoded, no markup, never HTML text. */ +const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript', 'plaintext']); +/** Elements whose content is text with references decoded and no markup inside. */ +const ESCAPABLE_TEXT_ELEMENTS = new Set(['title', 'textarea']); +/** Roots of foreign content. */ +const FOREIGN_ROOTS = new Set(['svg', 'math']); +/** + * Attributes read as a URL (or a list of URLs), current and legacy, on any + * element. A namespaced attribute (`xlink:href`, `xml:base`, `xmlns:…`) is + * read as one too. + */ +const URL_ATTRIBUTES = new Set([ + 'href', 'src', 'srcset', 'imagesrcset', 'action', 'formaction', 'cite', 'data', 'poster', + 'background', 'longdesc', 'usemap', 'codebase', 'classid', 'archive', 'code', 'profile', + 'manifest', 'icon', 'ping', 'lowsrc', 'dynsrc', 'datasrc', 'itemtype', 'itemid', 'xmlns', +]); +/** Element-specific attributes read as a URL: a meta refresh directive, a plugin parameter. */ +const ELEMENT_URL_ATTRIBUTES: Readonly<Record<string, ReadonlySet<string>>> = { + meta: new Set(['content']), + param: new Set(['value']), +}; +/** Attributes whose value is parsed as a document. */ +const MARKUP_ATTRIBUTES = new Set(['srcdoc']); + +function attributePlacement(element: string, attribute: string): MailHtmlHolePlacement { + if (attribute.startsWith('on')) return 'event-handler-attribute'; + if (attribute === 'style') return 'style-attribute'; + if (MARKUP_ATTRIBUTES.has(attribute)) return 'markup-attribute'; + if (URL_ATTRIBUTES.has(attribute) || attribute.includes(':')) return 'url-attribute'; + if (ELEMENT_URL_ATTRIBUTES[element]?.has(attribute)) return 'url-attribute'; + return 'quoted-attribute'; +} + +type State = + | 'data' + | 'tag-open' + | 'end-tag-open' + | 'tag-name' + | 'before-attr-name' + | 'attr-name' + | 'after-attr-name' + | 'before-attr-value' + | 'attr-value-dq' + | 'attr-value-sq' + | 'attr-value-uq' + | 'after-attr-value' + | 'self-closing' + | 'markup-declaration' + | 'comment' + | 'bogus-comment' + | 'raw-text' + | 'escapable-text' + | 'plaintext'; + +interface HoleContext { + placement: MailHtmlHolePlacement; + /** The hole is the whole value of a double-quoted `href`. */ + wholeDoubleQuotedHref: boolean; +} + +const isSpace = (c: string) => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f'; +const isAlpha = (c: string) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + +/** + * Walk the body as an HTML tokenizer would, with every hole standing for + * one inert character (what an encoded value is in the two supported + * placements), and record the placement at each hole's offset. + */ +function classifyHolePositions(html: string, holes: ReadonlyArray<{ start: number; end: number }>): HoleContext[] { + const contexts: HoleContext[] = []; + let state: State = 'data'; + let tagName = ''; + let endTag = false; + let attrName = ''; + let attrValueStart = -1; + let declaration = ''; + let textElement = ''; + /** Inside a script's text, past a comment opener and not yet past a closer. */ + let scriptEscaped = false; + let foreignDepth = 0; + + const emitTag = (selfClosing: boolean) => { + if (endTag) { + if (FOREIGN_ROOTS.has(tagName) && foreignDepth > 0) foreignDepth -= 1; + return 'data' as State; + } + if (FOREIGN_ROOTS.has(tagName)) { + if (!selfClosing) foreignDepth += 1; + return 'data' as State; + } + if (foreignDepth > 0) return 'data' as State; + textElement = tagName; + scriptEscaped = false; + if (tagName === 'plaintext') return 'plaintext' as State; + if (RAW_TEXT_ELEMENTS.has(tagName)) return 'raw-text' as State; + if (ESCAPABLE_TEXT_ELEMENTS.has(tagName)) return 'escapable-text' as State; + return 'data' as State; + }; + + /** True when `html` at `i` closes the current raw or escapable text element. */ + const closesTextElement = (i: number) => { + if (html[i] !== '<' || html[i + 1] !== '/') return false; + const name = html.slice(i + 2, i + 2 + textElement.length); + if (name.toLowerCase() !== textElement) return false; + const after = html[i + 2 + textElement.length]; + return after === undefined || isSpace(after) || after === '/' || after === '>'; + }; + + const contextAt = (start: number, end: number): HoleContext => { + const plain = (placement: MailHtmlHolePlacement): HoleContext => ({ placement, wholeDoubleQuotedHref: false }); + const inForeign = foreignDepth > 0 || (FOREIGN_ROOTS.has(tagName) && !endTag && isTagState(state)); + if (inForeign) return plain('foreign-content'); + switch (state) { + case 'data': + case 'escapable-text': + return plain('element-text'); + case 'raw-text': + case 'plaintext': + return plain('raw-text-element'); + case 'markup-declaration': + case 'comment': + case 'bogus-comment': + return plain('comment'); + case 'attr-value-dq': + case 'attr-value-sq': { + if (endTag) return plain('tag'); + const placement = attributePlacement(tagName, attrName); + const whole = state === 'attr-value-dq' && attrName === 'href' + && attrValueStart === start && html[end] === '"'; + return { placement, wholeDoubleQuotedHref: whole }; + } + case 'before-attr-value': + case 'attr-value-uq': + return plain(endTag ? 'tag' : 'unquoted-attribute'); + default: + return plain('tag'); + } + }; + + let h = 0; + let i = 0; + while (i < html.length) { + // Every jump below moves over literal characters only, so no hole start is + // ever stepped over; were one to be, it is refused rather than misaligned. + while (h < holes.length && holes[h].start < i) { + contexts.push({ placement: 'tag', wholeDoubleQuotedHref: false }); + h += 1; + } + if (h < holes.length && i === holes[h].start) { + contexts.push(contextAt(holes[h].start, holes[h].end)); + i = holes[h].end; + h += 1; + // The hole stands for one inert character from here on. + state = step(state, 'x'); + continue; + } + const c = html[i]; + // A script's text has escape states of its own, in which an end tag may + // not end the element. They are not modelled: from a comment opener on, + // the scan stays in raw text until an end tag that follows a comment + // closer, which can only refuse more than the parser would. + if (state === 'raw-text' && textElement === 'script') { + if (html.startsWith('<!--', i)) scriptEscaped = true; + else if (html.startsWith('-->', i)) scriptEscaped = false; + } + // States that read more than one character at a time. + if ((state === 'raw-text' || state === 'escapable-text') && !scriptEscaped && closesTextElement(i)) { + state = 'end-tag-open'; + tagName = ''; + endTag = true; + i += 2; + continue; + } + if (state === 'comment') { + if (html.startsWith('-->', i)) { state = 'data'; i += 3; continue; } + if (html.startsWith('--!>', i)) { state = 'data'; i += 4; continue; } + i += 1; + continue; + } + if (state === 'markup-declaration') { + declaration += c; + i += 1; + if (declaration === '--') { + // `<!-->` and `<!--->` close at once. + if (html[i] === '>') { state = 'data'; i += 1; } + else if (html.startsWith('->', i)) { state = 'data'; i += 2; } + else state = 'comment'; + } else if (declaration !== '-') { + state = c === '>' ? 'data' : 'bogus-comment'; + } + continue; + } + state = step(state, c); + i += 1; + } + while (contexts.length < holes.length) contexts.push({ placement: 'tag', wholeDoubleQuotedHref: false }); + return contexts; + + /** One character of tokenizer state for the states that read a character at a time. */ + function step(s: State, c: string): State { + switch (s) { + case 'data': + if (c === '<') return 'tag-open'; + return 'data'; + case 'tag-open': + if (c === '!') { declaration = ''; return 'markup-declaration'; } + if (c === '/') return 'end-tag-open'; + if (c === '?') return 'bogus-comment'; + if (isAlpha(c)) { tagName = c.toLowerCase(); endTag = false; return 'tag-name'; } + // A character that opens no tag is reconsumed in the data state, where + // only another `<` means anything. + return step('data', c); + case 'end-tag-open': + if (isAlpha(c)) { tagName = c.toLowerCase(); endTag = true; return 'tag-name'; } + if (c === '>') return 'data'; + return 'bogus-comment'; + case 'tag-name': + if (isSpace(c)) return 'before-attr-name'; + if (c === '/') return 'self-closing'; + if (c === '>') return emitTag(false); + tagName += c.toLowerCase(); + return 'tag-name'; + case 'before-attr-name': + if (isSpace(c)) return 'before-attr-name'; + if (c === '/') return 'self-closing'; + if (c === '>') return emitTag(false); + attrName = c.toLowerCase(); + return 'attr-name'; + case 'attr-name': + if (isSpace(c)) return 'after-attr-name'; + if (c === '/') return 'self-closing'; + if (c === '=') return 'before-attr-value'; + if (c === '>') return emitTag(false); + attrName += c.toLowerCase(); + return 'attr-name'; + case 'after-attr-name': + if (isSpace(c)) return 'after-attr-name'; + if (c === '/') return 'self-closing'; + if (c === '=') return 'before-attr-value'; + if (c === '>') return emitTag(false); + attrName = c.toLowerCase(); + return 'attr-name'; + case 'before-attr-value': + if (isSpace(c)) return 'before-attr-value'; + if (c === '"') { attrValueStart = i + 1; return 'attr-value-dq'; } + if (c === "'") { attrValueStart = i + 1; return 'attr-value-sq'; } + if (c === '>') return emitTag(false); + return 'attr-value-uq'; + case 'attr-value-dq': + return c === '"' ? 'after-attr-value' : 'attr-value-dq'; + case 'attr-value-sq': + return c === "'" ? 'after-attr-value' : 'attr-value-sq'; + case 'attr-value-uq': + if (isSpace(c)) return 'before-attr-name'; + if (c === '>') return emitTag(false); + return 'attr-value-uq'; + case 'after-attr-value': + if (isSpace(c)) return 'before-attr-name'; + if (c === '/') return 'self-closing'; + if (c === '>') return emitTag(false); + attrName = c.toLowerCase(); + return 'attr-name'; + case 'self-closing': + if (c === '>') return emitTag(true); + return step('before-attr-name', c); + case 'bogus-comment': + return c === '>' ? 'data' : 'bogus-comment'; + default: + // raw-text, escapable-text and plaintext hold until a closing tag (read above). + return s; + } + } +} + +function isTagState(s: State): boolean { + return s !== 'data' && s !== 'raw-text' && s !== 'escapable-text' && s !== 'plaintext' + && s !== 'comment' && s !== 'bogus-comment' && s !== 'markup-declaration'; +} diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 7e8fe4cc452..2ec7fff54b1 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -145,6 +145,7 @@ import { lintLivenessProperties } from './lint-liveness-properties.js'; import { lintAutonumberFormats } from './lint-autonumber-formats.js'; import { lintViewRefs } from './lint-view-refs.js'; import { validateRetiredPermissionResidue } from './validate-retired-permission-residue.js'; +import { validateEmailTemplateHolePlacements } from './validate-email-template-hole-placements.js'; import { lintUniqueDeclarations, lintUnscopedDeclaredIndexes, @@ -2304,6 +2305,35 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ runtimeTypes: ['object'], run: (stack) => validateRuleSchemaFormats(stack), }, + // [#25189] The email half of #25082 (ruling `6335348115`, F1 A2): a hole an + // email template's `bodyHtml` places where the mail renderer cannot encode + // it for that placement is refused, naming the placement and the supported + // ones. The classification is `@objectstack/formula`'s + // `judgeMailHtmlHoles` (F3 C2, context-strict plus the built-in auth + // templates' url `href` shape), the same judge `plugin-email`'s built-in + // pin reads. Gating: no reading of such a body renders as its author wrote + // it for every value. + // + // Runtime publish gate: `email_template` already maps to `emailTemplates` + // (`lintLivenessProperties` crossed it, and is silent there), and this rule + // judges each written template on its own — no sibling, no context + // collection — so the door's verdict and the whole-stack verdict are the + // same value. A stored template is not judged until its next publish, and a + // draft not at all (ruling item 4). MEASURED first, with this judge: 0 + // refusals over HotCRM's 76 declared bodies (`c8a7a7047feb`) and over every + // shipped template here; the five non-production bodies it refused (two + // docs examples, the dogfood materialization fixture, one conversion + // fixture's before and after) were corrected in the same change. + { + name: 'validateEmailTemplateHolePlacements', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-email-template-hole-placements.ts', + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['email_template'], + run: (stack) => validateEmailTemplateHolePlacements(stack), + }, ]; // ─── Runner ───────────────────────────────────────────────────────── diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index fac2fbcb61e..839a3c52170 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -936,6 +936,15 @@ export { PERMISSION_RETIRED_LIFECYCLE_RESIDUE, } from './validate-retired-permission-residue.js'; +// [#25189] A hole an email template's bodyHtml places where the mail renderer +// cannot encode it for that placement (ruling 6335348115 on #25082, F1 A2). +// The rule id is published for `--json` consumers (`rule-id-barrel-exports.test.ts`). +export { + validateEmailTemplateHolePlacements, + EMAIL_TEMPLATE_HOLE_PLACEMENT, +} from './validate-email-template-hole-placements.js'; +export type { EmailTemplateHolePlacementFinding } from './validate-email-template-hole-placements.js'; + export { lintAutonumberFormats } from './lint-autonumber-formats.js'; export type { AutonumberLintFinding } from './lint-autonumber-formats.js'; export { diff --git a/packages/lint/src/runtime-gate.inert-type-writes.test.ts b/packages/lint/src/runtime-gate.inert-type-writes.test.ts index 032fd1da9e2..47397e6ee27 100644 --- a/packages/lint/src/runtime-gate.inert-type-writes.test.ts +++ b/packages/lint/src/runtime-gate.inert-type-writes.test.ts @@ -57,6 +57,12 @@ * proved by a TABLE PIN rather than a door reading — the size of that proof is * stated where those cases are, and it is why the string assertion in the * first block is load-bearing rather than decorative. + * + * [#25189] `email_template` is silent only for the liveness bridge now. Its + * writes also cross `validateEmailTemplateHolePlacements`, whose own door test + * (`validate-email-template-hole-placements.test.ts`) refuses a written + * template through `runRuntimeAuthoringRules`, so a wrong stack key for that + * type goes red there too. For `mapping` the table pin is still all there is. */ import { describe, expect, it } from 'vitest'; @@ -705,6 +711,11 @@ describe('#19542 — group C: wired, dispatched, and silent by ledger (email_tem * assertion, `each stack key is the collection the crossed rules actually * read` in the first block of this file. * + * [#25189] For `email_template` that is no longer the whole proof: the hole + * placement rule judges its writes at this door, and its own test file + * reads the collection the key names. The paragraph below stands for + * `mapping`. + * * So for group C the wiring is held by a TABLE PIN, not a door reading. That * is acceptable under the ruling's fence (「the wiring is the whole * deliverable」, ⛔ no ledger population) — but it is the honest size of the diff --git a/packages/lint/src/validate-email-template-hole-placements.test.ts b/packages/lint/src/validate-email-template-hole-placements.test.ts new file mode 100644 index 00000000000..83e1b8467c7 --- /dev/null +++ b/packages/lint/src/validate-email-template-hole-placements.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25189 — `validateEmailTemplateHolePlacements` on every authoring door: the + * three CLI commands, and the runtime publish gate for an `email_template` + * write (ruling `6335348115` on #25082, F1 A2). + * + * Which placement is supported is the shared judge's to decide, and + * `@objectstack/formula`'s own test pins it class by class. What this file + * holds is the rule: it reads every template's `bodyHtml` with the template's + * own url variables, refuses at `error` on every door, names the placement and + * the supported set, judges a written template on its own, and leaves the + * plain-text faces alone. + * + * The bodies here carry holes and markup only, never a value. + */ +import { describe, expect, it } from 'vitest'; + +import { MAIL_HTML_PLACEMENT_LABELS, MAIL_HTML_SUPPORTED_SHAPES } from '@objectstack/formula'; + +import { AUTHORING_COMMANDS, AUTHORING_RULES, runAuthoringRules } from './authoring-rules.js'; +import { buildRuntimeWriteSnapshots, runRuntimeAuthoringRules, stackKeyForType } from './runtime-gate.js'; +import { + EMAIL_TEMPLATE_HOLE_PLACEMENT, + validateEmailTemplateHolePlacements, +} from './validate-email-template-hole-placements.js'; + +const template = (over: Record<string, unknown> = {}) => ({ + name: 'acme.welcome', + label: 'Welcome', + locale: 'en-US', + subject: 'Hi {{user.name}}', + bodyHtml: '<p>Hi <b>{{user.name}}</b>, <a href="{{{link}}}" title="{{user.name}}">open</a></p>', + bodyText: 'Hi {{user.name}}: {{{link}}}', + variables: [ + { name: 'user.name', type: 'string' }, + { name: 'link', type: 'url' }, + ], + ...over, +}); + +/** A body with an encoded hole in a URL attribute — a placement the encoding does not suit. */ +const IN_URL_ATTRIBUTE = '<p><a href="{{link}}">open</a></p>'; +/** A body with an unencoded hole in element text — outside the one shape that form is admitted in. */ +const RAW_IN_TEXT = '<p>{{{link}}}</p>'; + +const ruleIds = (findings: readonly { rule: string }[]) => findings.map((f) => f.rule); + +describe('the rule, on the stack', () => { + it('a body whose every hole is supported draws nothing', () => { + expect(validateEmailTemplateHolePlacements({ emailTemplates: [template()] })).toEqual([]); + }); + + it('refuses an encoded hole in a placement its encoding does not suit, naming the placement and the supported set', () => { + const findings = validateEmailTemplateHolePlacements({ emailTemplates: [template({ bodyHtml: IN_URL_ATTRIBUTE })] }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: EMAIL_TEMPLATE_HOLE_PLACEMENT, + path: 'emailTemplates[0].bodyHtml', + }); + expect(findings[0].where).toContain('acme.welcome'); + expect(findings[0].message).toContain(MAIL_HTML_PLACEMENT_LABELS['url-attribute']); + expect(findings[0].message).toContain('{{link}}'); + expect(findings[0].hint).toContain(MAIL_HTML_SUPPORTED_SHAPES); + }); + + it('refuses an unencoded hole outside the url href shape, and admits it only for a declared url variable', () => { + const raw = validateEmailTemplateHolePlacements({ emailTemplates: [template({ bodyHtml: RAW_IN_TEXT })] }); + expect(raw.map((f) => f.message)).toEqual([expect.stringContaining(MAIL_HTML_PLACEMENT_LABELS['element-text'])]); + + // The href shape reads the TEMPLATE's own declarations: the same body is + // refused once `link` is no longer declared with type 'url'. + const undeclared = template({ variables: [{ name: 'user.name', type: 'string' }, { name: 'link', type: 'string' }] }); + expect(ruleIds(validateEmailTemplateHolePlacements({ emailTemplates: [undeclared] }))).toEqual([EMAIL_TEMPLATE_HOLE_PLACEMENT]); + const noVariables = template({ variables: undefined }); + expect(ruleIds(validateEmailTemplateHolePlacements({ emailTemplates: [noVariables] }))).toEqual([EMAIL_TEMPLATE_HOLE_PLACEMENT]); + }); + + it('one finding per refused hole, each located on its own template', () => { + const findings = validateEmailTemplateHolePlacements({ + emailTemplates: [ + template(), + template({ name: 'acme.digest', bodyHtml: `${IN_URL_ATTRIBUTE}${RAW_IN_TEXT}` }), + ], + }); + expect(findings.map((f) => f.path)).toEqual(['emailTemplates[1].bodyHtml', 'emailTemplates[1].bodyHtml']); + expect(findings.every((f) => f.where.includes('acme.digest'))).toBe(true); + }); + + it('a name-keyed collection is located by name', () => { + const { name: _name, ...rest } = template({ bodyHtml: IN_URL_ATTRIBUTE }); + const findings = validateEmailTemplateHolePlacements({ emailTemplates: { 'acme.welcome': rest } }); + expect(findings.map((f) => f.path)).toEqual(['emailTemplates.acme.welcome.bodyHtml']); + }); + + it('the plain-text faces are not judged — they carry no markup context', () => { + expect(validateEmailTemplateHolePlacements({ + emailTemplates: [template({ subject: '{{{link}}} <a href="{{link}}">', bodyText: '<script>{{x}}</script>' })], + })).toEqual([]); + }); + + it('a stack with no templates, or a template with no html body, draws nothing', () => { + expect(validateEmailTemplateHolePlacements({})).toEqual([]); + expect(validateEmailTemplateHolePlacements({ emailTemplates: [template({ bodyHtml: undefined })] })).toEqual([]); + }); +}); + +describe('the registry entry', () => { + const entry = AUTHORING_RULES.find((r) => r.name === 'validateEmailTemplateHolePlacements'); + + it('is gating, on all three commands and the runtime publish gate for email_template writes', () => { + expect(entry).toMatchObject({ + tier: 'gating', + commands: [...AUTHORING_COMMANDS], + surfaces: ['cli', 'runtime-publish'], + runtimeTypes: ['email_template'], + }); + }); + + it.each([...AUTHORING_COMMANDS])('os %s refuses the body at error', (command) => { + const stack = { emailTemplates: [template({ bodyHtml: IN_URL_ATTRIBUTE })] }; + const errors = runAuthoringRules(command, { normalized: stack, parsed: stack }) + .filter((f) => f.severity === 'error' && f.rule === EMAIL_TEMPLATE_HOLE_PLACEMENT); + expect(errors).toHaveLength(1); + }); +}); + +describe('the runtime publish gate, for an email_template write', () => { + it('⭐ LIT — refuses the written template, located on the written template itself', () => { + const result = runRuntimeAuthoringRules({ type: 'email_template', item: template({ bodyHtml: IN_URL_ATTRIBUTE }) }); + expect(result.rulesRun).toContain('validateEmailTemplateHolePlacements'); + expect(ruleIds(result.errors)).toEqual([EMAIL_TEMPLATE_HOLE_PLACEMENT]); + // `emailTemplates` is no context collection, so the candidate holds the + // written template alone and its positional index is never re-keyed: the + // index names that template, and `where` carries its name. + expect(result.errors[0].path).toBe('emailTemplates[0].bodyHtml'); + expect(result.errors[0].where).toContain('acme.welcome'); + }); + + it('publishes a template whose every hole is supported', () => { + const result = runRuntimeAuthoringRules({ type: 'email_template', item: template() }); + expect(result.errors).toEqual([]); + }); + + it('judges the written template alone — a stored sibling is never in its snapshot, so never charged to it', () => { + // An email template write carries no context collection of its own kind: + // the candidate holds the written item and nothing else, and the baseline + // holds no template at all. A stored template keeps serving and answers + // for its own placements at its own next publish. + const snapshots = buildRuntimeWriteSnapshots({ type: 'email_template', item: template() }); + expect(stackKeyForType('email_template')).toBe('emailTemplates'); + expect(snapshots?.candidate.emailTemplates).toEqual([template()]); + expect(snapshots?.baseline.emailTemplates ?? []).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-email-template-hole-placements.ts b/packages/lint/src/validate-email-template-hole-placements.ts new file mode 100644 index 00000000000..86c6a407281 --- /dev/null +++ b/packages/lint/src/validate-email-template-hole-placements.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An email template's HTML body places each hole where the mail renderer's + * encoding is right for it (#25189 — the email half of #25082, ruling + * `6335348115` letters F1 A2 and F3 C2). + * + * ## The rule + * + * The mail renderer encodes a double-braced hole one way and leaves a + * triple-braced hole unencoded, and the template's author decides where each + * hole sits. Maintainer ruling 3C (`6308462370` on #25040) asks every outlet + * that renders a text field into HTML to escape by context, and triage read it + * on #25082 (`6332255183`) as reaching every placement an author can write, + * with a placement restriction — not a per-context encoder, not guidance — as + * the remedy. So a hole the renderer cannot encode for its placement is + * refused when the template is authored or published, naming the placement + * and the supported ones. + * + * WHICH placements are supported is not decided here: it is + * `@objectstack/formula`'s `judgeMailHtmlHoles`, the one judge this rule and + * `@objectstack/plugin-email`'s built-in template pin both read, whose header + * carries the classification and whose pin holds its grammar to the + * renderer's. This module only walks the collection and words the finding. + * + * ## Where it runs + * + * Every authoring door: `os validate`, `os build`, `os lint`, and the runtime + * publish gate (an ACTIVE save, and a draft's promotion) for `email_template` + * writes, refused there with the existing `422 INVALID_METADATA`. A draft is + * not judged. A stored template keeps rendering — nothing judges it at send + * or at boot — and is judged at its next publish (ruling item 4). + * + * Only `bodyHtml` is judged: `subject` and `bodyText` are plain-text faces the + * renderer writes verbatim, where a hole has no markup context at all. + */ + +import { + judgeMailHtmlHoles, + MAIL_HTML_PLACEMENT_LABELS, + MAIL_HTML_SUPPORTED_SHAPES, +} from '@objectstack/formula'; + +import { collectionEntries } from './collection-entries.js'; + +type AnyRec = Record<string, unknown>; + +export const EMAIL_TEMPLATE_HOLE_PLACEMENT = 'email-template-hole-placement'; + +export interface EmailTemplateHolePlacementFinding { + severity: 'error'; + rule: typeof EMAIL_TEMPLATE_HOLE_PLACEMENT; + where: string; + /** Positional config path, e.g. `emailTemplates[0].bodyHtml`. */ + path: string; + message: string; + hint: string; +} + +/** The variables a template declares with `type: 'url'` — the paths a triple-braced `href` hole may read. */ +function urlVariablesOf(template: AnyRec): string[] { + const variables = template.variables; + if (!Array.isArray(variables)) return []; + return variables + .filter((v): v is AnyRec => !!v && typeof v === 'object' && (v as AnyRec).type === 'url') + .map((v) => v.name) + .filter((name): name is string => typeof name === 'string'); +} + +export function validateEmailTemplateHolePlacements(stack: AnyRec): EmailTemplateHolePlacementFinding[] { + const findings: EmailTemplateHolePlacementFinding[] = []; + for (const { rec: template, path } of collectionEntries(stack.emailTemplates, 'emailTemplates')) { + const body = template.bodyHtml; + if (typeof body !== 'string' || body === '') continue; + const name = typeof template.name === 'string' ? template.name : '(unnamed)'; + const locale = typeof template.locale === 'string' ? ` [${template.locale}]` : ''; + for (const hole of judgeMailHtmlHoles(body, { urlVariables: urlVariablesOf(template) })) { + if (hole.supported) continue; + const form = hole.raw ? 'triple-braced (unencoded) hole' : 'hole'; + const why = hole.raw + ? 'the renderer writes that form without encoding, which only the url href shape admits' + : 'the renderer\'s encoding is not the one that placement needs'; + findings.push({ + severity: 'error', + rule: EMAIL_TEMPLATE_HOLE_PLACEMENT, + where: `email template "${name}"${locale}`, + path: `${path}.bodyHtml`, + message: + `bodyHtml places the ${form} ${hole.text} (offset ${hole.start}) in ` + + `${MAIL_HTML_PLACEMENT_LABELS[hole.placement]}; ${why}.`, + hint: + `Supported placements: ${MAIL_HTML_SUPPORTED_SHAPES}. Move the hole to one of them — a link ` + + 'goes in a variable declared with type \'url\', written as the whole double-quoted href value.', + }); + } + } + return findings; +} diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts b/packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts new file mode 100644 index 00000000000..35994189e7f --- /dev/null +++ b/packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25189 — the HOST half of the email template publish door. + * + * `@objectstack/lint`'s `validate-email-template-hole-placements.test.ts` + * proves the gate dispatches an `email_template` write to the placement rule. + * This file asks the other side of the package wall: does the SHIPPED caller + * thread an `email_template` write to that gate, and does the refusal arrive in + * the existing ADR-0112 envelope (`422 INVALID_METADATA`) a Studio / REST + * `/meta` / MCP author's client reads (ruling `6335348115` on #25082, item 3)? + * + * Harness: none — `evaluateRuntimeAuthoringGate` is pure, so nothing about the + * door is stubbed. 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; never a bare `toThrow()`. + * + * The bodies carry holes and markup only, never a value. + */ +import { describe, expect, it } from 'vitest'; + +import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js'; + +/** A template whose every hole sits in a supported placement. */ +const cleanTemplate = (over: Record<string, unknown> = {}) => ({ + name: 'acme.welcome', + label: 'Welcome', + locale: 'en-US', + subject: 'Hi {{user.name}}', + bodyHtml: '<p>Hi <b>{{user.name}}</b>, <a href="{{{link}}}">open</a></p>', + variables: [ + { name: 'user.name', type: 'string' }, + { name: 'link', type: 'url' }, + ], + ...over, +}); + +/** An encoded hole in a URL attribute — a placement class the encoding does not suit. */ +const IN_URL_ATTRIBUTE = '<p><a href="{{link}}">open</a></p>'; + +const publishTemplate = (body: unknown, state: 'draft' | 'active' = 'active') => + evaluateRuntimeAuthoringGate({ + type: 'email_template', + name: (body as { name?: string })?.name ?? 'acme.welcome', + state, + body, + objects: [], + permissions: [], + books: [], + datasets: [], + }); + +describe('the host threads an email_template publish to the runtime gate (#25189)', () => { + it('⭐ LIT — a hole in a placement the renderer cannot encode for is refused in the ADR-0112 envelope', () => { + const verdict = publishTemplate(cleanTemplate({ bodyHtml: IN_URL_ATTRIBUTE })); + + 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); + + // The receipt the author reads back: rule id, the face and the template it + // names, the hole as written. The written template is the candidate's only + // entry, so its positional path names it. + const issues = (verdict.error as { issues?: Array<Record<string, unknown>> }).issues!; + expect(issues.map((i) => [i.rule, i.path])).toEqual([['email-template-hole-placement', 'emailTemplates[0].bodyHtml']]); + const wire = JSON.stringify(issues); + expect(wire).toContain('acme.welcome'); + expect(wire).toContain('{{link}}'); + + expect((verdict.error as { rulesRun?: string[] }).rulesRun).toContain('validateEmailTemplateHolePlacements'); + }); + + 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(); + expect(verdict.advisories).toEqual([]); + }); + + it('a draft save is not judged (ruling item 4) — the same body is judged at its publish', () => { + const body = cleanTemplate({ bodyHtml: IN_URL_ATTRIBUTE }); + expect(publishTemplate(body, 'draft')).toEqual({ error: null, advisories: [] }); + // Non-vacuous: the draft's body IS refused once it is published. + expect(publishTemplate(body, 'active').error).not.toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts b/packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts index 6954aedcef7..1c0c1af01d0 100644 --- a/packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts +++ b/packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts @@ -17,8 +17,25 @@ * * The one unescaped form is the triple-braced hole, an author's explicit opt * out. The built-in templates use it for exactly one thing — a link the - * platform minted, placed inside an `href` attribute — and the pin on - * `templates/auth-templates.ts` below holds them to that. + * platform minted, placed inside an `href` attribute. + * + * ## Where a hole may sit — the shared judge (#25189) + * + * The escaping is right only where the placement needs exactly that encoding: + * element text, and a quoted plain-text attribute value. Every other + * placement, and an unescaped hole anywhere but a declared url variable + * forming a whole double-quoted `href`, is refused at authoring time by + * `@objectstack/lint`'s `validateEmailTemplateHolePlacements` (ruling + * `6335348115` on #25082). Which placement is which is `@objectstack/formula`'s + * `judgeMailHtmlHoles`, and the pin on `templates/auth-templates.ts` below + * holds the built-ins to that SAME judge, so a built-in can never sit where an + * author's template would be refused. + * + * That judge spells this package's hole grammar again, because the two share + * no module that could hold it, so the grammar is pinned here too, against the + * real renderer: on a grammar corpus and on every built-in body, the spans the + * judge finds are exactly the spans the renderer replaces, and the escaped and + * unescaped forms are the ones it encodes and does not. * * ## The list, and the pin on the list itself * @@ -59,6 +76,8 @@ import type { } from '@objectstack/spec/contracts'; import type { EmailTemplateDefinition } from '@objectstack/spec/system'; +import { judgeMailHtmlHoles, type MailHtmlHole } from '@objectstack/formula'; + import { renderTemplate, renderPlainTextTemplate } from './template-engine.js'; import { EmailService, type EmailTemplateRow, type TemplateLoader } from './email-service.js'; import { BUILTIN_AUTH_TEMPLATES } from './templates/auth-templates.js'; @@ -177,6 +196,8 @@ const PERSON = 'Ada <b>Lovelace</b> & "Co" it\'s'; const PERSON_IN_HTML = 'Ada &lt;b&gt;Lovelace&lt;/b&gt; &amp; &quot;Co&quot; it&#39;s'; /** The fixture's markup as it would appear had a hole passed it through unescaped. */ const PERSON_MARKUP = '<b>Lovelace</b>'; +/** That markup as an escaping hole renders it. */ +const PERSON_MARKUP_IN_HTML = '&lt;b&gt;Lovelace&lt;/b&gt;'; /** A platform-minted link with a query string. */ const LINK = 'https://acme.test/act?token=T1&next=%2Fhome'; @@ -229,6 +250,58 @@ function pinTemplateEngine(): void { it('the plain-text faces render every hole as typed', () => { expect(renderPlainTextTemplate('{{who}} / {{{who}}}', { who: PERSON })).toBe(`${PERSON} / ${PERSON}`); }); + + it('the shared placement judge reads this renderer\'s hole grammar: the same spans, the same two forms', () => { + const bodies = [...GRAMMAR_CORPUS, ...BUILTIN_AUTH_TEMPLATES.map((t) => t.bodyHtml ?? '')]; + let judged = 0; + for (const body of bodies) { + const holes = judgeMailHtmlHoles(body); + judged += holes.length; + // Spans: with no data every hole renders empty, so what the renderer + // leaves is the body minus exactly the spans the judge found. + expect(renderTemplate(body, {}), body).toBe(splice(body, holes, () => '')); + // Forms: where no hole carries a formatter, a marked value comes out + // escaped from every hole the judge calls escaped, and as typed from + // every hole it calls unescaped. + if (holes.some((h) => h.text.includes('|'))) continue; + expect(renderTemplate(body, everyPath(PERSON_MARKUP)), body) + .toBe(splice(body, holes, (h) => (h.raw ? PERSON_MARKUP : PERSON_MARKUP_IN_HTML))); + } + expect(judged, 'the corpus carries holes to compare').toBeGreaterThan(200); + }); +} + +/** + * The grammar's edges, beside the built-ins' ordinary holes: spacing, dotted + * paths, formatters with and without an argument, mismatched and stacked + * braces, and brace pairs the renderer leaves verbatim. No `default` + * formatter, which renders text for a missing value. + */ +const GRAMMAR_CORPUS = [ + '<p>{{a}} {{ a.b }} {{a_b.c1}}{{ spaced }}</p>', + '<p>{{ x | upper }} {{ total | currency:EUR }} {{ t | currency: \'USD\' }} {{ d | date }}</p>', + '<p>{{{a}}} {{{a}} {{a}}} {{{{a}}}} {{{ a }}}</p>', + '<p>{{ not a path }} {{}} {{ | upper }} {{ x | two words }} {{a{b}} {x}</p>', + '<p title="{{a}}">{{ <b }}{{b}}</p><a href="{{{u}}}">{{u}}</a>', +]; + +/** `body` with each hole's span replaced by `by(hole)`. */ +function splice(body: string, holes: readonly MailHtmlHole[], by: (h: MailHtmlHole) => string): string { + let out = ''; + let at = 0; + for (const h of holes) { + out += body.slice(at, h.start) + by(h); + at = h.end; + } + return out + body.slice(at); +} + +/** Data under which every dotted path resolves to `value`. */ +function everyPath(value: string): Record<string, unknown> { + const leaf: object = new Proxy({}, { + get: (_target, key) => (key === Symbol.toPrimitive ? () => value : typeof key === 'string' ? leaf : undefined), + }); + return leaf as Record<string, unknown>; } function pinEmailService(): void { @@ -264,6 +337,17 @@ function pinEmailService(): void { expect(out.text).toBe(`${PERSON} wrote on ${PERSON}`); expect(transport.sent).toHaveLength(0); }); + + it('a stored row the authoring rule would refuse still renders and sends: it is judged at its next publish, never at send', async () => { + const STORED: EmailTemplateRow = { ...AUTHORED, name: 'crm.stored', body_html: '<p><a href="{{record.title}}">{{actor.name}}</a></p>' }; + expect(judgeMailHtmlHoles(STORED.body_html).filter((h) => !h.supported).map((h) => h.placement)).toEqual(['url-attribute']); + const { svc, transport } = service([STORED]); + + await svc.sendTemplate({ template: 'crm.stored', to: 'ada@acme.test', data: DATA }); + + expect(transport.sent).toHaveLength(1); + expect(transport.sent[0].html).toContain(PERSON_IN_HTML); + }); } /** `{ 'user.name': v }` → `{ user: { name: v } }`. */ @@ -282,26 +366,32 @@ function dataFor(variables: NonNullable<EmailTemplateDefinition['variables']>): return data; } -const RAW_HOLE = /\{\{\{([^{}]*)\}\}\}/g; +/** The variables a built-in declares with `type: 'url'` — the paths its unescaped href holes may read. */ +const urlVariablesOf = (t: EmailTemplateDefinition) => + (t.variables ?? []).filter((v) => v.type === 'url').map((v) => v.name); function pinBuiltInTemplates(): void { it('there are built-in templates to judge (control)', () => { expect(BUILTIN_AUTH_TEMPLATES.length).toBeGreaterThan(0); }); + it('the built-ins carry unescaped holes, and each is admitted by its url declaration and by nothing else (control)', () => { + const judged = (urlVariables: (t: EmailTemplateDefinition) => string[]) => + BUILTIN_AUTH_TEMPLATES.flatMap((t) => judgeMailHtmlHoles(t.bodyHtml ?? '', { urlVariables: urlVariables(t) })) + .filter((h) => h.raw); + expect(judged(urlVariablesOf).length).toBeGreaterThan(0); + expect(judged(urlVariablesOf).every((h) => h.supported && h.placement === 'url-attribute')).toBe(true); + expect(judged(() => []).filter((h) => h.supported)).toEqual([]); + }); + for (const t of BUILTIN_AUTH_TEMPLATES) { const label = `${t.name} [${t.locale ?? 'en-US'}]`; - it(`${label}: every unescaped hole in the html body is a declared url variable inside an href attribute`, () => { - const html = t.bodyHtml ?? ''; - const urlVariables = new Set((t.variables ?? []).filter((v) => v.type === 'url').map((v) => v.name)); - const misplaced: string[] = []; - for (const m of html.matchAll(RAW_HOLE)) { - const at = m.index ?? 0; - const inHref = html.slice(at - 'href="'.length, at) === 'href="' && html[at + m[0].length] === '"'; - if (!urlVariables.has(m[1].trim()) || !inHref) misplaced.push(m[0]); - } - expect(misplaced).toEqual([]); + it(`${label}: every hole in the html body sits where an author's would be admitted (the shared judge)`, () => { + const refused = judgeMailHtmlHoles(t.bodyHtml ?? '', { urlVariables: urlVariablesOf(t) }) + .filter((h) => !h.supported) + .map((h) => `${h.text} in ${h.placement}`); + expect(refused).toEqual([]); }); it(`${label}: every text variable renders escaped into the html part`, async () => { diff --git a/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts b/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts index 6e3d0f75eef..fd27f7249e6 100644 --- a/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts @@ -44,8 +44,10 @@ export const etPasswordReset = defineEmailTemplateDefinition({ name: 'auth.password_reset', label: 'ET Password Reset', subject: 'Authored reset for {{user.name}}', - bodyHtml: '<p>Authored body: <a href="{{url}}">reset</a></p>', - variables: [{ name: 'url', type: 'string', required: true }], + // The link is a declared `url` variable forming the whole href, the one + // placement an unescaped hole is admitted in (`validateEmailTemplateHolePlacements`). + bodyHtml: '<p>Authored body: <a href="{{{url}}}">reset</a></p>', + variables: [{ name: 'url', type: 'url', required: true }], }); export const emailTemplateFixtureStack = defineStack({ diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 6213f70784a..0b59205d8aa 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8871,7 +8871,8 @@ const emailTemplateCategoryIsSystemRemoved: MetadataConversion = { category: 'auth', locale: 'en-US', subject: 'Reset your password', - bodyHtml: '<p>{{{resetUrl}}}</p>', + bodyHtml: '<p><a href="{{{resetUrl}}}">Reset</a></p>', + variables: [{ name: 'resetUrl', type: 'url', required: true }], isSystem: true, }, { @@ -8904,7 +8905,8 @@ const emailTemplateCategoryIsSystemRemoved: MetadataConversion = { label: 'Password Reset', locale: 'en-US', subject: 'Reset your password', - bodyHtml: '<p>{{{resetUrl}}}</p>', + bodyHtml: '<p><a href="{{{resetUrl}}}">Reset</a></p>', + variables: [{ name: 'resetUrl', type: 'url', required: true }], }, { name: 'crm.welcome',