Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/email-template-default-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/plugin-email": patch
"@objectstack/spec": patch
---

fix(plugin-email): a `sendTemplate` with no locale renders the documented en-US default, not an arbitrary row (#7731)

With an i18n bundle in `sys_email_template` — `en-US` and `zh-CN` rows under one
name — a `sendTemplate` call that named no `locale` rendered **zh-CN**, on two
consecutive fresh boots. Three declarations say en-US is the answer there
(`SendTemplateInput.locale`, `EmailTemplateDefinitionSchema.locale`, and
`sys_email_template`'s own object doc); the code asked the driver instead.

Two seams, both now answering from the contract:

- The `sys_email_template` loader moved out of `EmailServicePlugin` into
`createSysEmailTemplateLoader`. Its no-locale branch queries
`(name, 'en-US')` by name rather than `{ name }` unordered with `limit: 1`,
so no driver's row order can change the answer. Every query it issues carries
an `orderBy`, so duplicate rows for one locale resolve the same way on every
boot too.
- `EmailService.sendTemplate`'s ladder asks for `DEFAULT_TEMPLATE_LOCALE` when
the caller named no locale — the en-US fallback used to run only when a
locale *had* been named, so the no-locale path never reached it.

A bundle with no en-US row at all (a single-locale tenant) keeps rendering:
the lowest locale tag in the bundle is used, ordered rather than arbitrary.
Explicit locales are unchanged — exact match, then en-US. Language-only prefix
matching (`zh` → `zh-CN`) is still not performed; no contract declares it.

`SendTemplateInput.locale` (spec, doc-comment only) now spells the whole ladder
out, including that last rung — behaviour a caller can rely on has to be
declared where the contract is, not only where it is implemented.
20 changes: 6 additions & 14 deletions packages/plugins/plugin-email/src/email-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import {
type EmailPersistence,
type EmailQueueDelivery,
type TemplateLoader,
type EmailTemplateRow,
} from './email-service.js';
import { createSysEmailTemplateLoader } from './template-loader.js';
import {
makeTransport,
SmtpTransport,
Expand Down Expand Up @@ -544,19 +544,11 @@ export class EmailServicePlugin implements Plugin {
},
};

const templateLoader: TemplateLoader = {
async load(name, locale) {
const where: Record<string, unknown> = { name };
if (locale) where.locale = locale;
const rows = await (engine as any).find('sys_email_template', {
where,
limit: 1,
context: SYSTEM_CTX,
});
const row = Array.isArray(rows) ? rows[0] : (rows as any)?.data?.[0];
return (row as EmailTemplateRow) || null;
},
};
// Locale resolution lives in its own module (#7731) — the inline version
// here queried `{ name }` unordered with `limit: 1` whenever no locale
// was passed, which answered an i18n bundle with whatever row the driver
// yielded first instead of the documented en-US default.
const templateLoader: TemplateLoader = createSysEmailTemplateLoader(engine as any);

// Mutate the existing service instance so consumers that already
// captured a reference (e.g. AuthManager) see the upgrade.
Expand Down
43 changes: 38 additions & 5 deletions packages/plugins/plugin-email/src/email-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,10 +378,28 @@ function newId(): string {
return `${hex(8)}-${hex(4)}-4${hex(3)}-a${hex(3)}-${hex(12)}`;
}

/**
* The locale a template resolves to when the caller names none.
*
* Declared, not inferred: `SendTemplateInput.locale` (spec contract),
* `EmailTemplateDefinitionSchema.locale` and the `sys_email_template` object
* doc all say the service falls back to `en-US`. #7731 is what happened while
* one seam of the chain answered "whichever row the driver yields first"
* instead — a no-locale send rendered zh-CN out of an en-US + zh-CN bundle.
*/
export const DEFAULT_TEMPLATE_LOCALE = 'en-US';

/**
* Loader for sys_email_template rows. Injected by EmailServicePlugin
* on `kernel:ready`. Returns the best-matching row for `(name, locale)`
* or `null` when none exists / inactive.
*
* `locale` set → an EXACT match for that locale, or `null`; the en-US fallback
* lives in {@link EmailService.sendTemplate}'s ladder rather than in the
* loader, so a replacement loader cannot relocate the documented default.
* `locale` undefined → the loader's own deterministic best answer for the name
* (see `createSysEmailTemplateLoader`), which `sendTemplate` consults only as a
* last resort, when the bundle carries no en-US row at all.
*/
export interface TemplateLoader {
load(name: string, locale: string | undefined): Promise<EmailTemplateRow | null>;
Expand Down Expand Up @@ -1108,7 +1126,8 @@ export class EmailService implements IEmailService {

/**
* Render a named template from sys_email_template and deliver via
* send(). Looks up `(name, locale)` then falls back to `(name, 'en-US')`.
* send(). Looks up `(name, locale)` then falls back to
* `(name, {@link DEFAULT_TEMPLATE_LOCALE})`.
*/
async sendTemplate(input: SendTemplateInput): Promise<SendEmailResult> {
if (!input?.template) {
Expand All @@ -1118,13 +1137,27 @@ export class EmailService implements IEmailService {
if (!loader) {
throw new Error('TEMPLATE_NOT_FOUND: no templateLoader configured on EmailService');
}
// Locale ladder (#7731). The old first rung passed `undefined` to the
// loader whenever the caller named no locale, which is not a request for
// en-US — it is a request for *any* row, and that is exactly what came
// back (zh-CN, out of an en-US + zh-CN bundle, on two fresh boots). No
// locale means the DOCUMENTED default, so ask for it by name.
const preferred = input.locale && String(input.locale).trim();
let row = await loader.load(input.template, preferred || undefined);
if (!row && preferred && preferred !== 'en-US') {
row = await loader.load(input.template, 'en-US');
const wanted = preferred || DEFAULT_TEMPLATE_LOCALE;
let row = await loader.load(input.template, wanted);
if (!row && wanted !== DEFAULT_TEMPLATE_LOCALE) {
row = await loader.load(input.template, DEFAULT_TEMPLATE_LOCALE);
}
if (!row && !preferred) {
// A bundle with no en-US row at all. A caller that never named a locale
// did get *some* row here before (a zh-CN-only tenant is the realistic
// case) and hard-failing it now would swap one bug for an outage — so
// fall through to the loader's own no-locale answer, which is
// deterministic by contract rather than driver row order.
row = await loader.load(input.template, undefined);
}
if (!row) {
throw new Error(`TEMPLATE_NOT_FOUND: ${input.template} (locale=${preferred || 'en-US'})`);
throw new Error(`TEMPLATE_NOT_FOUND: ${input.template} (locale=${wanted})`);
}
if (row.active === false) {
throw new Error(`TEMPLATE_INACTIVE: ${input.template}`);
Expand Down
13 changes: 12 additions & 1 deletion packages/plugins/plugin-email/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,18 @@

export { EmailServicePlugin, resolveDurableQueue, resolveAttachmentStore } from './email-plugin.js';
export type { EmailServicePluginOptions } from './email-plugin.js';
export { LogTransport, normalizeMessage, formatAddress, EMAIL_SEND_QUEUE } from './email-service.js';
export {
LogTransport,
normalizeMessage,
formatAddress,
EMAIL_SEND_QUEUE,
DEFAULT_TEMPLATE_LOCALE,
} from './email-service.js';
export {
createSysEmailTemplateLoader,
type TemplateLoaderEngine,
type CreateTemplateLoaderOptions,
} from './template-loader.js';
export type {
EmailServiceOptions,
TemplateLoader,
Expand Down
119 changes: 119 additions & 0 deletions packages/plugins/plugin-email/src/template-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The `sys_email_template` {@link TemplateLoader} — locale resolution that
* answers from the DECLARED contract instead of from driver row order (#7731).
*
* ## What was wrong
*
* The loader used to be four lines inline in `EmailServicePlugin`: build
* `where = { name }`, add `where.locale` only when a locale was passed, then
* `find(..., { limit: 1 })` with **no ordering**. "First row of an unordered
* set" is whatever the driver happens to yield — so an i18n bundle with `en-US`
* and `zh-CN` rows under one name rendered `zh-CN` for a caller that named no
* locale at all, on two consecutive fresh boots. Nothing in the system declares
* that; three separate places declare the opposite:
*
* - `SendTemplateInput.locale` (spec contract): *"Falls back to `'en-US'`"*.
* - `EmailTemplateDefinitionSchema.locale`: *"the service picks the best match
* for the recipient's locale, falling back to `en-US`"*.
* - `sys_email_template`'s own object doc: *"Resolved by `(name, locale)`; the
* EmailService picks the best-matching locale for the recipient, falling
* back to `en-US`"*.
*
* ## The rule this implements
*
* Every branch below pins the row it wants in the `where` clause; none of them
* asks the store to pick a locale for us:
*
* 1. **A locale was named** → exact `(name, locale)` match, or `null`. The
* en-US fallback stays where it is documented — in `sendTemplate`'s ladder
* — so a replacement loader cannot silently relocate it.
* 2. **No locale** → `(name, 'en-US')`, the documented default. This is the
* #7731 fix: the *query* names en-US, so even a driver that honours no
* ordering at all cannot answer with a different locale.
* 3. **No locale and no en-US row** → the bundle's lowest locale tag, ordered.
* A store holding only `zh-CN` rows worked before this change (there was
* exactly one row to pick arbitrarily from), and refusing it now would
* trade one bug for an outage. Ordered rather than arbitrary, so the answer
* is the same on every boot.
*
* Language-only prefix matching (`zh` → `zh-CN`) is deliberately NOT here: no
* contract declares it, and inventing it would put a resolution rule in the
* code that no spec, doc or form describes.
*
* Ordering is spelled `orderBy` — the canonical engine key. `sort` is the wire
* spelling and `find()` rejects it outright, so a tie-break has to be spelled
* this way to exist at all.
*/

import { DEFAULT_TEMPLATE_LOCALE, type EmailTemplateRow, type TemplateLoader } from './email-service.js';
import { EMAIL_TEMPLATE_OBJECT } from './bootstrap-declared-email-templates.js';

/** System read context — template resolution is not an end-user query. */
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;

/**
* The slice of the ObjectQL engine this loader calls. Declared here rather than
* imported as `IDataEngine` so the seam a test has to fake is one method.
*/
export interface TemplateLoaderEngine {
find(object: string, query: Record<string, unknown>): Promise<unknown>;
}

/** Sort node shape as the engine spells it (`orderBy`, never the wire `sort`). */
interface TemplateSort { field: string; order: 'asc' | 'desc' }

/**
* Tie-break for a query that already pins `(name, locale)`: duplicates of one
* locale (an org overlay row landing beside the platform one) must still
* resolve the same way on every boot.
*/
const BY_ID: readonly TemplateSort[] = [{ field: 'id', order: 'asc' }];

/** Deterministic order for the unpinned last resort — lowest locale tag wins. */
const BY_LOCALE: readonly TemplateSort[] = [
{ field: 'locale', order: 'asc' },
{ field: 'id', order: 'asc' },
];

/** Options for {@link createSysEmailTemplateLoader}. */
export interface CreateTemplateLoaderOptions {
/** Backing object name; overridable for tests. */
object?: string;
}

/**
* Build the `sys_email_template` loader `EmailService` resolves templates
* through. See the module doc for the resolution rule it implements.
*/
export function createSysEmailTemplateLoader(
engine: TemplateLoaderEngine,
options: CreateTemplateLoaderOptions = {},
): TemplateLoader {
const object = options.object ?? EMAIL_TEMPLATE_OBJECT;

const first = async (
where: Record<string, unknown>,
orderBy: readonly TemplateSort[],
): Promise<EmailTemplateRow | null> => {
const rows = await engine.find(object, {
where,
orderBy: orderBy.map((s) => ({ ...s })),
limit: 1,
context: SYSTEM_CTX,
});
const row = Array.isArray(rows) ? rows[0] : (rows as any)?.data?.[0];
return (row as EmailTemplateRow) || null;
};

return {
async load(name, locale) {
if (locale) return first({ name, locale }, BY_ID);
const preferred = await first({ name, locale: DEFAULT_TEMPLATE_LOCALE }, BY_ID);
if (preferred) return preferred;
// No en-US row in this bundle — rule 3 in the module doc.
return first({ name }, BY_LOCALE);
},
};
}
Loading
Loading