Skip to content

Commit df3ba16

Browse files
fix(plugin-email): the subject and body_text faces render without HTML escaping, so a text-part link keeps its literal & (#20392)
Fixes #20374 Clause-②: no ## What was wrong `EmailService` rendered every face of a `sys_email_template` row through the HTML escaper (`escapeHtml` in `template-engine.ts`), but only `body_html` is markup. Measured on `c577e666` through the real `EmailService` over the seeded `BUILTIN_AUTH_TEMPLATES` rows, the text part of the verify, reset, invitation and magic-link mails read `…?token=abc&amp;callbackURL=%2F`. A plain-text client, or a user copying the link, gets a parameter named `amp;callbackURL`, and the post-verification redirect falls back to `/`. ## What changes - **`template-engine.ts`**: one internal `render()` with a per-face hole encoder. - `renderTemplate` (the HTML face, and the helper the package exports) keeps `escapeHtml` for `{{x}}` and verbatim for `{{{x}}}`, exactly as before. - The new `renderPlainTextTemplate` (the plain-text face) renders every hole verbatim, double or triple braced, formatter output included. It is exported from the module only. The package entry does not re-export it, so the public surface is unchanged: the built `dist/index.d.ts` names it 0 times, and the built CJS `renderTemplate` still answers `a&amp;b`. - **`email-service.ts`** `resolveAndRenderTemplate`: this is the ONE render entry behind both `sendTemplate` and `IEmailService.renderTemplate`. `subject` and `body_text` now go through the plain-text renderer, and `body_html` through the HTML renderer. The derived fallback is unchanged and was already right: when a row has no `body_text`, the text is `htmlToText(html)`, which decodes the entities. - **No template edit.** Nothing in `templates/auth-templates.ts` changes. This follows the triage direction: the engine switch covers every row that reaches the renderer, whether built-in, declared or authored in Studio. ### Also fixed in place: the subject (same defect class) The subject is a plain-text face too (the mail Subject header, `sys_email.subject`, and the inbox title). On `c577e666` it rendered `Verify your R&amp;D email address` and `O&#39;Brien invited you to R&amp;D`. It passes all four bounded in-place conditions: - it is this card's defect class, a plain-text face run through the HTML escaper; - the fix is mechanical, and its shape is already set: the same `renderPlainTextTemplate` call; - it is the same line of the same render entry, which the claim's file surface already names (`email-service.ts`, where the text body is rendered); - it uses the same test family and adds no verification surface. Evidence: the subject pins below, red under ablation A2. ### One file outside the claim's file surface: a doc sentence this change made false `content/docs/automation/email-templates.mdx` said that `{{path}}` is **HTML-escaped** in "Subject and both bodies". After this change that is false for `subject` and `bodyText`. The page now says that escaping follows the face. `.claude/agents/os-dev.md` requires fixing a published statement that a change makes false, while the claim's surface names only `packages/plugins/plugin-email/src/` and the changeset. The report calls out this conflict. No other doc or skill describes template escaping (a `git grep` for escaping in `content/docs` and `skills`). ## Measurements behind the dispatch's mechanism assumptions 1. **Call site.** - `template-engine.ts` has a single render function; the `{{`/`{{{` switch was at `:103` / `:120` and `escapeHtml` at `:78`. - `ENTITIES` / `decodeEntities` are used only by `htmlToText`, the derived-fallback path. The declared `body_text` path called no decode and had no text mode. - Today `body_text` holds `&amp;` for a URL carrying an ampersand, `&lt;` for a value carrying a less-than sign and `&#39;` for an apostrophe (measured, all four link templates). 2. **One switch covers every template.** `git grep` finds exactly one non-test caller of the engine's `renderTemplate` in the repo: `resolveAndRenderTemplate`. - Built-in auth rows, declared `emailTemplates` and Studio-authored rows all reach it through `sendTemplate` or `IEmailService.renderTemplate`. - The messaging inbox channel is the one non-email consumer. It reads `IEmailService.renderTemplate` and stores `subject` as the title and `text` as `body_md`. - Every call site is covered; none is left out. 3. **Nothing relies on escaped text.** - No test expected entities in a text body. The positive control: the grep does find the HTML-face expectations in `template-engine.test.ts`. - `template-locale-resolution.test.ts` wrapped its subject expectations in an `esc()` mirror of the escaper. That was vacuous on its Intl date values, which carry no escapable character, but it stated the old belief, so it is rewritten: subjects are now compared unescaped, and `html` expectations keep `esc()`. 4. **Invitation email.** `auth.invitation` lives in this package (`templates/auth-templates.ts`, in en-US, zh-CN, ja-JP and es-ES). `plugin-auth` `auth-manager.ts` sends it through `sendTemplate`, so the engine fix covers it with no edit outside the file surface. It is pinned below. ## Tests New `src/plain-text-faces.test.ts` (36 cases) runs the real `EmailService` over the real seeded rows, plus one authored row that nobody hand-braced: - **The card's acceptance, per template and locale.** For verify, reset, invitation and magic link in 4 locales, the persisted `sys_email.body_text` and the delivered text part contain the link with a literal ampersand and no `&amp;`. - **The HTML part is unchanged, per template and locale.** The `href` carries the link verbatim, and the visible copy-paste span is still the escaped markup. - **Control, per face.** A value carrying a less-than sign and an ampersand renders escaped in `body_html` and literally in `body_text` and the subject. This holds for a built-in template, for an authored template, for the render-only `renderTemplate` the inbox reads, and for a row with no `body_text` (the derived fallback, with entities decoded). `src/template-engine.test.ts`: 6 new `renderPlainTextTemplate` cases, including a control that runs the same inputs through `renderTemplate` and gets them escaped. Results at `eb169bd4` for `@objectstack/plugin-email`: - `pnpm test`: 31 files and 510 tests passed. It ran before the ablations and again after them, from the committed tree. - `typecheck`: `tsc --noEmit` exits 0, and `check:test-typecheck` reports 0 errors. **HTML part byte-identity.** One-time proof, not kept as a test. BASE `c577e666` `renderTemplate` and HEAD `renderTemplate` rendered every built-in `bodyHtml` (24), plus 2 hole-shape extras, with hostile data (apostrophe, quotes, ampersand, markup). 0 of 26 differ. The positive control shows the comparison can see a change: 24 of 24 `bodyText` renders differ between BASE HTML mode and HEAD text mode. ## Ablation: every negative pin, one-time, not committed Each leg ran through `scripts/ablation-replace.mjs` in WRAP mode on the committed fix at `eb169bd4`. The anchor hit 1 of 1 each time, and the blob changed on disk. The tests import `./email-service.js` and `./template-engine.js` relatively, so vitest runs `src/` and no `dist/` is in the resolution path. | leg | mutation | result | |---|---|---| | A1 | `body_text` back to `renderTemplate` in `email-service.ts` | **19 failed** / 48 passed | | A2 | `subject` back to `renderTemplate` | **3 failed** / 64 passed | | A3 | the plain-text encoder made to escape (`template-engine.ts`) | **23 failed** / 44 passed | - **A1** broke every text-part pin, the three control text pins and the render-only pin. For example, for `auth.verify_email [en-US]` it received `…?token=TOK123&amp;callbackURL=…`. The 16 HTML-unchanged pins and the fallback pin stayed green, as expected. - **A2** broke the three subject pins; the received value was `O&#39;Brien invited you to R&amp;D &lt;Lab&gt;`. - **A3** broke the 4 engine verbatim cases and every service pin above. Every restore is proven: the blob equals HEAD (`e5a222f83fb1` for `email-service.ts`, `86d358762d50` for `template-engine.ts`), `git diff HEAD` is empty and `git status` is clean. ## Gates (HEAD `eb169bd4`) `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` was run with no paths: 7 paths against merge base `c577e6663`, 90 commands. The dispatch-time list had 61 commands; the extra ones are the docs families that the `content/docs` edit adds. Each command was run with its exit code captured right after a single redirect. The `--ran` verdict: **90 accounted for: 88 run, 2 NOT MEASURED, 0 UNRUN**. - `check:skill-examples` first exited 3, because `@objectstack/client-react` was unbuilt. After building the `@objectstack/client-react...` closure it exited 0: 259 examples type-check. - **NOT MEASURED: `check:dual-build-cjs-loads`.** Exit 3: 51 packages have no `dist/`, and the gate needs a whole-repo `pnpm build`. Declared narrowing: the diff touches no `package.json`, tsup config or `exports`, so only this package's `dist` bytes move. On plugin-email's build, CJS `require` and ESM `import` both load, each with 93 exports and an identical key set. - **NOT MEASURED: `check:type-check-debt`.** Exit 3: 16 dependencies of ledgered packages are unbuilt. Declared narrowing: - The DEBT ledger has 4 entries: `cloud-connection`, `hono`, `observability` and the workspace root. - None of those three packages depends on plugin-email, and the root program is `scripts/` plus top-level configs, which this diff does not touch. - plugin-email itself is covered: `tsc --noEmit` exits 0 and its test layer has 0 errors. - **Lint, narrowed.** - `eslint --no-inline-config --format json` on the 5 touched `.ts` files: 5 files, 0 errors, 0 warnings. - The population comes from eslint's own config: each file resolves a config through `--print-config` (exit 0) and none is reported ignored. - Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules, as its own comment at `:326-328` states), so this diff cannot move the verdict on any untouched file. - `origin/main` moved 6 commits since the base. None of them touches plugin-email, the doc page, service-messaging or the spec email contract (empty diffstat). ## Acceptance notes - **Inbox consumer.** The messaging inbox channel stores `rendered.text` as `body_md`, so an authored value carrying markup characters now reaches that markdown field raw instead of entity-encoded. This is not a new exposure class: a row with no `body_text` already delivered raw text through the `htmlToText` fallback, and the contract (`RenderTemplateResult.text`, "rendered plain-text body") already said plain text. How the console renders `body_md` (raw HTML allowed or not) is NOT MEASURED, because no objectui checkout is in this container. Whoever next touches the inbox renderer owns that question. Owner: none. - **Related but separate:** objectstack-ai/objectui#10893. Sign-up passes no `callbackURL`, which is a different repo and a different mechanism. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 681868c commit df3ba16

7 files changed

Lines changed: 332 additions & 21 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-email": patch
3+
---
4+
5+
A template's plain-text faces — the subject and `body_text` — now render their `{{x}}` values verbatim instead of HTML-escaping them, so a link in the text part keeps its literal `&` (#20374).
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** `EmailService` rendered every face of a `sys_email_template` row through the HTML escaper. In the text/plain part of the built-in verification, password-reset, invitation and magic-link mails the link read `…?token=…&amp;callbackURL=%2F`: a plain-text client, or a user copying the link, got a parameter named `amp;callbackURL`, and the post-verification redirect fell back to `/`. The same escaping put `&amp;` / `&#39;` into subjects built from names such as `R&D` or `O'Brien`.
10+
- **What changes.** Escaping now follows the face. `body_html` is markup and is rendered exactly as before: `{{x}}` HTML-escaped, `{{{x}}}` not. The subject and `body_text` are plain text: every hole renders its value as-is, and triple braces mean the same as double there. The switch is in the renderer, so it covers every row that reaches `sendTemplate` / `renderTemplate` — the built-in auth templates, declared `emailTemplates` and rows authored in Studio — with no template edit.
11+
- **Who sees it.** The persisted `sys_email.body_text` and `subject`, the delivered text part and Subject header, and `IEmailService.renderTemplate()`'s `text` / `subject` (which the messaging inbox channel stores as a notification's body and title). A row with no `body_text` is unchanged: its text part was already derived from the HTML with the entities decoded.
12+
- **Unchanged.** The exported `renderTemplate()` helper is still the HTML renderer. Nothing an author writes needs to change.

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,16 @@ rather than silently sending nothing.
6464
## Placeholders
6565

