Skip to content

Commit 78020b8

Browse files
committed
feat(email): materialize declared email_template metadata into sys_email_template (#4509)
Authoring an `email_template` was a silent no-op. `sendTemplate` resolves `(name, locale)` against sys_email_template ROWS, whose only writers were the built-in auth templates plus a code-constructed `EmailServicePluginOptions. templates` that no bootstrapper passed — while every door an author can use (stack `emailTemplates:`, `*.email-template.ts`, Studio, PUT /meta) parked items in a metadata store nothing read back. An admin could "fix" the password-reset mail in Studio and users kept receiving the built-in copy: ADR-0078 false compliance on AUTH mail. Webhook #3461's shape, closed the same way (ADR-0049 enforce-or-remove, route: enforce). `bootstrapDeclaredEmailTemplates` materializes declared items at boot, validating each through EmailTemplateDefinitionSchema.parse() and projecting with `mapTemplateToRow` — the SAME mapping the built-in seeder uses, extracted and shared so the two doors cannot drift. Because email_template is allowRuntimeCreate:true (unlike webhook), the plugin also subscribes to metadata changes and re-materializes the changed item, so a Studio save takes effect without a restart; a withdrawn template deactivates its rows rather than losing them. Three breaks sat on this path, and closing any two would still have shipped a template that never sent: - objectql never registered a manifest's `emailTemplates:` into the registry at all — the key was missing from the generic ingestion list, so the bridge's own source was empty. - Built-in seeds left `managed_by` at the column's 'admin' default, masquerading as admin-authored; since the bridge refuses to overwrite admin rows, a built-in would permanently outrank the template an app declared. Built-ins now stamp 'platform'. - Nothing materialized declared metadata into rows. Seed-not-clobber mirrors sys_webhook (#3489): sys_email_template gains managed_by/customized, declared rows re-seed as 'package', and an admin-created or admin-edited row is never overwritten. Separate axis from `is_system`, which keeps its meaning for built-ins. Ledger email_template flips 13 dead → fully live, with an ADR-0054 proof bound on `subject`: it boots a real stack, authors a template overriding a built-in auth template, and asserts the AUTHORED wording reaches the transport. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu
1 parent 48fbacb commit 78020b8

17 files changed

Lines changed: 1203 additions & 68 deletions
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
"@objectstack/plugin-email": minor
4+
"@objectstack/objectql": minor
5+
"@objectstack/spec": patch
6+
---
7+
8+
feat(email): declared email templates reach the mail service (#4509)
9+
10+
Authoring an `email_template` was a silent no-op. `EmailService.sendTemplate`
11+
resolves `(name, locale)` against **`sys_email_template` rows**, and the only
12+
writers of those rows were the built-in auth templates plus a code-constructed
13+
`EmailServicePluginOptions.templates` that no bootstrapper ever passed. Every
14+
door an author can actually use — a stack's `emailTemplates:`, an
15+
`*.email-template.ts` file, Studio's metadata-admin list, `PUT /meta` — parked
16+
items in a metadata store nothing read back. So an admin could "fix" the
17+
password-reset email in Studio, get a success toast, and watch users keep
18+
receiving the built-in copy: ADR-0078 false compliance on **authentication
19+
mail**. This is the shape #3461 had for webhooks, closed the same way (ADR-0049
20+
enforce-or-remove, route: enforce).
21+
22+
**`bootstrapDeclaredEmailTemplates`** now materializes declared templates into
23+
`sys_email_template` at boot. Each item is validated through
24+
`EmailTemplateDefinitionSchema.parse()` — the spec schema finally has a real
25+
consumer, defaults and all — and projected with `mapTemplateToRow`, which is the
26+
**same** mapping the built-in seeder uses, extracted and shared so the two doors
27+
cannot drift apart. A malformed template warns and is skipped rather than
28+
crashing boot.
29+
30+
**Runtime writes take effect immediately.** Unlike `webhook`, `email_template`
31+
is `allowRuntimeCreate: true`, so a boot-only bridge would have left a Studio
32+
save inert until the next restart — the same bug, half-fixed. The plugin also
33+
subscribes to `email_template` metadata changes and re-materializes the single
34+
changed item; withdrawing a template deactivates its rows (across locales)
35+
rather than deleting them.
36+
37+
**Three breaks sat on this path, not one**, and closing any two of them would
38+
still have shipped a template that never sent:
39+
40+
- `@objectstack/objectql` never registered a manifest's `emailTemplates:` into
41+
the metadata registry at all — the key was simply missing from the generic
42+
ingestion list, so the bridge's own source was empty.
43+
- The built-in seeder left `managed_by` at the column's `'admin'` default, which
44+
made platform templates masquerade as admin-authored. Since the bridge refuses
45+
to overwrite admin rows, a built-in would have permanently outranked the
46+
template an app declared. Built-ins now stamp `managed_by: 'platform'`.
47+
- Nothing materialized declared metadata into rows.
48+
49+
**Seed-not-clobber** mirrors `sys_webhook` (#3489) and `sys_sharing_rule`
50+
(#2909): `sys_email_template` gains `managed_by` / `customized`. Declared
51+
templates re-seed every boot as `managed_by: 'package'`; a row an admin created
52+
(`admin`) or edited (`customized`, stamped by a `beforeUpdate` hook) is never
53+
overwritten, so reworded transactional mail survives redeploys. This is a
54+
separate axis from `is_system`, which keeps its existing meaning for built-ins.
55+
56+
The `email_template` liveness ledger flips from 13 dead properties to fully
57+
live, with an ADR-0054 runtime proof bound on `subject`
58+
(`email-template-materialization`): it boots a real stack, authors a template
59+
that overrides a built-in auth template, and asserts the **authored** wording is
60+
what reaches the transport.

packages/cli/src/utils/lint-liveness-properties.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -284,9 +284,14 @@ describe('lintLivenessProperties', () => {
284284
expect(perms!.hint).toMatch(/per item|Per-item/i);
285285
});
286286

287-
// email_template: the WHOLE authoring surface is disconnected from
288-
// sendTemplate (webhook shape) — one per-artifact warn carried on `name`.
289-
it('warns once per email_template artifact via name (#4488)', () => {
287+
// email_template used to carry a per-artifact warn on `name`: the WHOLE
288+
// authoring surface was disconnected from sendTemplate (the webhook shape).
289+
// #4509 built the materializer bridge, so authoring is no longer a no-op and
290+
// a well-formed template must warn about NOTHING. The type stays in
291+
// TYPE_COLLECTIONS — a listed type with zero warns is the resolved state
292+
// (webhook sits there the same way), and keeping it means a future
293+
// regression that re-deadens a prop starts warning again on its own.
294+
it('does not warn on a well-formed email_template — the bridge closed it (#4509)', () => {
290295
const findings = lintLivenessProperties({
291296
emailTemplates: [{
292297
name: 'crm.welcome',
@@ -295,9 +300,7 @@ describe('lintLivenessProperties', () => {
295300
bodyHtml: '<p>Welcome</p>',
296301
}],
297302
});
298-
const hit = findings.find((f) => f.message.includes('`name`'));
299-
expect(hit).toBeDefined();
300-
expect(hit!.hint).toMatch(/sys_email_template/);
303+
expect(findings).toEqual([]);
301304
});
302305

303306
// translation.validationMessages: pointed at by #3778's own migration table,

packages/objectql/src/engine.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1632,6 +1632,12 @@ export class ObjectQL implements IObjectQLEngine {
16321632
'hooks', 'mappings', 'analyticsCubes',
16331633
// Integration Protocol
16341634
'connectors',
1635+
// System Protocol — outbound mail templates. Registered here so the
1636+
// email plugin's materializer can read them back into
1637+
// `sys_email_template` (#4509); without this key an authored
1638+
// `emailTemplates:` entry never reached the registry at all, which is
1639+
// the far end of the disconnect the bridge closes.
1640+
'emailTemplates',
16351641
// System Protocol — package documentation (ADR-0046); inert data
16361642
'docs',
16371643
// Documentation navigation spine (ADR-0046 §6)

packages/platform-objects/src/audit/sys-email-template.object.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,40 @@ export const SysEmailTemplate = ObjectSchema.create({
145145
group: 'Lifecycle',
146146
}),
147147

148+
// ── Provenance (#4509 — record-authoritative seed-not-clobber) ──
149+
// Mirrors sys_webhook (#3461) / sys_sharing_rule (#2909). `is_system`
150+
// remains the built-in-auth-template axis; these two track the DECLARED
151+
// metadata door: bootstrapDeclaredEmailTemplates seeds `package` rows and
152+
// re-seeds them every boot, while an admin's edit stamps `customized` and
153+
// freezes the row. Both are `readonly` — the engine strips them from
154+
// non-system payloads, and only the seeder / stamp hook (isSystem) write
155+
// them. Deliberately NOT a write gate: editing a template in Studio is a
156+
// first-class admin action, it just has to be remembered.
157+
managed_by: Field.select(
158+
['platform', 'package', 'admin'],
159+
{
160+
label: 'Managed By',
161+
required: false,
162+
readonly: true,
163+
defaultValue: 'admin',
164+
description:
165+
'Record provenance: platform = framework built-in / package = app/package-declared ' +
166+
'(boot-seeded from declared email_template metadata) / admin = created in Studio.',
167+
group: 'System',
168+
},
169+
),
170+
171+
customized: Field.boolean({
172+
label: 'Customized',
173+
required: false,
174+
readonly: true,
175+
defaultValue: false,
176+
description:
177+
'Set when an admin edits a package-declared template; boot seeding will no longer ' +
178+
'overwrite the row (a reworded password-reset mail survives redeploys). Meaningless on admin rows.',
179+
group: 'System',
180+
}),
181+
148182
created_at: Field.datetime({
149183
label: 'Created At',
150184
required: true,

0 commit comments

Comments
 (0)