diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 4586704aa8..4920fbe26c 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -40,6 +40,7 @@ "@objectstack/formula": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-approvals": "workspace:*", + "@objectstack/plugin-email": "workspace:*", "@objectstack/service-automation": "workspace:*", "@objectstack/service-messaging": "workspace:*", "@playwright/test": "^1.62.1", diff --git a/examples/app-showcase/src/system/emails/index.ts b/examples/app-showcase/src/system/emails/index.ts index 93a398c0b9..cbb9eb6453 100644 --- a/examples/app-showcase/src/system/emails/index.ts +++ b/examples/app-showcase/src/system/emails/index.ts @@ -1,20 +1,49 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -/** Email template fired by the Task Completed flow. */ -export const TaskDoneEmail = { +import { defineEmailTemplateDefinition } from '@objectstack/spec'; + +/** + * Email template declared for the Task Completed flow. + * + * ## Why `locale` is `en-US`, and why it is spelled out + * + * `sendTemplate`'s ladder is **exact match → `en-US` → (no-locale calls only) + * the bundle's lowest tag**, with deliberately no language-prefix matching: + * `en` does not satisfy `en-US`. Because `en-US` is the ladder's own second + * rung, a row authored at `en-US` is reachable from *every* call shape — an + * explicit `en-US`, this app's `defaultLocale: 'en'` (which the notify path + * passes as the recipient locale), and a call naming no locale at all. Any + * other tag is reachable from strictly fewer: this row used to say `en`, which + * made `sendTemplate({ locale: 'en-US' })` fail with `TEMPLATE_NOT_FOUND`. + * + * The key is written out rather than left to the schema default because the + * example corpus is what gets copied: the tag is the bundle key a second + * language row has to match, and `content/docs/automation/email-templates.mdx` + * teaches authoring the tags your callers actually pass. + * + * ## Not wired to the flow yet + * + * `showcase_task_completed`'s notify node demonstrates the inline + * `title`/`message` path, which the node schema makes mutually exclusive with + * `template` — so referencing this template there is a substitution, not an + * addition: it would cost the script node's `{summary}` its only consumer, and + * that consumption is what the flow exists to demonstrate. Tracked as its own + * decision in #10394 rather than smuggled in here. + */ +export const TaskDoneEmail = defineEmailTemplateDefinition({ name: 'showcase_task_done_email', label: 'Task Done Notification', - category: 'workflow' as const, - locale: 'en', + category: 'workflow', + locale: 'en-US', subject: '✅ Task done: {{title}}', bodyHtml: '

The task {{title}} on project {{project}} was marked done.

