Skip to content

Commit 9127f23

Browse files
fix(lint)!: refuse an email template HTML body hole the mail renderer cannot encode for its placement (#25189) (#25212)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg19C8PuVjmPVdq1gQefBuic)_ Fixes #25189 Part of #25082 (the email half; the parent stays open for the notification half, which is not in this PR) Clause-②: no (narrowing) Metadata-protocol: none Session: `session_01Fg7vNwYgyAmHMTJm8rAYsD`, the card thread of `domain:spec` seat 2 (seat session `session_01B2EeooonSXrANpkpgUSfFZ`). Ruling `6335348115` on #25082, letter F1 A2, items 1, 3 and 4. ## What changes A new gating authoring rule, `email-template-hole-placement` (`validateEmailTemplateHolePlacements`), reads every email template's `bodyHtml` and refuses each hole that sits where the mail renderer's encoding does not suit it. It is one `CLI_AND_RUNTIME` entry in `AUTHORING_RULES` with `runtimeTypes: ['email_template']`, so it runs on `os validate`, `os build`, `os lint` and the runtime publish gate. - **Supported placements:** a double-braced hole in element text (the title and textarea elements included) 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, by class:** 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. The classification is context-strict: the allow-list is read off the renderer's own encoder, and anything it does not cover is refused. Where the scan does not model a tokenizer state (a script element's escape states), it fails closed and keeps the hole in raw text. - **Where it answers:** an `error` finding on the CLI at `emailTemplates[i].bodyHtml`, and the existing `422 INVALID_METADATA` at the publish gate with the same issue. The message names the hole, its offset and its placement; the hint names the supported placements. No new error code. - **What is not judged:** `subject` and `bodyText` (plain-text faces), a draft save, and every stored template. A stored template keeps rendering and is judged at its next publish. No migration. The classifier is shared. `judgeMailHtmlHoles` lives in `@objectstack/formula`, because both readers already depend on that package and on nothing else they share: the lint rule, and `@objectstack/plugin-email`'s built-in template pin. That pin now reads the same classifier, and a parity test holds the classifier's hole grammar to the renderer's (an empty render and a markup-carrying render of every built-in body and a grammar corpus, over 200 holes). The renderer itself is not changed. ## Measured before crossing - HotCRM at `c8a7a7047feb`: 76 HTML bodies, 116 holes, 0 refused (re-measured with the classifier at `86330c090`). - The templates this repository ships: 0 refused (the plugin-email pin asserts it per built-in). - The five bodies the rule did refuse were non-production and each now uses the supported link shape: two docs examples, one dogfood fixture, and two spec conversion fixtures. ## File surface and domain declarations - `packages/lint`: the rule, its registry entry, its barrel export and its tests, and the header comments of `runtime-gate.inert-type-writes.test.ts` (`domain:spec`, by the lane table's anchoring exception). - `packages/formula`: the shared classifier, its export and its tests, declared to `domain:engine`. - `packages/plugins/plugin-email`: the built-in pin, test only. - `packages/metadata-protocol`: one new test file, the host half of the publish door (envelope `code` and `status`, a draft not judged). Test only; declared here as part of the rule's tests. - `content/docs/automation/email-templates.mdx` (the placement rule, its refused list including SVG and MathML content, and the two examples), declared to `domain:devx`. The four-doors table in `deployment/validating-metadata.mdx` gains the rule's row and the `email_template` superscript, also `domain:devx`. Four CLI transcripts (`deployment/cli.mdx`, `deployment/validating-metadata.mdx`, `getting-started/build-with-claude-code.mdx`, `ui/react-pages.mdx`) print the author-time rule count, which this entry moves from 50 to 51; `check:docs-transcript-drift` found them, and they are declared to `domain:devx` too. - `packages/qa/dogfood` fixture, declared to `domain:cli`. - `packages/spec/src/conversions/registry.ts`: one conversion's example templates only. - `.changeset/25189-email-template-hole-placement.md`: `patch` for lint, formula and spec, with the BREAKING banner and the ADR-0087 disposition (`not-required`, no migration prescription). ## Tests At head `86330c090` (formula, plugin-email and the door test re-run after the scan correction; the full lint suite and spec conversions were run on `f3bc62099`, and since then lint changed only a test file's comments): - `@objectstack/formula`: the classifier, class by class (46 tests); the whole package, 1413 passed. - `@objectstack/lint`: the rule, its registry entry, every command, and the runtime gate (refused and clean writes, and the written template judged alone); with `authoring-rule-wiring` and `runtime-gate.inert-type-writes`, 82 passed on `86330c090`; the whole package, 7101 passed on `f3bc62099`. - `@objectstack/metadata-protocol`: the door test, 3 passed. The refusal asserts `code: 'INVALID_METADATA'` and `status: 422` together (ADR-0112), plus the issue's rule and path. - `@objectstack/plugin-email`: the pin, 77 passed; the whole package, 671 passed. - `@objectstack/spec`: `src/conversions`, 739 passed. - `typecheck` for formula, lint, metadata-protocol, plugin-email and spec: exit 0 each. `@objectstack/dogfood` typecheck: exit 0 (under the lock, with a raised heap; the default heap ran out of memory once). ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff, then every command run on head `86330c090` with its exit code recorded before any pipe: - `dispatch-gates --ran`: 113 derived families accounted for, 113 run, 0 NOT-MEASURED (a derived zero: every one recorded an exit code and none is 3), 0 UNRUN. All 113 exited 0. - An earlier run on `06f68ec4e` found `check:docs-transcript-drift` red on the four CLI transcripts named above (rule count 50, derived 51). They are corrected in `f3bc62099`. The union was also all green on `f3bc62099`, and the run above is on the current head. - Also run, outside the derived set: `check-changeset-fixed`, `check:meta-url-spelling`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity` (rosters in a touched directory), `check-nul-bytes`, `check-platform-checklist --changed-from origin/main` and `check:stack-collection-maps`: exit 0 each. - The families CI runs (Test Core, Build Core, Build Docs, the type-check lanes and the rest) are CI's and are cited from this PR's check-runs, not re-run here. ## Dogfood battery On head `86330c090`, after rebuilding the dogfood closure, `pnpm --filter @objectstack/dogfood test` under the shared lock: `VERDICT command-exit 0` (lock held 1585s). Test Files 287 passed, 3 skipped (290). Tests 2398 passed, 18 skipped (2416). No failure. The same counts held on `f3bc62099`, where the dogfood fixture this PR edits, `email-template-materialization`, also passed on its own (4 of 4). ## Acceptance notes - Four plugin-email renderer tests (`plain-text-faces.test.ts`, `send-template.test.ts`, and two rows of `template-loader.test.ts`) render bodies this rule refuses. They feed the renderer directly and never cross an authoring door, so they stay as renderer inputs. - The refusal direction of the two scan corrections in `86330c090` (a character after a tag opener that opens no tag, and a script element's escape states) is not pinned by a dedicated test in this PR. The existing class-by-class rows and the renderer parity pin pass on that head. - `runtime-gate.inert-type-writes.test.ts` said the `email_template` mapping was proved only by a string assertion; its header now says the hole placement rule's door test reads that collection too, and that the table pin is still the whole proof for `mapping`. - An `email_template` write's finding is located positionally (`emailTemplates[0].bodyHtml`), not by name. `emailTemplates` is not a context collection, so the gate never re-keys it, and the written template is the candidate's only entry. `where` carries the template's name. - The notification half of #25082 and the B3 export retirement are other cards and are not touched here. - Open PR #24947 also edits `packages/lint/src/authoring-rules.ts`, at a different entry. This branch merges cleanly with `main` at `2919ddb87` (`git merge-tree`, exit 0). --- _Generated by [Claude Code](https://claude.ai/code/session_01Fg7vNwYgyAmHMTJm8rAYsD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 69913f8 commit 9127f23

18 files changed

Lines changed: 1219 additions & 32 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/lint': patch
3+
'@objectstack/formula': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
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)
8+
9+
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)
10+
Metadata-protocol: none
11+
12+
<!-- 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). -->
13+
14+
**BREAKING** (an authoring accept set narrows), shipped as `patch` because the change is not to the metadata protocol (maintainer ruling A on #25105).
15+
16+
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.
17+
18+
- **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`.
19+
- **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.
20+
- **FROM:** such a template passed `os build`, `os validate` and `os lint`, and an active `email_template` write answered 200.
21+
- **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.
22+
- **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.
23+
- **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.
24+
- **`@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.
25+
- **`@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.
26+
- **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.

‎content/docs/automation/email-templates.mdx‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,26 @@ never needs triple braces.
7474

7575
- `{{path.to.value}}` — dotted-path lookup against the send's `data` object,
7676
**HTML-escaped in `bodyHtml`**, verbatim in `subject` and `bodyText`.
77-
- `{{{path.to.value}}}` — the same value, *not* escaped even in `bodyHtml`. Use
78-
it only for pre-rendered HTML fragments such as a URL you are dropping into
79-
`href`.
77+
- `{{{path.to.value}}}` — the same value, *not* escaped even in `bodyHtml`. It is
78+
admitted in exactly one place: a variable declared with `type: 'url'`, written
79+
as the whole value of a double-quoted `href` (`<a href="{{{reset_url}}}">`).
8080
- `{{ order.total | currency:EUR }}` / `{{ ts | datetime }}` — an optional
8181
formatter from the shared formula whitelist, so money and dates render the
8282
same way they do in-app. `datetime` honours the reference timezone the caller
8383
passes; calendar-day `date` values are timezone-naive.
8484

85+
**Where a hole may sit in `bodyHtml`.** The renderer's escaping is right for
86+
element text and for a quoted attribute value that holds plain text, so those
87+
are the supported placements for `{{path}}`. A hole anywhere else — inside a
88+
`script` or `style` element, a comment, a tag outside any attribute value, an
89+
unquoted attribute value, an attribute read as a URL (`href`, `src`, …), an
90+
event handler, a style or markup, or SVG or MathML content — is refused by `os validate`, `os build`,
91+
`os lint` and the metadata publish door, and so is a `{{{path}}}` hole outside
92+
the `href` shape above. The refusal (`email-template-hole-placement`, a
93+
`422 INVALID_METADATA` at the publish door) names the placement and the
94+
supported ones. A template already stored keeps sending; it is judged the next
95+
time it is published.
96+
8597
Two properties of the renderer to author around:
8698

8799
- **A missing placeholder renders as an empty string.** Rendering never throws.
@@ -118,15 +130,17 @@ export const passwordResetEn = defineEmailTemplateDefinition({
118130
label: 'Password Reset',
119131
locale: 'en-US',
120132
subject: 'Reset your password',
121-
bodyHtml: '<p>Use {{{reset_url}}} within {{ttl_minutes}} minutes.</p>',
133+
bodyHtml: '<p>Use <a href="{{{reset_url}}}">this link</a> within {{ttl_minutes}} minutes.</p>',
134+
variables: [{ name: 'reset_url', type: 'url', required: true }],
122135
});
123136

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

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,7 +758,7 @@ os compile --json # JSON output for CI pipelines
758758
→ Normalizing stack definition...
759759
→ Lowering inline handlers...
760760
→ Validating protocol compliance...
761-
→ Running author-time rules (50)...
761+
→ Running author-time rules (51)...
762762
→ Checking that every required capability has a provider installable in this edition...
763763
→ Collecting package docs (ADR-0046)... 0 collected
764764
→ Writing artifact...

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@ orthogonal to both, and no cell here can carry it; it is written out in
481481
| Platform-schedule `create_record` organization (#6285) | — | — | — | ✓ᶠ |
482482
| Public-form anonymous intake on this deployment's tenancy posture — advisory only (#21476) | — | — | — | ✓ᵛ |
483483
| Autonumber `{field}` interpolation | ✓ | ✓ | ✓ | ✓ᵒ |
484+
| Email template HTML hole placements — a hole in a placement the mail renderer's encoding does not suit (#25189) | ✓ | ✓ | ✓ | ✓ᵉ |
484485
| View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | — |
485486
| Flow authoring anti-patterns (#1874) | ✓ | ✓ | ✓ | ✓ᶠ |
486487
| Flow trigger readiness — a flow that looks armed and never launches (#5762) | ✓ | ✓ | ✓ | ✓ᶠ |
@@ -495,19 +496,21 @@ orthogonal to both, and no cell here can carry it; it is written out in
495496
**Every superscript in the `runtime publish` column is one metadata type whose
496497
writes that rule inspects: `✓ᶠ` `flow`, `✓ᵛ` `view`, `✓ᵈ` `dashboard`, `✓ᵒ`
497498
`object`, `✓ᵖ` `page`, `✓ˢ` `seed`, `✓ᵇ` `book`, `✓ᵖᵉ` `permission`,
498-
`✓ᵖᵒ` `position`, `✓ᵃ` `app`** — the two `ᵖ`-initial pairs carry a second
499-
letter because `page` already holds `ᵖ` on its own. Read the codes
500-
**longest-first**: `ᵖᵒ` is `position`, never `page` followed by `object`. A cell
501-
lists one superscript per declared type, in the order the rule declares them, and that
499+
`✓ᵖᵒ` `position`, `✓ᵃ` `app`, `✓ᵉ` `email_template`** — the two
500+
`ᵖ`-initial pairs carry a second letter because `page` already holds `ᵖ` on its
501+
own. Read the codes **longest-first**: `ᵖᵒ` is `position`, never `page`
502+
followed by `object`, and `ᵖᵉ` is `permission`, never `page` followed by
503+
`email_template`. A cell lists one superscript per declared type, in the order the rule declares them, and that
502504
rule's own `runtimeTypes` in `AUTHORING_RULES` is the authority — the set has
503505
grown a type at a time (#4463 shipped P1 as `flow` and four rule families, #7220
504506
moved the whole `views[]` visibility-predicate family across in one edit, #7529
505507
put widget-binding integrity on `dashboard`, #8307 → #8310 walked the ADR-0090
506508
security-posture block across `seed`, then `permission` and `book`, then
507509
`object`, #4716 crossed the five remaining **gating** object rules —
508510
functional completeness, managed API methods, autonumber formats, and both
509-
validation-rule enforceability checks — onto `object`, and #19370 took the
510-
vocabulary freeze across all five of its own write types at once), so read the
511+
validation-rule enforceability checks — onto `object`, #19370 took the
512+
vocabulary freeze across all five of its own write types at once, and #25189
513+
put the email template hole placement rule on `email_template`), so read the
511514
rule rather than assuming a save of some other type reaches storage unjudged.
512515

513516
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:
732735
Config: /path/to/support-desk/objectstack.config.ts
733736
Load time: 21ms
734737
→ Validating against ObjectStack Protocol...
735-
→ Running author-time rules (50)...
738+
→ Running author-time rules (51)...
736739
→ Checking that every required capability has a provider installable in this edition...
737740
→ Checking package docs (ADR-0046)...
738741

‎content/docs/getting-started/build-with-claude-code.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ visible: 'status != "resolved"'
305305
◆ Validate
306306
────────────────────────────────────────
307307
→ Validating against ObjectStack Protocol...
308-
→ Running author-time rules (50)...
308+
→ Running author-time rules (51)...
309309
310310
✗ Author-time rules failed (1 issue)
311311
• stack · action 'resolve_ticket' visible: bare reference `status` — a

‎content/docs/ui/react-pages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ objectstack validate
390390
────────────────────────────────────────
391391
→ Loading configuration...
392392
→ Validating against ObjectStack Protocol...
393-
→ Running author-time rules (50)...
393+
→ Running author-time rules (51)...
394394
→ Checking that every required capability has a provider installable in this edition...
395395
→ Checking package docs (ADR-0046)...
396396

‎packages/formula/src/index.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ export type {
6464
} from './cel-engine';
6565
export { cronEngine } from './cron-engine';
6666
export { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-engine';
67+
// #25189 — where a hole may sit in a mail template's HTML face (ruling
68+
// 6335348115 on #25082, F1 A2 / F3 C2). Here because both readers already
69+
// depend on this package and on nothing else they share: `@objectstack/lint`'s
70+
// authoring rule, and `@objectstack/plugin-email`'s built-in template pin,
71+
// which also holds this grammar to the renderer's.
72+
export {
73+
judgeMailHtmlHoles,
74+
MAIL_HTML_SUPPORTED_PLACEMENTS,
75+
MAIL_HTML_PLACEMENT_LABELS,
76+
MAIL_HTML_SUPPORTED_SHAPES,
77+
} from './mail-html-hole-placement';
78+
export type { MailHtmlHole, MailHtmlHolePlacement, MailHtmlHoleJudgeOptions } from './mail-html-hole-placement';
6779
export { registerStdLib, buildScope, registerPermissionPredicate, registerCapabilityPredicate } from './stdlib';
6880
export type { PermissionBinding } from './stdlib';
6981
// objectui#4421 / batch #147 — the permission predicate's data door. `can` reads
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25189 — the mail HTML hole placement judge, one placement class at a time.
5+
*
6+
* Each case names the class it asserts; the bodies carry holes and markup
7+
* only, never a value. The judge's grammar parity with the renderer is pinned
8+
* in `plugin-email` (`text-field-html-outlets.pin.test.ts`), where the
9+
* renderer is.
10+
*/
11+
import { describe, expect, it } from 'vitest';
12+
13+
import {
14+
judgeMailHtmlHoles,
15+
MAIL_HTML_PLACEMENT_LABELS,
16+
MAIL_HTML_SUPPORTED_PLACEMENTS,
17+
type MailHtmlHolePlacement,
18+
} from './mail-html-hole-placement';
19+
20+
const one = (html: string, urlVariables: string[] = []) => {
21+
const holes = judgeMailHtmlHoles(html, { urlVariables });
22+
expect(holes, html).toHaveLength(1);
23+
return holes[0];
24+
};
25+
26+
describe('the supported placements', () => {
27+
it.each([
28+
['<p>{{ who }}</p>', 'element-text'],
29+
['{{who}}', 'element-text'],
30+
['<table><tr><td>{{ who | upper }}</td></tr></table>', 'element-text'],
31+
['<title>{{who}}</title>', 'element-text'],
32+
['<textarea>{{who}}</textarea>', 'element-text'],
33+
['<p title="{{who}}">x</p>', 'quoted-attribute'],
34+
["<p title='{{who}}'>x</p>", 'quoted-attribute'],
35+
['<td width="{{w}}" class="a {{c}} b">x</td>', 'quoted-attribute'],
36+
] as const)('%s → %s, supported', (html, placement) => {
37+
for (const hole of judgeMailHtmlHoles(html)) {
38+
expect(hole.placement).toBe(placement);
39+
expect(hole.supported).toBe(true);
40+
expect(hole.raw).toBe(false);
41+
}
42+
});
43+
44+
it('a triple-braced hole is supported as a declared url variable forming the whole double-quoted href', () => {
45+
for (const html of ['<a href="{{{url}}}">go</a>', '<a HREF="{{{ url }}}">go</a>', '<a class="b" href = "{{{url}}}">go</a>']) {
46+
const hole = one(html, ['url']);
47+
expect(hole).toMatchObject({ raw: true, placement: 'url-attribute', supported: true, path: 'url' });
48+
}
49+
});
50+
51+
it('the supported list is the two encoded placements', () => {
52+
expect([...MAIL_HTML_SUPPORTED_PLACEMENTS]).toEqual(['element-text', 'quoted-attribute']);
53+
});
54+
});
55+
56+
describe('the refused placements, by class', () => {
57+
it.each([
58+
['<script>{{x}}</script>', 'raw-text-element'],
59+
['<style>p { color: {{x}} }</style>', 'raw-text-element'],
60+
['<noscript>{{x}}</noscript>', 'raw-text-element'],
61+
['<plaintext>{{x}}', 'raw-text-element'],
62+
['<!-- {{x}} -->', 'comment'],
63+
['<!--[if mso]><p>{{x}}</p><![endif]-->', 'comment'],
64+
['<!DOCTYPE {{x}}>', 'comment'],
65+
['<? {{x}} ?>', 'comment'],
66+
['<td width={{x}}>a</td>', 'unquoted-attribute'],
67+
['<td width=1{{x}}>a</td>', 'unquoted-attribute'],
68+
['<p {{x}}>a</p>', 'tag'],
69+
['<{{x}}>', 'tag'],
70+
['</{{x}}>', 'tag'],
71+
['<p title="a"{{x}}>a</p>', 'tag'],
72+
['<p data-{{x}}="1">a</p>', 'tag'],
73+
['<a href="{{x}}">a</a>', 'url-attribute'],
74+
['<a href="https://example.test/{{x}}">a</a>', 'url-attribute'],
75+
['<img src="{{x}}">', 'url-attribute'],
76+
['<form action="{{x}}"></form>', 'url-attribute'],
77+
['<td background="{{x}}">a</td>', 'url-attribute'],
78+
['<a xlink:href="{{x}}">a</a>', 'url-attribute'],
79+
['<meta http-equiv="refresh" content="{{x}}">', 'url-attribute'],
80+
['<p onclick="{{x}}">a</p>', 'event-handler-attribute'],
81+
['<p style="color: {{x}}">a</p>', 'style-attribute'],
82+
['<iframe srcdoc="{{x}}"></iframe>', 'markup-attribute'],
83+
['<svg><text>{{x}}</text></svg>', 'foreign-content'],
84+
['<svg width="{{x}}"></svg>', 'foreign-content'],
85+
['<math><mi>{{x}}</mi></math>', 'foreign-content'],
86+
] as const)('%s → %s, refused', (html, placement) => {
87+
const hole = one(html);
88+
expect(hole.placement).toBe(placement);
89+
expect(hole.supported).toBe(false);
90+
});
91+
92+
it('a triple-braced hole anywhere but the href shape is refused', () => {
93+
const refused: Array<[string, string[]]> = [
94+
['<p>{{{url}}}</p>', ['url']],
95+
['<p title="{{{url}}}">a</p>', ['url']],
96+
['<a href="{{{url}}}">a</a>', []],
97+
['<a href="{{{other}}}">a</a>', ['url']],
98+
['<a href="{{{ url | upper }}}">a</a>', ['url']],
99+
["<a href='{{{url}}}'>a</a>", ['url']],
100+
['<a href="{{{url}}}/next">a</a>', ['url']],
101+
['<a href="https://example.test/{{{url}}}">a</a>', ['url']],
102+
['<a href={{{url}}}>a</a>', ['url']],
103+
['<img src="{{{url}}}">', ['url']],
104+
['<a data-href="{{{url}}}">a</a>', ['url']],
105+
['<svg><a href="{{{url}}}">a</a></svg>', ['url']],
106+
];
107+
for (const [html, urlVariables] of refused) {
108+
const hole = one(html, urlVariables);
109+
expect(hole.raw, html).toBe(true);
110+
expect(hole.supported, html).toBe(false);
111+
}
112+
});
113+
114+
it('a hole after a closed raw-text element, comment or foreign root is judged by the markup that follows', () => {
115+
const holes = judgeMailHtmlHoles(
116+
'<style>p{}</style><p>{{a}}</p><!-- c --><b>{{b}}</b><svg></svg><i>{{c}}</i><script></SCRIPT ><u>{{d}}</u>',
117+
);
118+
expect(holes.map((h) => [h.path, h.placement, h.supported])).toEqual([
119+
['a', 'element-text', true],
120+
['b', 'element-text', true],
121+
['c', 'element-text', true],
122+
['d', 'element-text', true],
123+
]);
124+
});
125+
126+
it('text inside a raw-text element that only looks like a closing tag does not close it', () => {
127+
expect(one('<script></scriptx>{{x}}</script>').placement).toBe('raw-text-element');
128+
expect(one('<style></style-x>{{x}}</style>').placement).toBe('raw-text-element');
129+
});
130+
131+
it('an abruptly closed comment ends where the parser ends it', () => {
132+
expect(one('<!--><p>{{x}}</p>').placement).toBe('element-text');
133+
expect(one('<!---><p>{{x}}</p>').placement).toBe('element-text');
134+
expect(one('<!-- a --!><p>{{x}}</p>').placement).toBe('element-text');
135+
});
136+
137+
it('every refused class has an author-facing label', () => {
138+
const classes: MailHtmlHolePlacement[] = [
139+
'element-text', 'quoted-attribute', 'url-attribute', 'event-handler-attribute', 'style-attribute',
140+
'markup-attribute', 'unquoted-attribute', 'tag', 'raw-text-element', 'comment', 'foreign-content',
141+
];
142+
expect(Object.keys(MAIL_HTML_PLACEMENT_LABELS).sort()).toEqual([...classes].sort());
143+
});
144+
});
145+
146+
describe('what is and is not a hole — the renderer\'s grammar', () => {
147+
it('text the renderer leaves verbatim is markup, not a hole', () => {
148+
expect(judgeMailHtmlHoles('<p>{{ not a path }}</p>')).toEqual([]);
149+
expect(judgeMailHtmlHoles('<p>{x}</p>')).toEqual([]);
150+
expect(judgeMailHtmlHoles('')).toEqual([]);
151+
});
152+
153+
it('a verbatim brace pair still moves the markup around it', () => {
154+
// The pair is not a hole, so the renderer leaves it in the output — and the
155+
// tag it opens there is where the hole after it sits.
156+
const holes = judgeMailHtmlHoles('<p>{{ <b }}{{x}}</p>');
157+
expect(holes).toHaveLength(1);
158+
expect(holes[0]).toMatchObject({ path: 'x', placement: 'tag', supported: false });
159+
});
160+
161+
it('a hole\'s span, braces included, and its form', () => {
162+
expect(judgeMailHtmlHoles('a {{ x.y | currency:EUR }} b {{{z}}} c {{{w}}')).toEqual([
163+
expect.objectContaining({ start: 2, text: '{{ x.y | currency:EUR }}', path: 'x.y', raw: false }),
164+
expect.objectContaining({ text: '{{{z}}}', path: 'z', raw: true }),
165+
expect.objectContaining({ text: '{{{w}}', path: 'w', raw: false }),
166+
]);
167+
});
168+
});

0 commit comments

Comments
 (0)