Skip to content
26 changes: 26 additions & 0 deletions .changeset/25189-email-template-hole-placement.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no stored row is read, rewritten or converted, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the verdict the authoring doors give an email template whose HTML body places a hole outside the supported placements. A stored template keeps rendering and is judged at its next publish, and the remedy is the author moving the hole, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a lint verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

**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.
24 changes: 19 additions & 5 deletions content/docs/automation/email-templates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`<a href="{{{reset_url}}}">`).
- `{{ 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.
Expand Down Expand Up @@ -118,15 +130,17 @@ export const passwordResetEn = defineEmailTemplateDefinition({
label: 'Password Reset',
locale: 'en-US',
subject: 'Reset your password',
bodyHtml: '<p>Use {{{reset_url}}} within {{ttl_minutes}} minutes.</p>',
bodyHtml: '<p>Use <a href="{{{reset_url}}}">this link</a> within {{ttl_minutes}} minutes.</p>',
variables: [{ name: 'reset_url', type: 'url', required: true }],
});

export const passwordResetZh = defineEmailTemplateDefinition({
name: 'auth.password_reset',
label: 'Password Reset',
locale: 'zh-CN',
subject: '重置您的密码',
bodyHtml: '<p>请在 {{ttl_minutes}} 分钟内使用 {{{reset_url}}}。</p>',
bodyHtml: '<p>请在 {{ttl_minutes}} 分钟内使用<a href="{{{reset_url}}}">此链接</a>。</p>',
variables: [{ name: 'reset_url', type: 'url', required: true }],
});
```

Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
17 changes: 10 additions & 7 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) | ✓ | ✓ | ✓ | ✓ᶠ |
Expand All @@ -495,19 +496,21 @@ 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
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
Expand Down Expand Up @@ -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)...

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)...

Expand Down
12 changes: 12 additions & 0 deletions packages/formula/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions packages/formula/src/mail-html-hole-placement.test.ts
Original file line number Diff line number Diff line change
@@ -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([
['<p>{{ who }}</p>', 'element-text'],
['{{who}}', 'element-text'],
['<table><tr><td>{{ who | upper }}</td></tr></table>', 'element-text'],
['<title>{{who}}</title>', 'element-text'],
['<textarea>{{who}}</textarea>', 'element-text'],
['<p title="{{who}}">x</p>', 'quoted-attribute'],
["<p title='{{who}}'>x</p>", 'quoted-attribute'],
['<td width="{{w}}" class="a {{c}} b">x</td>', '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 ['<a href="{{{url}}}">go</a>', '<a HREF="{{{ url }}}">go</a>', '<a class="b" href = "{{{url}}}">go</a>']) {
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([
['<script>{{x}}</script>', 'raw-text-element'],
['<style>p { color: {{x}} }</style>', 'raw-text-element'],
['<noscript>{{x}}</noscript>', 'raw-text-element'],
['<plaintext>{{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 }),
]);
});
});
Loading
Loading