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