Skip to content

Commit 92a0073

Browse files
fix(plugin-email)!: retire renderTemplate, the render export that takes an email template body with no save moment (#26399)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XEyF7AtCnTe94fR7XzB94BG)_ Fixes #25190 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no `skills/**` file names the retired package export, and `content/docs/**` names only `services.email`'s `renderTemplate` method (`automation/email-templates.mdx` lines 14, 224, 261), which stays; nothing teaches the removed export ## 维护者速读 - **改了什么**:`@objectstack/plugin-email` 的包入口不再导出 `renderTemplate` 函数;函数本身留在包内,邮件服务照常用它渲染已存储的邮件模板。 - **为什么改**:这个导出直接接收一段模板正文并当场渲染,没有保存环节,邮件 HTML 洞位规则(在编写/发布时判定)看不到它;退役后不再有规则看不见的渲染入口。这是本卡的第二个也是最后一个 PR(`renderNotification` 已由 #25285 退役)。 - **风险与回滚**:本仓与 hotcrm 从包入口导入它的数量为 0;外部若有直接导入者会在自己的 import 处得到编译错误,changeset 写明改用 `services.email` 的 `renderTemplate` / `sendTemplate`。回滚 = revert 本 PR。 - **先看**:`packages/plugins/plugin-email/src/index.ts`(删除的那一行导出);`.changeset/25190-retire-render-template-export.md`(BREAKING 说明与 FROM → TO);`packages/plugins/plugin-email/src/render-export-retired.pin.test.ts`。 - **两分钟验证**:`node scripts/check-adr-0087-registration.mjs --base origin/main`,预期 exit 0 且打印 `verified: @objectstack/plugin-email#renderTemplate`。 - **需要你做的**:无,席位落地。 ## What changes - `renderTemplate` leaves the published entry of `@objectstack/plugin-email` (ruling `6335348115` on #25082, F2 B3; ruling `6349343463` letter A, item 4). The module-internal function stays: `EmailService.sendTemplate` / `renderTemplate` keep rendering stored `email_template` items through it. - `requireVars` and `htmlToText` stay on the entry. `IEmailService.renderTemplate` (the spec contract method of the same name) is a different door and is untouched. `interpolate` (service-messaging) stays, per ruling `6349343463` item 2. - One `patch` changeset with the BREAKING banner, ADR-0087 `runtime-export-retired @objectstack/plugin-email#renderTemplate`, and a FROM → TO line naming the service door. No staged window. ## Tests - New `src/render-export-retired.pin.test.ts` (3 cases): the entry has no `renderTemplate`; control: `requireVars`, `htmlToText`, `createRegistryEmailTemplateLoader`, `EmailServicePlugin` are still exported; a stored template renders by name through `EmailService.renderTemplate` and `sendTemplate`, html hole encoded, subject verbatim. - Reverse check (one-off, no permanent file): with the base `index.ts` restored, the retired-name case went red (1 failed, 2 passed); restored with `git checkout HEAD`, HEAD blob hash matched. ## Acceptance notes - Restart line of the hold (`6351169534`) measured on this real head, not from the record: `check-adr-0087-registration --base origin/main` exit 0, `verified: @objectstack/plugin-email#renderTemplate (export, packages/plugins/plugin-email/src/template-engine.ts)`. - Readers swept: no import of `renderTemplate` from `@objectstack/plugin-email` anywhere in this tree (tests, `ui/`, examples, docs, `docs/qa/**`, `scripts/**` included) or in `objectstack-ai/hotcrm`. `content/docs/automation/email-templates.mdx` names `renderTemplate({ template, data, locale })`, which is the service method, so it stays as written. No `skills/**` reader. The spec api-surface snapshots do not cover plugin-email. - npm dependants: NOT MEASURED. The registry has no dependants index; its text search ignores a `dependencies:` qualifier and returns unrelated packages. - Open PRs touching `packages/plugins/plugin-email/**`: none, read at start (20 open) and again before opening this PR (31 open). ## Verification All readings on head `0570b5e6`, branched from `origin/main` `6390fe52`. <details><summary>ADR-0087 restart line: exit 0</summary> `node scripts/check-adr-0087-registration.mjs --base origin/main` exit 0: `.changeset/25190-retire-render-template-export.md [BREAKING+bang+clause-②-narrowing] not-required (runtime-export-retired) -- verified: @objectstack/plugin-email#renderTemplate (export, packages/plugins/plugin-email/src/template-engine.ts)`. </details> <details><summary>Package suite, typecheck, lint: all exit 0</summary> - Under `os-verify-lock`, `pnpm --filter @objectstack/plugin-email build && … test && … typecheck`: VERDICT command-exit 0. Test Files 39 passed (39), Tests 702 passed (702). The typecheck ran `tsc --noEmit` and `check:test-typecheck` (the test layer included). - `pnpm exec eslint --no-inline-config packages/plugins/plugin-email` exit 0. - `node scripts/check-platform-checklist.mjs --changed-from origin/main` exit 0: 0 anchored files, nothing judged. </details> <details><summary>Derived gates: 65 of 65 exit 0</summary> - `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 65 families. All 65 were run, with each exit code captured before any pipe, and all 65 exit 0. - Two needed a prerequisite before they returned a verdict. `check-plugin-teardown-shape --self-test` exited 3 (shallow clone) until its pinned fixture commit `621a4876` was fetched, then exit 0 (48 cases). `check:dual-build-cjs-loads` exited 3 (no dist) until a full build ran, then exit 0 (112 entry points across 69 packages). - `--ran`: 65 derived, 65 run, 0 NOT-MEASURED, exit 0. </details> <details><summary>Full dogfood battery (bang title): 2 files red, both red on main and not touched by this diff</summary> - Full build under the lock: turbo 121/121, VERDICT command-exit 0. Then `pnpm --filter @objectstack/dogfood test` under the lock: VERDICT command-exit 1. Test Files 2 failed, 297 passed, 3 skipped (302); Tests 10 failed, 2521 passed, 18 skipped (2549). - The 10 failures are all in `test/authz-probe-blind-spot.test.ts` (the `rest-server.ts` / `rest-route-ledger.ts` census: population 70 where 69 is pinned) and `test/authz-conformance.test.ts` (#17111 docblock figure pins). This diff touches none of the files they read; its whole delta is the plugin-email entry, one pin test and one changeset. - With `packages/rest/src` and `packages/qa/dogfood/test` checked out from the current `origin/main` `abb90460`, the same two files are red the same way (10 failed, 98 passed). They were restored with `git checkout HEAD`, and `git diff HEAD` came back empty. `Dogfood Regression Gate` on `origin/main` `abb90460` also concludes `failure` in CI. Last changes on the read files: `rest-route-ledger.ts` `6a2866af` (#26361); `rest-server.ts`, the matrix and both tests `ff918954` (#26291). - Main's expected reds on this head (10), each anchored at #26400 (cause #26361). This PR does not fix them: - `test/authz-conformance.test.ts` › #17111 — the matrix docblock's ledger figures are pinned to the ledgers › DELETING the figure is RED — the pin cannot be silenced by dropping its subject · anchored at #26400 - `test/authz-conformance.test.ts` › #17111 — … › a SECOND present-tense copy of a figure is RED — a new carrier cannot arrive unpinned · anchored at #26400 - `test/authz-conformance.test.ts` › #17111 — … › a stale GROUP count is RED too — both halves of the pair are pinned · anchored at #26400 - `test/authz-conformance.test.ts` › #17111 — … › a stale ROW count is RED — the #14503 drift, replayed · anchored at #26400 - `test/authz-conformance.test.ts` › #17111 — … › every figure the docblock states equals what its ledger holds TODAY · anchored at #26400 - `test/authz-conformance.test.ts` › #17111 — … › the anchor is the PATH, so the dated drift note is NOT pinned as a present-tense claim · anchored at #26400 - `test/authz-probe-blind-spot.test.ts` › authz probe blind-spot census (#13260) › packages/rest/src/rest-route-ledger.ts — every positive control is still present in THAT file · anchored at #26400 - `test/authz-probe-blind-spot.test.ts` › authz probe blind-spot census (#13260) › packages/rest/src/rest-route-ledger.ts — population, reach and blind spot are unchanged · anchored at #26400 - `test/authz-probe-blind-spot.test.ts` › authz probe blind-spot census (#13260) › packages/rest/src/rest-server.ts — every positive control is still present in THAT file · anchored at #26400 - `test/authz-probe-blind-spot.test.ts` › authz probe blind-spot census (#13260) › packages/rest/src/rest-server.ts — population, reach and blind spot are unchanged · anchored at #26400 </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014LDnV6FyUqxjaBRSi5Kv1L Co-authored-by: Claude <noreply@anthropic.com>
1 parent b7b04e5 commit 92a0073

3 files changed

Lines changed: 105 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-email': patch
3+
---
4+
5+
fix(plugin-email)!: retire `renderTemplate`, the render export that takes an email template body with no save moment
6+
7+
The hole-placement rule for mail templates is judged where a template is authored or published: `os validate`, `os build`, `os lint` and the runtime publish gate. The `renderTemplate` function on the package entry took a template body inline and rendered its html face at once, with no save moment, so the rule could not see it. It leaves the published entry of `@objectstack/plugin-email`. Nothing in this repository imported it from that entry. The renderer stays inside the package: `services.email` still renders every stored `email_template` through it, unchanged.
8+
9+
Clause-②: no (narrowing)
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (runtime-export-retired @objectstack/plugin-email#renderTemplate) One runtime function leaves a published package entry; no key of any metadata schema, no object definition and no stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. A TypeScript consumer meets the break at its own import, and the line below names the door to use instead. -->
13+
14+
**BREAKING** (a runtime export is removed). It ships as `patch` because the metadata protocol does not move. There is no staged window.
15+
16+
- FROM `renderTemplate` TO an `email_template` item for the name and locale, rendered by `services.email` through `renderTemplate({ template, data, locale })` (render only) or `sendTemplate` (render and send).
17+
18+
Unchanged: `IEmailService.renderTemplate` (the render-by-name method of the same name), `requireVars`, `htmlToText` and the template loader; and how every stored template renders.

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,12 @@ export {
8585
type AttachmentReclaimOutcome,
8686
type ReclaimAttachmentContentOptions,
8787
} from './attachment-reclaim.js';
88-
export { renderTemplate, requireVars, htmlToText } from './template-engine.js';
88+
// [#25190] `renderTemplate` is not on this entry: it took a template body
89+
// inline and rendered its html face with no save moment, so the hole-placement
90+
// rule, judged where a template is authored or published, could not see it
91+
// (ruling 6335348115 on #25082, F2 B3). A template is an `email_template`
92+
// item, rendered by `services.email` (`sendTemplate` / `renderTemplate`).
93+
export { requireVars, htmlToText } from './template-engine.js';
8994
export {
9095
ResendTransport,
9196
PostmarkTransport,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25190 — `renderTemplate`, the module function that took a template body
5+
* inline, is retired from this package's published entry (ruling 6335348115
6+
* on #25082, F2 B3; ADR-0087 `runtime-export-retired`). The hole-placement
7+
* rule judges a template where it is authored or published, and a body handed
8+
* straight to the function was never either, so it was the one door the rule
9+
* could not see. `IEmailService.renderTemplate` — the render-by-name method of
10+
* the same name — is a different door and stays.
11+
*
12+
* Pinned both ways: the entry no longer names it (the helpers beside it stay),
13+
* and a stored template still renders through the service, by name — the html
14+
* face with every hole encoded, the subject (a plain-text face) verbatim —
15+
* for both the render-only and the sending door.
16+
*/
17+
18+
import { describe, it, expect } from 'vitest';
19+
import type { IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts';
20+
import * as entry from './index.js';
21+
import type { EmailTemplateRow, TemplateLoader } from './index.js';
22+
import { EmailService } from './email-service.js';
23+
24+
const row: EmailTemplateRow = {
25+
name: 'deal.won',
26+
locale: 'en-US',
27+
subject: 'Won {{deal.name}}',
28+
body_html: '<h1>{{deal.name}}</h1>',
29+
active: true,
30+
};
31+
32+
function makeService() {
33+
const sent: NormalizedEmailMessage[] = [];
34+
const transport: IEmailTransport = {
35+
async send(message: NormalizedEmailMessage): Promise<TransportSendResult> {
36+
sent.push(message);
37+
return { messageId: `msg-${sent.length}` };
38+
},
39+
};
40+
const templateLoader: TemplateLoader = {
41+
async load(name, locale) {
42+
return row.name === name && (locale === undefined || row.locale === locale) ? row : null;
43+
},
44+
};
45+
const svc = new EmailService({
46+
transport,
47+
defaultFrom: { address: 'no-reply@x.com' },
48+
templateLoader,
49+
persistence: {
50+
async insert(r) { return { id: r.id }; },
51+
async update() { /* noop */ },
52+
},
53+
});
54+
return { svc, sent };
55+
}
56+
57+
describe('plugin-email entry: the body-taking renderer is retired (#25190)', () => {
58+
it('the published entry does not export renderTemplate', () => {
59+
expect('renderTemplate' in entry).toBe(false);
60+
});
61+
62+
it('THE CONTROL: the entry still exports the helpers beside it and the stored-template wiring', () => {
63+
expect(typeof entry.requireVars).toBe('function');
64+
expect(typeof entry.htmlToText).toBe('function');
65+
expect(typeof entry.createRegistryEmailTemplateLoader).toBe('function');
66+
expect(typeof entry.EmailServicePlugin).toBe('function');
67+
});
68+
69+
it('a stored template still renders by name through the service', async () => {
70+
const { svc, sent } = makeService();
71+
72+
const out = await svc.renderTemplate({ template: 'deal.won', data: { deal: { name: 'A<b>' } } });
73+
expect(out.subject).toBe('Won A<b>');
74+
expect(out.html).toBe('<h1>A&lt;b&gt;</h1>');
75+
76+
await svc.sendTemplate({ template: 'deal.won', to: 'ada@example.com', data: { deal: { name: 'A<b>' } } });
77+
expect(sent).toHaveLength(1);
78+
expect(sent[0].subject).toBe('Won A<b>');
79+
expect(sent[0].html).toBe('<h1>A&lt;b&gt;</h1>');
80+
});
81+
});

0 commit comments

Comments
 (0)