Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/25190-retire-render-template-export.md
Original file line number Diff line number Diff line change
@@ -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

<!-- 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. -->

**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.
7 changes: 6 additions & 1 deletion packages/plugins/plugin-email/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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: '<h1>{{deal.name}}</h1>',
active: true,
};

function makeService() {
const sent: NormalizedEmailMessage[] = [];
const transport: IEmailTransport = {
async send(message: NormalizedEmailMessage): Promise<TransportSendResult> {
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<b>' } } });
expect(out.subject).toBe('Won A<b>');
expect(out.html).toBe('<h1>A&lt;b&gt;</h1>');

await svc.sendTemplate({ template: 'deal.won', to: 'ada@example.com', data: { deal: { name: 'A<b>' } } });
expect(sent).toHaveLength(1);
expect(sent[0].subject).toBe('Won A<b>');
expect(sent[0].html).toBe('<h1>A&lt;b&gt;</h1>');
});
});
Loading