Skip to content

Commit 691bb95

Browse files
committed
feat(core): emailTemplateDefinitionFromRow — a sys_email_template row as the email_template definition it carries
The pure row-to-definition mapper beside permissionSetDefinitionFromRow and positionDefinitionFromRow, for the v18 ceremony's promotion of customized template rows (ADR-0131 D10, ruling C on section 6 Q1). No import, so the lean entry carries it. Pinned against EmailTemplateDefinitionSchema. Claude-Session: https://claude.ai/code/session_01DjKf52bcyXa3fbGW723phR Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5a05cb3 commit 691bb95

3 files changed

Lines changed: 218 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D6/D10, ruling C] The pin of `emailTemplateDefinitionFromRow` — the
5+
* mapping the upgrade ceremony applies to promote a customized
6+
* `sys_email_template` row to an environment `email_template` item through the
7+
* metadata door. The expected bodies are the outputs the module doc states,
8+
* written out by hand; the schema is read only to hold the body to it.
9+
*/
10+
11+
import { describe, expect, it } from 'vitest';
12+
import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system';
13+
import { emailTemplateDefinitionFromRow } from './email-template-row-definition.js';
14+
15+
/** A row as `sys_email_template` stores it, its state and provenance riding along. */
16+
const storedRow = {
17+
id: 'tpl_01',
18+
name: 'crm.welcome',
19+
label: 'Welcome',
20+
category: 'notification',
21+
locale: 'zh-CN',
22+
description: 'Sent on sign-up',
23+
subject: 'Welcome, {{user.name}}',
24+
body_html: '<p>Hello {{user.name}}</p>',
25+
body_text: 'Hello {{user.name}}',
26+
from_name: 'Acme Support',
27+
from_address: 'support@acme.test',
28+
reply_to: 'help@acme.test',
29+
active: 0,
30+
is_system: 1,
31+
variables_json: '[{"name":"user.name","type":"string","required":true}]',
32+
managed_by: 'package',
33+
customized: 1,
34+
organization_id: 'org_1',
35+
created_at: '2026-01-01T00:00:00.000Z',
36+
updated_at: '2026-01-02T00:00:00.000Z',
37+
};
38+
39+
const schemaKeys = (): Set<string> =>
40+
new Set(Object.keys((EmailTemplateDefinitionSchema as unknown as { shape: Record<string, unknown> }).shape));
41+
42+
describe('emailTemplateDefinitionFromRow', () => {
43+
it('answers the definition the row carries, in order, with no state or provenance column', () => {
44+
expect(emailTemplateDefinitionFromRow(storedRow)).toStrictEqual({
45+
name: 'crm.welcome',
46+
label: 'Welcome',
47+
locale: 'zh-CN',
48+
subject: 'Welcome, {{user.name}}',
49+
bodyHtml: '<p>Hello {{user.name}}</p>',
50+
bodyText: 'Hello {{user.name}}',
51+
variables: [{ name: 'user.name', type: 'string', required: true }],
52+
fromOverride: { name: 'Acme Support', address: 'support@acme.test' },
53+
replyTo: 'help@acme.test',
54+
active: false,
55+
description: 'Sent on sign-up',
56+
});
57+
});
58+
59+
it('answers a body the email_template schema accepts whole, with no key the schema does not declare', () => {
60+
const body = emailTemplateDefinitionFromRow(storedRow);
61+
const keys = schemaKeys();
62+
expect(Object.keys(body).filter((k) => !keys.has(k))).toEqual([]);
63+
expect(EmailTemplateDefinitionSchema.safeParse(body).success).toBe(true);
64+
});
65+
66+
it('falls back for the sparse row: label to name, locale to en-US, bodies to empty, active to true', () => {
67+
const body = emailTemplateDefinitionFromRow({
68+
name: 'auth.reset',
69+
label: '',
70+
locale: null,
71+
subject: null,
72+
body_html: null,
73+
body_text: '',
74+
from_name: 'Nobody',
75+
from_address: '',
76+
reply_to: null,
77+
variables_json: 'not json',
78+
description: null,
79+
});
80+
expect(body).toStrictEqual({
81+
name: 'auth.reset',
82+
label: 'auth.reset',
83+
locale: 'en-US',
84+
subject: '',
85+
bodyHtml: '',
86+
variables: [],
87+
active: true,
88+
});
89+
expect(EmailTemplateDefinitionSchema.safeParse(body).success).toBe(true);
90+
});
91+
92+
it('reads active the way the stored spellings mean it, and variables already parsed', () => {
93+
for (const off of [false, 0, '0', 'false']) {
94+
expect(emailTemplateDefinitionFromRow({ name: 'a', active: off }).active).toBe(false);
95+
}
96+
for (const on of [true, 1, '1', 'true', undefined, null]) {
97+
expect(emailTemplateDefinitionFromRow({ name: 'a', active: on }).active).toBe(true);
98+
}
99+
expect(emailTemplateDefinitionFromRow({ name: 'a', variables_json: [{ name: 'x' }] }).variables).toEqual([{ name: 'x' }]);
100+
expect(emailTemplateDefinitionFromRow({ name: 'a', variables_json: '{"name":"x"}' }).variables).toEqual([]);
101+
});
102+
103+
it('is deterministic: the same row answers the same serialization', () => {
104+
expect(JSON.stringify(emailTemplateDefinitionFromRow({ ...storedRow }))).toBe(
105+
JSON.stringify(emailTemplateDefinitionFromRow(storedRow)),
106+
);
107+
});
108+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D6/D10, ruling C on §6 Q1] A `sys_email_template` row, turned into
5+
* the `email_template` DEFINITION it carries.
6+
*
7+
* Templates resolve from the registry (`email_template` metadata); the rows are
8+
* no longer read on the send path. A row an organization customized before
9+
* that door closed is the one copy of the customer's edit, and ruling C
10+
* promotes it to an environment template before the row is deleted. The
11+
* upgrade ceremony (`os migrate`, C7b) reads the row, calls this function and
12+
* saves the answer through the metadata door. ⛔ Never at boot.
13+
*
14+
* The function is pure: one row in, one body out — no engine, no I/O, and no
15+
* import, so the lean entry carries it as it stands. The mapping is the
16+
* inverse of the row shape the retired projector wrote, which is also the
17+
* render input the email service reads (`EmailTemplateRow`):
18+
*
19+
* - `name` → `name`; `label` → `label`, falling back to the name when the
20+
* column is empty or absent;
21+
* - `locale` → `locale`, falling back to `en-US` (the schema's default and
22+
* the bundle floor) when empty or absent;
23+
* - `subject` → `subject` and `body_html` → `bodyHtml`, each `''` when empty
24+
* or absent (both are required strings);
25+
* - `body_text` → `bodyText`, `reply_to` → `replyTo` and `description` →
26+
* `description`, each only when a non-empty string;
27+
* - `from_address` → `fromOverride.address`, with `from_name` →
28+
* `fromOverride.name` when non-empty; no `fromOverride` without an address;
29+
* - `variables_json` → `variables` when it is JSON text (or an already
30+
* parsed value) holding an array, else `[]`;
31+
* - `active` → `active`: `false` for a stored `false`, `0`, `'0'` or
32+
* `'false'`, `true` for anything else, an absent column included (the
33+
* schema's default).
34+
*
35+
* The retired keys (`category`, `is_system`) and every state or provenance
36+
* column (`managed_by`, `customized`, `organization_id`, the timestamps) are
37+
* not a definition and never travel into one.
38+
*
39+
* ⛔ It is not validation. A name outside the schema's dotted snake_case still
40+
* answers a body; the metadata door refuses it, and reporting that refusal is
41+
* the ceremony's.
42+
*/
43+
44+
/**
45+
* One `sys_email_template` row, as the mapping reads it. Every other column a
46+
* row carries is accepted and ignored.
47+
*/
48+
export interface EmailTemplateRowForDefinition {
49+
readonly name?: unknown;
50+
readonly label?: unknown;
51+
readonly locale?: unknown;
52+
readonly description?: unknown;
53+
readonly subject?: unknown;
54+
readonly body_html?: unknown;
55+
readonly body_text?: unknown;
56+
readonly from_name?: unknown;
57+
readonly from_address?: unknown;
58+
readonly reply_to?: unknown;
59+
readonly active?: unknown;
60+
readonly variables_json?: unknown;
61+
readonly [column: string]: unknown;
62+
}
63+
64+
const filled = (value: unknown): string | undefined =>
65+
typeof value === 'string' && value !== '' ? value : undefined;
66+
67+
const variablesOf = (value: unknown): unknown[] => {
68+
let parsed = value;
69+
if (typeof value === 'string') {
70+
try {
71+
parsed = JSON.parse(value);
72+
} catch {
73+
return [];
74+
}
75+
}
76+
return Array.isArray(parsed) ? parsed : [];
77+
};
78+
79+
/**
80+
* The `email_template` definition one `sys_email_template` row carries (module
81+
* doc). Deterministic: the same row answers the same body, key for key and in
82+
* the same key order.
83+
*/
84+
export function emailTemplateDefinitionFromRow(row: EmailTemplateRowForDefinition): Record<string, unknown> {
85+
const body: Record<string, unknown> = {
86+
name: row.name,
87+
label: filled(row.label) ?? row.name,
88+
locale: filled(row.locale) ?? 'en-US',
89+
subject: filled(row.subject) ?? '',
90+
bodyHtml: filled(row.body_html) ?? '',
91+
};
92+
const bodyText = filled(row.body_text);
93+
if (bodyText !== undefined) body.bodyText = bodyText;
94+
body.variables = variablesOf(row.variables_json);
95+
const address = filled(row.from_address);
96+
if (address !== undefined) {
97+
const name = filled(row.from_name);
98+
body.fromOverride = name === undefined ? { address } : { name, address };
99+
}
100+
const replyTo = filled(row.reply_to);
101+
if (replyTo !== undefined) body.replyTo = replyTo;
102+
body.active = !(row.active === false || row.active === 0 || row.active === '0' || row.active === 'false');
103+
const description = filled(row.description);
104+
if (description !== undefined) body.description = description;
105+
return body;
106+
}

‎packages/core/src/security/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,10 @@ export {
290290
positionDefinitionFromRow,
291291
type PositionRowForDefinition,
292292
} from './position-row-definition.js';
293+
export {
294+
emailTemplateDefinitionFromRow,
295+
type EmailTemplateRowForDefinition,
296+
} from './email-template-row-definition.js';
293297

294298
// [commit f8eb73601] The measured read surface of the administrator derivation — the
295299
// single source `plugin-auth`'s break-glass standing-key lists correspond to.

0 commit comments

Comments
 (0)