diff --git a/.changeset/25190-retire-render-template-export.md b/.changeset/25190-retire-render-template-export.md new file mode 100644 index 00000000000..682d045fc41 --- /dev/null +++ b/.changeset/25190-retire-render-template-export.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-email': patch +--- + +fix(plugin-email)!: retire `renderTemplate`, the render export that takes an email template body with no save moment + +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. + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** (a runtime export is removed). It ships as `patch` because the metadata protocol does not move. There is no staged window. + +- 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). + +Unchanged: `IEmailService.renderTemplate` (the render-by-name method of the same name), `requireVars`, `htmlToText` and the template loader; and how every stored template renders. diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index c3aa51380e3..6f2d1cd7de3 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -85,7 +85,12 @@ export { type AttachmentReclaimOutcome, type ReclaimAttachmentContentOptions, } from './attachment-reclaim.js'; -export { renderTemplate, requireVars, htmlToText } from './template-engine.js'; +// [#25190] `renderTemplate` is not on this entry: it took a template body +// inline and rendered its html face with no save moment, so the hole-placement +// rule, judged where a template is authored or published, could not see it +// (ruling 6335348115 on #25082, F2 B3). A template is an `email_template` +// item, rendered by `services.email` (`sendTemplate` / `renderTemplate`). +export { requireVars, htmlToText } from './template-engine.js'; export { ResendTransport, PostmarkTransport, diff --git a/packages/plugins/plugin-email/src/render-export-retired.pin.test.ts b/packages/plugins/plugin-email/src/render-export-retired.pin.test.ts new file mode 100644 index 00000000000..686b2b616d5 --- /dev/null +++ b/packages/plugins/plugin-email/src/render-export-retired.pin.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25190 — `renderTemplate`, the module function that took a template body + * inline, is retired from this package's published entry (ruling 6335348115 + * on #25082, F2 B3; ADR-0087 `runtime-export-retired`). The hole-placement + * rule judges a template where it is authored or published, and a body handed + * straight to the function was never either, so it was the one door the rule + * could not see. `IEmailService.renderTemplate` — the render-by-name method of + * the same name — is a different door and stays. + * + * Pinned both ways: the entry no longer names it (the helpers beside it stay), + * and a stored template still renders through the service, by name — the html + * face with every hole encoded, the subject (a plain-text face) verbatim — + * for both the render-only and the sending door. + */ + +import { describe, it, expect } from 'vitest'; +import type { IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts'; +import * as entry from './index.js'; +import type { EmailTemplateRow, TemplateLoader } from './index.js'; +import { EmailService } from './email-service.js'; + +const row: EmailTemplateRow = { + name: 'deal.won', + locale: 'en-US', + subject: 'Won {{deal.name}}', + body_html: '

{{deal.name}}

', + active: true, +}; + +function makeService() { + const sent: NormalizedEmailMessage[] = []; + const transport: IEmailTransport = { + async send(message: NormalizedEmailMessage): Promise { + sent.push(message); + return { messageId: `msg-${sent.length}` }; + }, + }; + const templateLoader: TemplateLoader = { + async load(name, locale) { + return row.name === name && (locale === undefined || row.locale === locale) ? row : null; + }, + }; + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.com' }, + templateLoader, + persistence: { + async insert(r) { return { id: r.id }; }, + async update() { /* noop */ }, + }, + }); + return { svc, sent }; +} + +describe('plugin-email entry: the body-taking renderer is retired (#25190)', () => { + it('the published entry does not export renderTemplate', () => { + expect('renderTemplate' in entry).toBe(false); + }); + + it('THE CONTROL: the entry still exports the helpers beside it and the stored-template wiring', () => { + expect(typeof entry.requireVars).toBe('function'); + expect(typeof entry.htmlToText).toBe('function'); + expect(typeof entry.createRegistryEmailTemplateLoader).toBe('function'); + expect(typeof entry.EmailServicePlugin).toBe('function'); + }); + + it('a stored template still renders by name through the service', async () => { + const { svc, sent } = makeService(); + + const out = await svc.renderTemplate({ template: 'deal.won', data: { deal: { name: 'A' } } }); + expect(out.subject).toBe('Won A'); + expect(out.html).toBe('

A<b>

'); + + await svc.sendTemplate({ template: 'deal.won', to: 'ada@example.com', data: { deal: { name: 'A' } } }); + expect(sent).toHaveLength(1); + expect(sent[0].subject).toBe('Won A'); + expect(sent[0].html).toBe('

A<b>

'); + }); +});