Skip to content

Commit 85058b6

Browse files
committed
fix(plugin-email, service-messaging)!: retire the render exports that take a template body with no save moment
renderTemplate leaves @objectstack/plugin-email's entry; renderNotification and interpolate leave @objectstack/service-messaging's. The module-internal renderers stay: the mail service and the email and sms channels render stored templates through them unchanged. Claude-Session: https://claude.ai/code/session_01CP5uvqW3aX2Tfwy6YAsNu5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent bc5daf0 commit 85058b6

5 files changed

Lines changed: 164 additions & 3 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-email': patch
3+
'@objectstack/service-messaging': patch
4+
---
5+
6+
fix(plugin-email, service-messaging)!: retire the render exports that take a template body with no save moment (`renderTemplate`, `renderNotification`, `interpolate`)
7+
8+
The hole-placement rule is judged where a template is saved or registered: the `sys_notification_template` save hooks, and the authoring rule for email templates. Three exports took a template body inline and rendered it at once, with no save moment, so the rule could not see them. They leave the two packages' published entries. Nothing in this repository imported them from either entry. The renderers stay inside the packages: the mail service and the email and sms channels still render every stored template through them, unchanged.
9+
10+
Clause-②: no (narrowing)
11+
Metadata-protocol: none
12+
13+
<!-- adr-0087: not-required (runtime-export-retired @objectstack/plugin-email#renderTemplate, @objectstack/service-messaging#renderNotification, @objectstack/service-messaging#interpolate) Three runtime functions leave two published package entries; 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 lines below name the door to use instead. -->
14+
15+
**BREAKING** (three runtime exports are removed). It ships as `patch` because the metadata protocol does not move. There is no staged window.
16+
17+
- FROM `renderTemplate` imported from `@objectstack/plugin-email` TO a template saved or registered as an `email_template` and rendered by name through the mail service: `renderTemplate({ template, data, locale })` on the `email` service (render only) or `sendTemplate` (render and send).
18+
- FROM `renderNotification` imported from `@objectstack/service-messaging` TO a `sys_notification_template` row for the topic, channel and locale, which the delivering channel renders when the notification is emitted.
19+
- FROM `interpolate` imported from `@objectstack/service-messaging` TO the subject or plain-text body of a `sys_notification_template` row, rendered the same way by the delivering channel.
20+
21+
Unchanged: `requireVars` and `htmlToText` in `@objectstack/plugin-email`, and the mail service's `setTemplateLoader`; `NotificationTemplateStore`, `TEMPLATE_OBJECT`, `DEFAULT_LOCALE` and the template types in `@objectstack/service-messaging`; 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` (the HTML-face renderer) is not on this entry: it
89+
// took a template body inline and rendered it with no save moment, so the
90+
// hole-placement rule judged where a template is saved or registered could not
91+
// see it (ruling 6335348115 on #25082, F2 B3). A template is rendered from the
92+
// mail service by NAME: `IEmailService.renderTemplate` / `sendTemplate`.
93+
export { requireVars, htmlToText } from './template-engine.js';
8994
export {
9095
ResendTransport,
9196
PostmarkTransport,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25190 — `renderTemplate`, the HTML-face renderer that took a template body
5+
* inline, is retired from this package's published entry (ruling 6335348115 on
6+
* #25082, F2 B3; ADR-0087 `runtime-export-retired`). The hole-placement rule is
7+
* judged where a template is saved or registered, and a body handed straight to
8+
* that function had neither moment, so it was the one door the rule could not
9+
* see.
10+
*
11+
* Pinned both ways: the entry no longer names it (its two siblings from the same
12+
* module stay), and a template still renders through the stored-template door —
13+
* the mail service, by NAME — with every hole encoded for the HTML face.
14+
*/
15+
16+
import { describe, it, expect } from 'vitest';
17+
import * as entry from './index.js';
18+
import { EmailService, type EmailTemplateRow } from './email-service.js';
19+
20+
describe('plugin-email entry: the body-taking renderer is retired (#25190)', () => {
21+
it('the published entry does not export renderTemplate', () => {
22+
expect('renderTemplate' in entry).toBe(false);
23+
});
24+
25+
it('THE CONTROL: the entry still exports its siblings from the same module', () => {
26+
expect(typeof entry.requireVars).toBe('function');
27+
expect(typeof entry.htmlToText).toBe('function');
28+
});
29+
30+
it('a stored template still renders by name through the mail service, holes encoded for HTML', async () => {
31+
const row: EmailTemplateRow = {
32+
name: 'deal.won',
33+
locale: 'en-US',
34+
subject: 'Deal won: {{deal.name}}',
35+
body_html: '<p>Deal <b>{{deal.name}}</b> closed.</p>',
36+
active: true,
37+
};
38+
const svc = new EmailService({
39+
transport: { async send() { return { messageId: 'never' }; } },
40+
defaultFrom: { address: 'no-reply@x.com' },
41+
templateLoader: { async load(name) { return name === row.name ? row : null; } },
42+
});
43+
44+
const out = await svc.renderTemplate({ template: 'deal.won', data: { deal: { name: 'A<b>' } } });
45+
46+
expect(out.subject).toBe('Deal won: A<b>');
47+
expect(out.html).toBe('<p>Deal <b>A&lt;b&gt;</b> closed.</p>');
48+
});
49+
});

‎packages/services/service-messaging/src/index.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,15 @@ export type {
8787
} from './notification-text.js';
8888

8989
// Templates + renderer (ADR-0030 P3)
90+
//
91+
// [#25190] The renderer itself (`renderNotification`, and `interpolate`, its
92+
// plain-text face) is not on this entry: both took a template body inline and
93+
// rendered it with no save moment, so the hole-placement rule the template
94+
// object's save hooks run could not see them (ruling 6335348115 on #25082,
95+
// F2 B3). A notification template is a `sys_notification_template` row, judged
96+
// when it is saved and rendered by the channels that deliver it.
9097
export {
9198
NotificationTemplateStore,
92-
renderNotification,
93-
interpolate,
9499
TEMPLATE_OBJECT,
95100
DEFAULT_LOCALE,
96101
} from './template-renderer.js';
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 — `renderNotification` and `interpolate`, the renderer that took a
5+
* template body inline, are retired from this package's published entry
6+
* (ruling 6335348115 on #25082, F2 B3; ADR-0087 `runtime-export-retired`). The
7+
* hole-placement rule is judged by the `sys_notification_template` save hooks,
8+
* and a body handed straight to either function was never saved, so they were
9+
* the one door the rule could not see.
10+
*
11+
* Pinned both ways: the entry no longer names them (the template store and the
12+
* channels stay), and a stored template row still renders through a channel
13+
* built from the entry — the html body with every hole encoded, the subject
14+
* (`interpolate`'s plain-text face) verbatim.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import * as entry from './index.js';
19+
20+
describe('service-messaging entry: the body-taking renderer is retired (#25190)', () => {
21+
it('the published entry does not export renderNotification or interpolate', () => {
22+
expect('renderNotification' in entry).toBe(false);
23+
expect('interpolate' in entry).toBe(false);
24+
});
25+
26+
it('THE CONTROL: the entry still exports the template store and the channels that render', () => {
27+
expect(typeof entry.NotificationTemplateStore).toBe('function');
28+
expect(entry.TEMPLATE_OBJECT).toBe('sys_notification_template');
29+
expect(typeof entry.createEmailChannel).toBe('function');
30+
expect(typeof entry.createSmsChannel).toBe('function');
31+
});
32+
33+
it('a stored html template row still renders through the email channel', async () => {
34+
const row = {
35+
organization_id: 'org_1',
36+
topic: 'deal.won',
37+
channel: 'email',
38+
locale: 'en',
39+
is_active: true,
40+
format: 'html',
41+
subject: 'Won {{ payload.title }}',
42+
body: '<h1>{{ payload.title }}</h1>',
43+
};
44+
const data = {
45+
async findOne(object: string, query: any) {
46+
const w = query?.where ?? {};
47+
if (object === 'sys_user') return w.id === 'user_1' ? { email: 'ada@example.com' } : null;
48+
if (object === entry.TEMPLATE_OBJECT) {
49+
return row.organization_id === w.organization_id && row.topic === w.topic
50+
&& row.channel === w.channel && row.locale === w.locale ? row : null;
51+
}
52+
return null;
53+
},
54+
} as any;
55+
const sent: any[] = [];
56+
const ch = entry.createEmailChannel({
57+
getEmail: () => ({ async send(input: any) { sent.push(input); return { id: 'email_row_1' }; } }) as any,
58+
getData: () => data,
59+
store: new entry.NotificationTemplateStore({ getData: () => data }),
60+
});
61+
const logger = { info: () => {}, warn: () => {}, error: () => {} };
62+
63+
const r = await ch.send({ logger } as any, {
64+
channel: 'email',
65+
recipient: 'user_1',
66+
notification: {
67+
notificationId: 'evt_1',
68+
organizationId: 'org_1',
69+
topic: 'deal.won',
70+
title: 'A<b>',
71+
body: '',
72+
severity: 'info',
73+
recipients: ['user_1'],
74+
payload: { title: 'A<b>' },
75+
},
76+
} as any);
77+
78+
expect(r.ok).toBe(true);
79+
expect(sent).toEqual([{ to: 'ada@example.com', subject: 'Won A<b>', html: '<h1>A&lt;b&gt;</h1>', organizationId: 'org_1' }]);
80+
});
81+
});

0 commit comments

Comments
 (0)