6666
Subject and both bodies are rendered by a deliberately tiny mustache-style
67-
renderer:
67+
renderer. Escaping follows the face: only `bodyHtml` is markup, so only
68+
`bodyHtml` HTML-escapes its values. `subject` and `bodyText` are plain text and
69+
render every value verbatim — an `&` in a link stays `&`, so the plain-text part
70+
never needs triple braces.
6871

6972
- `{{path.to.value}}` — dotted-path lookup against the send's `data` object,
70-
**HTML-escaped**.
71-
- `{{{path.to.value}}}` — the same value, *not* escaped. Use it only for
72-
pre-rendered HTML fragments such as a URL you are dropping into `href`.
73+
**HTML-escaped in `bodyHtml`**, verbatim in `subject` and `bodyText`.
74+
- `{{{path.to.value}}}` — the same value, *not* escaped even in `bodyHtml`. Use
75+
it only for pre-rendered HTML fragments such as a URL you are dropping into
76+
`href`.
7377
- `{{ order.total | currency:EUR }}` / `{{ ts | datetime }}` — an optional
7478
formatter from the shared formula whitelist, so money and dates render the
7579
same way they do in-app. `datetime` honours the reference timezone the caller

‎packages/plugins/plugin-email/src/email-service.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
IQueueService,
1616
QueueBackoffPolicy,
1717
} from '@objectstack/spec/contracts';
18-
import { renderTemplate, requireVars, htmlToText } from './template-engine.js';
18+
import { renderTemplate, renderPlainTextTemplate, requireVars, htmlToText } from './template-engine.js';
1919
import {
2020
SYS_EMAIL_ATTACHMENT_LIMIT_BYTES,
2121
encodeAttachmentsForRow,
@@ -1375,10 +1375,18 @@ export class EmailService implements IEmailService {
13751375
...(locale ? { locale } : {}),
13761376
...(input.timezone ? { timeZone: input.timezone } : {}),
13771377
};
1378-
const subject = renderTemplate(row.subject, data, renderOpts);
1378+
// Each face is rendered in ITS OWN encoding. Only `body_html` is markup,
1379+
// so only it HTML-escapes its `{{x}}` holes. The subject (a mail header,
1380+
// an inbox title) and `body_text` (the plain-text part, an inbox body) are
1381+
// plain text: escaping there put `&amp;` into every link carrying a
1382+
// query string — a plain-text reader, or anyone copying the link, got a
1383+
// parameter named `amp;callbackURL` and lost the post-verification
1384+
// redirect. The derived fallback was already right: `htmlToText` decodes
1385+
// the entities the HTML render introduced.
1386+
const subject = renderPlainTextTemplate(row.subject, data, renderOpts);
13791387
const html = renderTemplate(row.body_html, data, renderOpts);
13801388
const text = row.body_text
1381-
? renderTemplate(row.body_text, data, renderOpts)
1389+
? renderPlainTextTemplate(row.body_text, data, renderOpts)
13821390
: htmlToText(html);
13831391

13841392
return { row, rendered: { subject, html, text } };
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The plain-text faces of a template render WITHOUT HTML escaping (#20374).
5+
*
6+
* A template has three faces and only one of them is markup: `body_html`.
7+
* The subject (a mail header, an inbox title) and `body_text` (the text/plain
8+
* part, an inbox body) are plain text. Rendering them through the HTML
9+
* escaper put `&amp;` into every link carrying a query string, so a
10+
* plain-text client — or a user copying the text link — received
11+
* `…?token=…&amp;callbackURL=%2F`: the token still parsed, the redirect
12+
* parameter arrived as `amp;callbackURL`, and a verified invitee landed on
13+
* `/` instead of the accept page.
14+
*
15+
* The switch lives in the engine (`renderPlainTextTemplate`), not in the
16+
* templates, so these pins run the REAL `EmailService` over the REAL seeded
17+
* built-in rows AND over an authored row nobody hand-braced — a per-template
18+
* `{{{…}}}` patch would pass the first half and fail the second.
19+
*
20+
* ⚠️ Every expectation is an independent literal: nothing is derived from the
21+
* template constants or from the renderer under test, so an edit that
22+
* reintroduces escaping cannot quietly agree with itself.
23+
*/
24+
25+
import { describe, it, expect } from 'vitest';
26+
import { EmailService, type EmailTemplateRow, type TemplateLoader } from './email-service.js';
27+
import { BUILTIN_AUTH_TEMPLATES } from './templates/auth-templates.js';
28+
import type {
29+
IEmailTransport,
30+
NormalizedEmailMessage,
31+
TransportSendResult,
32+
} from '@objectstack/spec/contracts';
33+
34+
const LOCALES = ['en-US', 'zh-CN', 'ja-JP', 'es-ES'] as const;
35+
36+
/** A link whose second query parameter is the one the defect dropped. */
37+
const LINK = 'https://acme.test/api/v1/auth/verify-email?token=TOK123&callbackURL=%2Faccept-invitation%2Finv_1';
38+
/** The same link as HTML markup spells it — correct inside `body_html` only. */
39+
const LINK_AS_MARKUP = 'https://acme.test/api/v1/auth/verify-email?token=TOK123&amp;callbackURL=%2Faccept-invitation%2Finv_1';
40+
41+
class CaptureTransport implements IEmailTransport {
42+
public sent: NormalizedEmailMessage[] = [];
43+
async send(message: NormalizedEmailMessage): Promise<TransportSendResult> {
44+
this.sent.push(message);
45+
return { messageId: `msg-${this.sent.length}` };
46+
}
47+
}
48+
49+
/** Exactly what `EmailServicePlugin` seeds, matched on `(name, locale)` with no fallback of its own. */
50+
function seededRow(name: string, locale: string): EmailTemplateRow | null {
51+
const hit = BUILTIN_AUTH_TEMPLATES.find((t) => t.name === name && t.locale === locale);
52+
if (!hit) return null;
53+
return {
54+
name: hit.name,
55+
locale: hit.locale ?? 'en-US',
56+
subject: hit.subject,
57+
body_html: hit.bodyHtml ?? '',
58+
body_text: hit.bodyText ?? null,
59+
active: hit.active !== false,
60+
variables_json: JSON.stringify(hit.variables ?? []),
61+
};
62+
}
63+
64+
function harness(extraRows: EmailTemplateRow[] = []) {
65+
const transport = new CaptureTransport();
66+
const rows: Array<Record<string, any>> = [];
67+
const loader: TemplateLoader = {
68+
async load(name, locale) {
69+
const authored = extraRows.find((r) => r.name === name && (locale === undefined || r.locale === locale));
70+
if (authored) return authored;
71+
return locale === undefined ? null : seededRow(name, locale);
72+
},
73+
};
74+
const svc = new EmailService({
75+
transport,
76+
defaultFrom: { address: 'no-reply@acme.test' },
77+
templateLoader: loader,
78+
persistence: {
79+
async insert(row) { rows.push(row); return { id: row.id }; },
80+
async update() { /* noop */ },
81+
},
82+
});
83+
return { svc, transport, rows };
84+
}
85+
86+
const USER = { name: 'Alice', email: 'alice@acme.test', id: 'usr_1' };
87+
88+
/** The three link-carrying sends the card names, plus the magic link that shares the shape. */
89+
const LINK_SENDS = [
90+
{ template: 'auth.verify_email', data: { user: USER, verificationUrl: LINK, appName: 'Acme' } },
91+
{ template: 'auth.password_reset', data: { user: USER, resetUrl: LINK, expiresInMinutes: 30, appName: 'Acme' } },
92+
{
93+
template: 'auth.invitation',
94+
data: {
95+
inviter: { name: 'Bob', email: 'bob@acme.test' },
96+
organization: { name: 'Acme' },
97+
role: 'member',
98+
acceptUrl: LINK,
99+
appName: 'Acme',
100+
},
101+
},
102+
{ template: 'auth.magic_link', data: { magicLinkUrl: LINK, expiresInMinutes: 10, appName: 'Acme' } },
103+
] as const;
104+
105+
describe('auth mail: the plain-text part carries the link verbatim', () => {
106+
for (const { template, data } of LINK_SENDS) {
107+
for (const locale of LOCALES) {
108+
it(`${template} [${locale}]: sys_email.body_text and the text part keep a literal &`, async () => {
109+
const { svc, transport, rows } = harness();
110+
111+
await svc.sendTemplate({ template, to: 'alice@acme.test', locale, data: data as Record<string, unknown> });
112+
113+
expect(rows).toHaveLength(1);
114+
const bodyText = String(rows[0].body_text);
115+
// The persisted audit row and the delivered text/plain part are the
116+
// same string, and both carry the link a user can actually follow.
117+
expect(bodyText).toContain(LINK);
118+
expect(bodyText).not.toContain('&amp;');
119+
expect(transport.sent[0].text).toBe(bodyText);
120+
});
121+
122+
it(`${template} [${locale}]: the HTML part is unchanged — href verbatim, visible copy still escaped markup`, async () => {
123+
const { svc, transport } = harness();
124+
125+
await svc.sendTemplate({ template, to: 'alice@acme.test', locale, data: data as Record<string, unknown> });
126+
127+
const html = String(transport.sent[0].html);
128+
// `{{{url}}}` in the href was never escaped …
129+
expect(html).toContain(`href="${LINK}"`);
130+
// … and the copy-paste `{{url}}` span is still HTML-escaped, which is
131+
// correct in markup: a mail client decodes it back to `&` on display.
132+
expect(html).toContain(LINK_AS_MARKUP);
133+
});
134+
}
135+
}
136+
});
137+
138+
describe('control: a value carrying markup characters, per face', () => {
139+
it('a built-in template: < and & are escaped in body_html and literal in body_text and the subject', async () => {
140+
const { svc, transport, rows } = harness();
141+
142+
await svc.sendTemplate({
143+
template: 'auth.invitation',
144+
to: 'alice@acme.test',
145+
locale: 'en-US',
146+
data: {
147+
inviter: { name: "O'Brien", email: 'ob@acme.test' },
148+
organization: { name: 'R&D <Lab>' },
149+
role: 'member',
150+
acceptUrl: LINK,
151+
appName: 'Acme',
152+
},
153+
});
154+
155+
const sent = transport.sent[0];
156+
expect(sent.html).toContain('<strong>R&amp;D &lt;Lab&gt;</strong>');
157+
expect(sent.html).toContain('<strong>O&#39;Brien</strong>');
158+
expect(sent.text).toBe(
159+
"O'Brien (ob@acme.test) invited you to join R&D <Lab> on Acme.\n\n" + `Accept: ${LINK}`,
160+
);
161+
expect(sent.subject).toBe("O'Brien invited you to R&D <Lab>");
162+
expect(rows[0].subject).toBe("O'Brien invited you to R&D <Lab>");
163+
});
164+
165+
const AUTHORED: EmailTemplateRow = {
166+
name: 'crm.deal_won',
167+
locale: 'en-US',
168+
subject: 'Won: {{deal.name}}',
169+
body_html: '<p>Deal <b>{{deal.name}}</b> closed. <a href="{{{deal.url}}}">Open</a> {{deal.url}}</p>',
170+
body_text: 'Deal {{deal.name}} closed. Open: {{deal.url}}',
171+
active: true,
172+
};
173+
const DEAL = { deal: { name: 'Q3 <Renewal> & more', url: 'https://acme.test/deal?id=7&tab=notes' } };
174+
175+
it('an authored template (no per-template braces): escaped in body_html, literal in body_text and the subject', async () => {
176+
const { svc, transport, rows } = harness([AUTHORED]);
177+
178+
await svc.sendTemplate({ template: 'crm.deal_won', to: 'alice@acme.test', data: DEAL });
179+
180+
const sent = transport.sent[0];
181+
expect(sent.html).toBe(
182+
'<p>Deal <b>Q3 &lt;Renewal&gt; &amp; more</b> closed. '
183+
+ '<a href="https://acme.test/deal?id=7&tab=notes">Open</a> https://acme.test/deal?id=7&amp;tab=notes</p>',
184+
);
185+
expect(sent.text).toBe('Deal Q3 <Renewal> & more closed. Open: https://acme.test/deal?id=7&tab=notes');
186+
expect(sent.subject).toBe('Won: Q3 <Renewal> & more');
187+
expect(rows[0].body_text).toBe(sent.text);
188+
});
189+
190+
it('renderTemplate (the render-only face the inbox channel reads) answers the same text and subject', async () => {
191+
const { svc } = harness([AUTHORED]);
192+
193+
const out = await svc.renderTemplate({ template: 'crm.deal_won', data: DEAL });
194+
195+
expect(out.text).toBe('Deal Q3 <Renewal> & more closed. Open: https://acme.test/deal?id=7&tab=notes');
196+
expect(out.subject).toBe('Won: Q3 <Renewal> & more');
197+
expect(out.html).toContain('<b>Q3 &lt;Renewal&gt; &amp; more</b>');
198+
});
199+
200+
it('a row with NO body_text still derives its text part with the entities decoded', async () => {
201+
const { svc, transport } = harness([{ ...AUTHORED, body_text: null }]);
202+
203+
await svc.sendTemplate({ template: 'crm.deal_won', to: 'alice@acme.test', data: DEAL });
204+
205+
expect(transport.sent[0].text).toBe('Deal Q3 <Renewal> & more closed. Open https://acme.test/deal?id=7&tab=notes');
206+
});
207+
});

‎packages/plugins/plugin-email/src/template-engine.test.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,44 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { renderTemplate, requireVars, htmlToText } from './template-engine.js';
4+
import { renderTemplate, renderPlainTextTemplate, requireVars, htmlToText } from './template-engine.js';
55

66
describe('template-engine', () => {
7+
// The plain-text face (`subject`, `body_text`): no HTML escaping at all.
8+
// Expectations are literals; the last case runs the same inputs through the
9+
// HTML face, which still escapes — so the pair pins the SWITCH, not a
10+
// global change to escaping.
11+
describe('renderPlainTextTemplate', () => {
12+
it('renders a double-braced link with a literal &', () => {
13+
expect(renderPlainTextTemplate('Open: {{url}}', { url: 'https://x.test/v?token=t&callbackURL=%2F' }))
14+
.toBe('Open: https://x.test/v?token=t&callbackURL=%2F');
15+
});
16+
17+
it('renders every character the HTML face escapes verbatim', () => {
18+
expect(renderPlainTextTemplate('{{s}}', { s: `&<>"'` })).toBe(`&<>"'`);
19+
});
20+
21+
it('renders triple braces verbatim too', () => {
22+
expect(renderPlainTextTemplate('{{{s}}}', { s: 'a&b<c>' })).toBe('a&b<c>');
23+
});
24+
25+
it('renders formatted holes verbatim', () => {
26+
expect(renderPlainTextTemplate('{{ s | upper }}', { s: 'a&b' })).toBe('A&B');
27+
});
28+
29+
it('keeps the shared lookup rules: missing → empty, non-hole left verbatim, scalars stringified', () => {
30+
expect(renderPlainTextTemplate('a={{a}} b={{b}} n={{n}}', { a: 'A&', n: 3 })).toBe('a=A& b= n=3');
31+
expect(renderPlainTextTemplate('{{ not a hole }}', {})).toBe('{{ not a hole }}');
32+
expect(renderPlainTextTemplate('', { a: 1 })).toBe('');
33+
});
34+
35+
it('control: the HTML face still escapes the same inputs', () => {
36+
expect(renderTemplate('Open: {{url}}', { url: 'https://x.test/v?token=t&callbackURL=%2F' }))
37+
.toBe('Open: https://x.test/v?token=t&amp;callbackURL=%2F');
38+
expect(renderTemplate('{{ s | upper }}', { s: 'a&b' })).toBe('A&amp;B');
39+
});
40+
});
41+
742
describe('renderTemplate', () => {
843
it('substitutes dotted paths', () => {
944
expect(renderTemplate('Hi {{user.name}}', { user: { name: 'Alice' } }))

‎packages/plugins/plugin-email/src/template-engine.ts‎

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,21 @@
44
* Minimal mustache-style template renderer.
55
*
66
* Supports `{{path.to.value}}` placeholders resolved against a plain
7-
* JS object via dotted-path lookup. Values are HTML-escaped by
8-
* default; use `{{{path}}}` (triple braces) to opt out of escaping
9-
* (e.g. when injecting pre-rendered HTML fragments such as URLs in
10-
* `<a href="">`).
7+
* JS object via dotted-path lookup.
8+
*
9+
* Escaping follows the FACE being rendered, so there are two entry points:
10+
*
11+
* - {@link renderTemplate} renders an HTML face (`body_html`). Values are
12+
* HTML-escaped by default; use `{{{path}}}` (triple braces) to opt out
13+
* of escaping (e.g. when injecting pre-rendered HTML fragments such as
14+
* URLs in `<a href="">`).
15+
* - {@link renderPlainTextTemplate} renders a plain-text face (`subject`,
16+
* `body_text`). Every hole, double- or triple-braced, renders its value
17+
* verbatim: HTML entities in plain text are not markup, they are
18+
* corruption — a `&` in a link turned into `&amp;` hands a plain-text
19+
* reader a query parameter named `amp;callbackURL`. The switch lives
20+
* here, once, so every template that reaches the renderer — built-in,
21+
* declared or authored — gets it without a per-template brace change.
1122
*
1223
* A hole may carry an optional formatter from the shared formula
1324
* whitelist — `{{ order.total | currency:EUR }}`, `{{ ts | datetime }}` —
@@ -84,15 +95,45 @@ function escapeHtml(s: string): string {
8495
.replace(/'/g, '&#39;');
8596
}
8697

98+
/** How a double-braced hole's value is encoded for the face being rendered. */
99+
type HoleEncoder = (value: string) => string;
100+
101+
const verbatim: HoleEncoder = (value) => value;
102+
87103
/**
88-
* Render `template` with values from `data`. Missing placeholders
104+
* Render `template` as an HTML face with values from `data`: `{{x}}`
105+
* holes are HTML-escaped, `{{{x}}}` holes are not. Missing placeholders
89106
* render as empty strings (no throw); call `requireVars()` first if
90107
* you need strict validation.
91108
*/
92109
export function renderTemplate(
93110
template: string,
94111
data: Record<string, any>,
95112
opts: RenderOptions = {},
113+
): string {
114+
return render(template, data, opts, escapeHtml);
115+
}
116+
117+
/**
118+
* Render `template` as a PLAIN-TEXT face (`subject`, `body_text`) with
119+
* values from `data`: every hole renders its value verbatim, with no HTML
120+
* escaping — plain text is not markup, so an entity there is a corrupted
121+
* character, never a safe one. Same lookup, formatter and missing-value
122+
* rules as {@link renderTemplate}.
123+
*/
124+
export function renderPlainTextTemplate(
125+
template: string,
126+
data: Record<string, any>,
127+
opts: RenderOptions = {},
128+
): string {
129+
return render(template, data, opts, verbatim);
130+
}
131+
132+
function render(
133+
template: string,
134+
data: Record<string, any>,
135+
opts: RenderOptions,
136+
encode: HoleEncoder,
96137
): string {
97138
if (!template) return '';
98139
return template.replace(
@@ -117,7 +158,7 @@ export function renderTemplate(
117158
if (raw == null) return '';
118159
str = typeof raw === 'string' ? raw : String(raw);
119160
}
120-
return isUnescaped ? str : escapeHtml(str);
161+
return isUnescaped ? str : encode(str);
121162
},
122163
);
123164
}

0 commit comments

Comments
 (0)