', bodyText: 'The task {{title}} on project {{project}} was marked done.', variables: [ - { name: 'title', type: 'string' as const, required: true, description: 'Task title' }, - { name: 'project', type: 'string' as const, required: false, description: 'Project name' }, + { name: 'title', type: 'string', required: true, description: 'Task title' }, + { name: 'project', type: 'string', required: false, description: 'Project name' }, ], active: true, isSystem: false, -}; +}); export const allEmails = [TaskDoneEmail]; diff --git a/examples/app-showcase/test/email-template-locale.test.ts b/examples/app-showcase/test/email-template-locale.test.ts new file mode 100644 index 0000000000..ff427fcc9f --- /dev/null +++ b/examples/app-showcase/test/email-template-locale.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The showcase's declared email templates have to be REACHABLE, and the only +// way to know that is to resolve them. +// +// The bug this pins: `showcase_task_done_email` declared `locale: 'en'`. +// `sendTemplate`'s ladder is exact match → `en-US` → (no-locale calls only) the +// bundle's lowest tag, with deliberately no language-prefix matching, so `en` +// never satisfies `en-US`. Measured against the old declaration, through the +// same loader used below: `load(name, 'en-US')` answered `null`, and a +// `sendTemplate({ locale: 'en-US' })` threw +// `TEMPLATE_NOT_FOUND: showcase_task_done_email (locale=en-US)`. A no-locale +// send still worked — by falling through to rung 3, the arbitrary-looking +// "lowest tag in the bundle" — which is exactly why the defect was latent. +// +// So these assertions run the DECLARED templates through the real boot path: +// the canonical schema parse, the real `mapTemplateToRow` projection into +// `sys_email_template` columns, and the real `createSysEmailTemplateLoader`. +// Nothing here inspects the source literal's strings; every verdict is a +// resolution. The composition of rungs 1-3 itself is plugin-email's own +// contract and is pinned there (`template-locale-resolution.test.ts`). + +import { describe, it, expect } from 'vitest'; +import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system'; +import { + createSysEmailTemplateLoader, + mapTemplateToRow, + EMAIL_TEMPLATE_OBJECT, + DEFAULT_TEMPLATE_LOCALE, +} from '@objectstack/plugin-email'; +import { allEmails, TaskDoneEmail } from '../src/system/emails/index.js'; + +type Row = Record & { id: string }; + +/** What the boot seeder writes: schema parse, then the shared column mapping. */ +function materialize(templates: readonly unknown[]): Row[] { + return templates.map((t, i) => ({ + id: `row-${i}`, + ...mapTemplateToRow(EmailTemplateDefinitionSchema.parse(t) as never), + })); +} + +/** + * A driver-ish engine over the materialized rows — filters by `where`, honours + * `orderBy`, then `limit`. Mirrors the fake plugin-email's own resolution + * suite uses, so the loader is exercised the way a real store exercises it. + */ +function engine(rows: Row[]) { + return { + async find(object: string, query: Record) { + expect(object).toBe(EMAIL_TEMPLATE_OBJECT); + const where = (query.where ?? {}) as Record; + let out = rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v)); + const orderBy = query.orderBy as Array<{ field: string; order?: string }> | undefined; + if (Array.isArray(orderBy)) { + out = [...out].sort((a, b) => { + for (const { field, order } of orderBy) { + const av = String(a[field] ?? ''); + const bv = String(b[field] ?? ''); + if (av !== bv) return (av < bv ? -1 : 1) * (order === 'desc' ? -1 : 1); + } + return 0; + }); + } + return typeof query.limit === 'number' ? out.slice(0, query.limit) : out; + }, + }; +} + +const loader = () => createSysEmailTemplateLoader(engine(materialize(allEmails)) as never); + +describe('showcase email templates — declared tags the send ladder can actually reach', () => { + it('resolves `showcase_task_done_email` for an explicit en-US send', async () => { + const found = await loader().load('showcase_task_done_email', DEFAULT_TEMPLATE_LOCALE); + + // The regression, stated as the send that used to fail. For an explicit + // `en-US` the loader IS the whole ladder: rung 1 and rung 2 name the same + // tag, so a null here is a `TEMPLATE_NOT_FOUND` throw at the service. + expect(found).not.toBeNull(); + expect(found?.locale).toBe('en-US'); + }); + + it('resolves THIS template, not some other row of the bundle', async () => { + const found = await loader().load('showcase_task_done_email', DEFAULT_TEMPLATE_LOCALE); + + // A resolution that answers the wrong row is the failure mode a bare + // "not null" assertion cannot see, so pin the identity of what came back. + expect(found?.name).toBe('showcase_task_done_email'); + expect(found?.subject).toBe(TaskDoneEmail.subject); + expect(found?.body_html).toBe(TaskDoneEmail.bodyHtml); + }); + + it('answers a no-locale send from the default rung, not from the lowest-tag rung', async () => { + // Rung 3 ("no en-US row at all ⇒ the bundle's lowest tag") is a + // keep-the-tenant-working fallback, not a place an authored corpus should + // be living. With the row at en-US this send is answered by rung 2. + const found = await loader().load('showcase_task_done_email', undefined); + expect(found?.locale).toBe(DEFAULT_TEMPLATE_LOCALE); + }); + + it('does NOT answer the language-only tag `en` — and does not need to', async () => { + // Pinned in the true direction: there is no prefix matching in either + // direction, so an `en` lookup misses rung 1 by design. It still delivers, + // because rung 2 of the service ladder is `en-US` — which is precisely why + // `en-US` is the tag reachable from every call shape and `en` is not. + expect(await loader().load('showcase_task_done_email', 'en')).toBeNull(); + }); + + it('every declared template in the corpus is reachable at the default locale', async () => { + // The class guard: a template added later cannot reintroduce the defect + // by picking a tag the ladder's default rung does not name. + for (const template of allEmails) { + const name = EmailTemplateDefinitionSchema.parse(template).name; + const found = await loader().load(name, DEFAULT_TEMPLATE_LOCALE); + expect(found, `${name} is unreachable at ${DEFAULT_TEMPLATE_LOCALE}`).not.toBeNull(); + } + }); + + it('every declared template went through `defineEmailTemplateDefinition`', async () => { + // The second half of the card: the literal used to be exported bare, so + // `EmailTemplateDefinitionSchema.parse()` never ran at authoring time. A + // definition that has been through the factory is a FIXED POINT of the + // parse (every default already applied); a bare literal is not. + for (const template of allEmails) { + expect(EmailTemplateDefinitionSchema.parse(template)).toEqual(template); + } + }); +}); diff --git a/examples/app-showcase/tsconfig.json b/examples/app-showcase/tsconfig.json index e7a576d205..20167e6b9f 100644 --- a/examples/app-showcase/tsconfig.json +++ b/examples/app-showcase/tsconfig.json @@ -27,8 +27,19 @@ // `dist` typechecks green over an engine contract that has since moved. // `pnpm check:type-source-resolution` is the gate; it wants the `paths` // rule, not a registry entry. + // + // Same rule, same reason, for the email plugin: `test/email-template-locale.test.ts` + // resolves this app's declared email templates through plugin-email's real + // `sys_email_template` loader and its real column mapping. Unaliased, its TYPES + // come from `packages/plugins/plugin-email/dist/*.d.ts`, so `tsc --noEmit` would + // grade those declarations against whatever was last built rather than against the + // locale ladder in this checkout. Bare key, no `*`: a tsconfig `paths` key without + // a star is an EXACT match, and the `@objectstack/plugin-email*` spelling would + // fold every subpath onto this one target and type-check green against the wrong + // module. "paths": { - "@objectstack/formula": ["../../packages/formula/src/index.ts"] + "@objectstack/formula": ["../../packages/formula/src/index.ts"], + "@objectstack/plugin-email": ["../../packages/plugins/plugin-email/src/index.ts"] } }, // This package took the widened-`include` route rather than a sibling diff --git a/examples/app-showcase/vitest.config.ts b/examples/app-showcase/vitest.config.ts index 36cd179662..d677b284f6 100644 --- a/examples/app-showcase/vitest.config.ts +++ b/examples/app-showcase/vitest.config.ts @@ -24,8 +24,18 @@ export default defineConfig({ // PREFIX, so with a FILE replacement it would also swallow any subpath and // resolve it to `…/formula/src/index.ts/` — `ENOTDIR` at run time, // from a config that reads as correct. + // + // `test/email-template-locale.test.ts` resolves this app's declared email + // templates through plugin-email's real `sys_email_template` loader and its + // real column mapping. Through the workspace link that package resolves to + // `dist/` — a build artifact — so a stale dist would grade the declarations + // against an OLD locale ladder, which is the one thing those assertions + // exist to measure. `pnpm check:test-source-alias` is the gate, and its + // registry is shrink-only: the alias is the sanctioned remedy, never a new + // registry entry. alias: [ { find: /^@objectstack\/formula$/, replacement: path.resolve(__dirname, '../../packages/formula/src/index.ts') }, + { find: /^@objectstack\/plugin-email$/, replacement: path.resolve(__dirname, '../../packages/plugins/plugin-email/src/index.ts') }, ], }, test: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d12007ecd8..e56c87dd68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: '@objectstack/plugin-approvals': specifier: workspace:* version: link:../../packages/plugins/plugin-approvals + '@objectstack/plugin-email': + specifier: workspace:* + version: link:../../packages/plugins/plugin-email '@objectstack/service-automation': specifier: workspace:* version: link:../../packages/services/service-automation