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. Use this link within {{ttl_minutes}} minutes. 请在 {{ttl_minutes}} 分钟内使用 {{{reset_url}}}。 请在 {{ttl_minutes}} 分钟内使用此链接。 {{ who }}
| {{ who | upper }} |
x
', 'quoted-attribute'], + ["x
", 'quoted-attribute'], + ['a
', 'tag'], + ['<{{x}}>', 'tag'], + ['{{x}}>', 'tag'], + ['a
', 'tag'], + ['a
', 'tag'], + ['a', 'url-attribute'], + ['a', 'url-attribute'], + ['a
', 'event-handler-attribute'], + ['a
', 'style-attribute'], + ['', 'markup-attribute'], + ['', 'foreign-content'], + ['', 'foreign-content'], + ['', '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[]]> = [ + ['{{{url}}}
', ['url']], + ['a
', ['url']], + ['a', []], + ['a', ['url']], + ['a', ['url']], + ["a", ['url']], + ['a', ['url']], + ['a', ['url']], + ['a', ['url']], + ['{{a}}
{{b}}{{c}}{{d}}', + ); + 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('').placement).toBe('raw-text-element'); + expect(one('').placement).toBe('raw-text-element'); + }); + + it('an abruptly closed comment ends where the parser ends it', () => { + expect(one('{{x}}
').placement).toBe('element-text'); + expect(one('{{x}}
').placement).toBe('element-text'); + expect(one('{{x}}
').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('{{ not a path }}
')).toEqual([]); + expect(judgeMailHtmlHoles('{x}
')).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('{{ ');
+ 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 Hi {{user.name}}, open {{{link}}} Hi {{user.name}}, open {{a}} {{ a.b }} {{a_b.c1}}{{ spaced }} {{ x | upper }} {{ total | currency:EUR }} {{ t | currency: \'USD\' }} {{ d | date }} {{{a}}} {{{a}} {{a}}} {{{{a}}}} {{{ a }}} {{ not a path }} {{}} {{ | upper }} {{ x | two words }} {{a{b}} {x} {{ {{u}}',
+];
+
+/** `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 Authored body: reset Authored body: reset {{{resetUrl}}} {{{resetUrl}}}