Skip to content

Commit 23abe27

Browse files
os-steveclaude
andauthored
feat(messaging): IEmailService gains a render-only renderTemplate seam, consumed by the inbox channel (#9225) (#9248)
* feat(messaging): IEmailService render-only renderTemplate seam, consumed by the inbox channel (#9225) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP * chore(spec): regenerate api-surface + export-origins for the new contract exports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent afba4ec commit 23abe27

11 files changed

Lines changed: 569 additions & 34 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-email": minor
4+
"@objectstack/service-messaging": minor
5+
---
6+
7+
feat(messaging): `IEmailService` gains a render-only `renderTemplate({ template, locale, data, timezone }) → { subject, html, text }`, and the inbox channel consumes it — localized `sys_email_template` content now reaches `sys_inbox_message` (#9225)
8+
9+
A template-path notify node with `channels: ['inbox', 'email']` delivered a
10+
localized email and an inbox row whose title was the topic and whose body was
11+
empty: the locale ladder + `{{var}}` renderer (ADR-0053 format filters
12+
included) lived inside plugin-email's `sendTemplate`, unreachable without
13+
sending mail (maintainer-ruled seam, 2026-08-17, on #9225).
14+
15+
- `IEmailService.renderTemplate` (new contract method, `packages/spec`)
16+
resolves a `sys_email_template` bundle by `(name, locale)` with the same
17+
documented en-US ladder as `sendTemplate`, validates required variables, and
18+
returns the rendered `{ subject, html, text }` — strictly render-only: no
19+
transport call, no queueing, no `sys_email` row. Implemented ONCE in
20+
plugin-email by extracting the resolver `sendTemplate` already used;
21+
`sendTemplate` now delivers what the shared resolver renders, byte for byte.
22+
- The messaging inbox channel consumes it the way the email channel consumes
23+
`sendTemplate`: a delivery whose payload carries a notify `template`
24+
reference renders `subject` into the row's `title` and `text` into
25+
`body_md`, per recipient, at delivery time. A registered email service
26+
without the method — or no email service at all — fails the delivery LOUDLY
27+
(`TEMPLATE_UNSUPPORTED`, graded permanent) instead of silently degrading to
28+
topic-as-title; renderer failure codes (`TEMPLATE_NOT_FOUND` /
29+
`TEMPLATE_INACTIVE` / `MISSING_VARIABLES`) land on the delivery row and are
30+
graded permanent, mirroring the email channel.
31+
32+
The result shape follows what `sys_email_template` rows carry
33+
(`subject`/`body_html`/`body_text?`): `html` is the rendered `body_html`,
34+
`text` is the rendered `body_text` or, when the row declares none, derived
35+
from the rendered HTML.

packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@ function createRecordingEmailService(failOn?: string) {
151151
}
152152
return { id: `email_${sent.length}`, status: 'sent' };
153153
},
154+
// Render-only face (#9225) — nothing in these tests renders without
155+
// sending, so the fake honestly refuses rather than inventing content.
156+
async renderTemplate(input) {
157+
throw new Error(`TEMPLATE_NOT_FOUND: ${input.template} (locale=en-US)`);
158+
},
154159
};
155160
return { service, sent };
156161
}

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

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type {
66
SendEmailInput,
77
SendEmailResult,
88
SendTemplateInput,
9+
RenderTemplateInput,
10+
RenderTemplateResult,
911
NormalizedEmailMessage,
1012
EmailAddress,
1113
EmailDeliveryStatus,
@@ -1158,11 +1160,15 @@ export class EmailService implements IEmailService {
11581160
}
11591161

11601162
/**
1161-
* Render a named template from sys_email_template and deliver via
1162-
* send(). Looks up `(name, locale)` then falls back to
1163-
* `(name, {@link DEFAULT_TEMPLATE_LOCALE})`.
1163+
* Resolve `(template, locale)` and render subject/html/text — the ONE
1164+
* resolver + renderer behind both {@link sendTemplate} (which delivers the
1165+
* result) and {@link renderTemplate} (which only returns it, #9225). The
1166+
* resolved row rides along so `sendTemplate` can keep reading its envelope
1167+
* columns (`from_address`/`from_name`/`reply_to`).
11641168
*/
1165-
async sendTemplate(input: SendTemplateInput): Promise<SendEmailResult> {
1169+
private async resolveAndRenderTemplate(
1170+
input: RenderTemplateInput,
1171+
): Promise<{ row: EmailTemplateRow; rendered: RenderTemplateResult }> {
11661172
if (!input?.template) {
11671173
throw new Error('VALIDATION_FAILED: template name is required');
11681174
}
@@ -1241,6 +1247,32 @@ export class EmailService implements IEmailService {
12411247
? renderTemplate(row.body_text, data, renderOpts)
12421248
: htmlToText(html);
12431249

1250+
return { row, rendered: { subject, html, text } };
1251+
}
1252+
1253+
/**
1254+
* Render a named template from sys_email_template WITHOUT sending —
1255+
* `IEmailService.renderTemplate` (#9225). Strictly render-only: the shared
1256+
* resolver above never touches the transport, the queue, persistence or
1257+
* the outbox; it only resolves `(name, locale)` per the documented ladder,
1258+
* validates required variables, and renders the `{{var}}` holes (ADR-0053
1259+
* format filters included).
1260+
*/
1261+
async renderTemplate(input: RenderTemplateInput): Promise<RenderTemplateResult> {
1262+
const { rendered } = await this.resolveAndRenderTemplate(input);
1263+
return rendered;
1264+
}
1265+
1266+
/**
1267+
* Render a named template from sys_email_template and deliver via
1268+
* send(). Looks up `(name, locale)` then falls back to
1269+
* `(name, {@link DEFAULT_TEMPLATE_LOCALE})` — the shared
1270+
* {@link resolveAndRenderTemplate} ladder.
1271+
*/
1272+
async sendTemplate(input: SendTemplateInput): Promise<SendEmailResult> {
1273+
const { row, rendered } = await this.resolveAndRenderTemplate(input);
1274+
const { subject, html, text } = rendered;
1275+
12441276
const from: EmailAddress | undefined = input.from
12451277
?? (row.from_address
12461278
? { address: row.from_address, ...(row.from_name ? { name: row.from_name } : {}) }
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `IEmailService.renderTemplate` (#9225) — the render-only face of the one
5+
* template resolver. Same `(name, locale)` ladder, same `{{var}}` renderer
6+
* (ADR-0053 format filters included) as `sendTemplate`, ZERO send path: no
7+
* transport call, no `sys_email` row, no queue.
8+
*/
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { EmailService, type TemplateLoader, type EmailTemplateRow } from './email-service.js';
12+
import type { IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts';
13+
14+
class CaptureTransport implements IEmailTransport {
15+
public sent: NormalizedEmailMessage[] = [];
16+
async send(message: NormalizedEmailMessage): Promise<TransportSendResult> {
17+
this.sent.push(message);
18+
return { messageId: `msg-${this.sent.length}` };
19+
}
20+
}
21+
22+
/** Exact-locale loader — the en-US fallback lives in the service's ladder. */
23+
function makeLoader(rows: EmailTemplateRow[]): TemplateLoader {
24+
return {
25+
async load(name, locale) {
26+
if (locale === undefined) return rows.find((r) => r.name === name) ?? null;
27+
return rows.find((r) => r.name === name && r.locale === locale) ?? null;
28+
},
29+
};
30+
}
31+
32+
function makeService(rows: EmailTemplateRow[]) {
33+
const transport = new CaptureTransport();
34+
const inserts: Array<Record<string, any>> = [];
35+
const svc = new EmailService({
36+
transport,
37+
defaultFrom: { address: 'no-reply@x.com' },
38+
templateLoader: makeLoader(rows),
39+
persistence: {
40+
async insert(row) { inserts.push(row); return { id: row.id }; },
41+
async update() { /* noop */ },
42+
},
43+
});
44+
return { svc, transport, inserts };
45+
}
46+
47+
const enUs: EmailTemplateRow = {
48+
name: 'deal.won',
49+
locale: 'en-US',
50+
subject: 'Deal won: {{deal.name}}',
51+
body_html: '<p>Hi {{user.name}}, deal <b>{{deal.name}}</b> closed.</p>',
52+
body_text: 'Hi {{user.name}}, deal {{deal.name}} closed.',
53+
active: true,
54+
};
55+
56+
const zhCn: EmailTemplateRow = {
57+
name: 'deal.won',
58+
locale: 'zh-CN',
59+
subject: '赢单:{{deal.name}}',
60+
body_html: '<p>{{user.name}},{{deal.name}} 已成交。</p>',
61+
active: true,
62+
};
63+
64+
describe('EmailService.renderTemplate (#9225)', () => {
65+
it('renders subject/html/text from the resolved row WITHOUT sending — no transport call, no sys_email row', async () => {
66+
const { svc, transport, inserts } = makeService([enUs]);
67+
68+
const out = await svc.renderTemplate({
69+
template: 'deal.won',
70+
data: { user: { name: 'Alice' }, deal: { name: 'Acme' } },
71+
});
72+
73+
expect(out).toEqual({
74+
subject: 'Deal won: Acme',
75+
html: '<p>Hi Alice, deal <b>Acme</b> closed.</p>',
76+
text: 'Hi Alice, deal Acme closed.',
77+
});
78+
// Strictly render-only (the ruling's zero-send-path clause): nothing
79+
// reached the transport and nothing was persisted.
80+
expect(transport.sent).toHaveLength(0);
81+
expect(inserts).toHaveLength(0);
82+
});
83+
84+
it('resolves the recipient locale exactly, and derives text from html when the row has no body_text', async () => {
85+
const { svc } = makeService([enUs, zhCn]);
86+
87+
const out = await svc.renderTemplate({
88+
template: 'deal.won',
89+
locale: 'zh-CN',
90+
data: { user: { name: '张三' }, deal: { name: 'Acme' } },
91+
});
92+
93+
expect(out.subject).toBe('赢单:Acme');
94+
expect(out.html).toBe('<p>张三,Acme 已成交。</p>');
95+
// zh-CN row declares no body_text → text is htmlToText(rendered html).
96+
expect(out.text).toBe('张三,Acme 已成交。');
97+
});
98+
99+
it('falls back to en-US when the requested locale has no row (the documented ladder)', async () => {
100+
const { svc } = makeService([enUs, zhCn]);
101+
102+
const out = await svc.renderTemplate({
103+
template: 'deal.won',
104+
locale: 'ja-JP',
105+
data: { user: { name: 'Yuki' }, deal: { name: 'Acme' } },
106+
});
107+
108+
expect(out.subject).toBe('Deal won: Acme');
109+
});
110+
111+
it('renders ADR-0053 format-filter holes with the input reference timezone', async () => {
112+
const tpl: EmailTemplateRow = {
113+
name: 'order.shipped',
114+
locale: 'en-US',
115+
subject: 'Shipped',
116+
body_html: '<p>Ships {{ shipAt | datetime }}</p>',
117+
body_text: 'Ships {{ shipAt | datetime }}',
118+
active: true,
119+
};
120+
const { svc } = makeService([tpl]);
121+
122+
// 2026-06-02T01:30Z is still 2026-06-01 in America/New_York.
123+
const out = await svc.renderTemplate({
124+
template: 'order.shipped',
125+
data: { shipAt: '2026-06-02T01:30:00.000Z' },
126+
timezone: 'America/New_York',
127+
});
128+
129+
expect(out.text).toContain('6/1/26'); // shifted to the NY calendar day
130+
expect(out.text).not.toContain('2026-06-02T01:30'); // not raw ISO
131+
});
132+
133+
it('throws TEMPLATE_NOT_FOUND when no row matches (name, locale|en-US)', async () => {
134+
const { svc, transport } = makeService([enUs]);
135+
await expect(svc.renderTemplate({ template: 'no.such_template' }))
136+
.rejects.toThrow(/TEMPLATE_NOT_FOUND/);
137+
expect(transport.sent).toHaveLength(0);
138+
});
139+
140+
it('throws TEMPLATE_INACTIVE for a resolvable but deactivated row', async () => {
141+
const { svc } = makeService([{ ...enUs, active: false }]);
142+
await expect(svc.renderTemplate({ template: 'deal.won' }))
143+
.rejects.toThrow(/TEMPLATE_INACTIVE/);
144+
});
145+
146+
it('throws MISSING_VARIABLES naming the absent required variables', async () => {
147+
const tpl: EmailTemplateRow = {
148+
...enUs,
149+
variables_json: JSON.stringify([
150+
{ name: 'user.name', required: true },
151+
{ name: 'deal.name', required: true },
152+
]),
153+
};
154+
const { svc } = makeService([tpl]);
155+
await expect(svc.renderTemplate({ template: 'deal.won', data: { user: { name: 'Alice' } } }))
156+
.rejects.toThrow(/MISSING_VARIABLES: deal.name/);
157+
});
158+
159+
it('throws VALIDATION_FAILED without a template name, and TEMPLATE_NOT_FOUND without a loader', async () => {
160+
const { svc } = makeService([enUs]);
161+
await expect(svc.renderTemplate({ template: '' }))
162+
.rejects.toThrow(/VALIDATION_FAILED: template name is required/);
163+
164+
const bare = new EmailService({ transport: new CaptureTransport() });
165+
await expect(bare.renderTemplate({ template: 'deal.won' }))
166+
.rejects.toThrow(/TEMPLATE_NOT_FOUND: no templateLoader configured/);
167+
});
168+
});

packages/services/service-messaging/src/email-channel.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ export interface EmailSenderSurface {
4646
data?: Record<string, unknown>;
4747
locale?: string;
4848
}): Promise<{ id?: string; status?: string; error?: string } | unknown>;
49+
/**
50+
* Structural mirror of `IEmailService.renderTemplate` (#9225) — resolves a
51+
* `sys_email_template` bundle by `(template, locale)` with the same
52+
* documented en-US ladder as `sendTemplate` and returns the rendered
53+
* content WITHOUT sending. OPTIONAL for the same reason `sendTemplate` is:
54+
* an older or third-party email implementation may not provide it, and a
55+
* consumer that needs it (the inbox channel's template path) then fails
56+
* LOUDLY on the delivery row rather than degrading silently
57+
* (declared = enforced, ADR-0049).
58+
*/
59+
renderTemplate?(input: {
60+
template: string;
61+
data?: Record<string, unknown>;
62+
locale?: string;
63+
}): Promise<{ subject: string; html: string; text: string }>;
4964
}
5065

5166
export interface EmailChannelOptions {

0 commit comments

Comments
 (0)