Skip to content

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

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-7731-email-default-locale
Aug 11, 2026
Merged

fix(plugin-email): a no-locale sendTemplate renders the en-US default, not an arbitrary row (#7731)#7787
huangyiirene merged 3 commits into
mainfrom
claude/issue-7731-email-default-locale

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #7731

The bug

With an i18n bundle in sys_email_template (en-US + 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 (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"

Premise verified against origin/main (7a8476f), still valid. Both seams the issue names are present and unchanged, and each had to be right on its own for the default to hold:

  • email-plugin.ts:547 — the inline loader built where = { name }, added locale only when one was passed, then ran limit: 1 with no ordering. "First row of an unordered set" is whatever the driver yields.
  • email-service.ts:1139 — the en-US fallback ran only if (!row && preferred && …), so the no-locale path never reached it.

The fix

Locale resolution moved out of the plugin into template-loader.ts (createSysEmailTemplateLoader). Every branch pins the row it wants in the where clause and carries an orderBy, so neither locale selection nor duplicate-row tie-breaking reads storage order:

  1. locale named → exact (name, locale) match, or null. The en-US fallback stays in sendTemplate's ladder, where it is documented, so a replacement loader can't relocate it.
  2. no locale(name, 'en-US'). 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 single-locale (e.g. zh-CN-only) tenant had exactly one row to pick arbitrarily from before this change; hard-failing it now would swap one bug for an outage. Ordered rather than arbitrary, so it's the same answer on every boot.

EmailService.sendTemplate asks for DEFAULT_TEMPLATE_LOCALE by name when the caller named none, and only reaches rung 3 for a call that named no locale — an explicit locale is never widened to "any row".

Out of scope, deliberately (声明面从紧): language-only prefix matching (zhzh-CN) is still not performed. No contract declares it and the issue does not claim it should; the loader's module doc records the omission.

orderBy is the canonical engine key — find() rejects the wire spelling sort outright, so the tie-break has to be spelled that way to exist at all. A test pins that we never send sort.

Verification record

  • Reverse-verification — restored both pre-fix seams inside the new module and re-ran the new file: 8 of 18 fail, including the reported symptom (renders en-US for a no-locale send on a fresh boot (zh-CN inserted first)) and asks for en-US by name when the caller named no locale. The 10 that pass in both directions are the unchanged-behaviour pins (explicit exact match, explicit → en-US fallback) — which is what "unaffected" should look like. Reverted, suite green again.
  • Determinism is not insertion-order luck — the locale assertions run the same bundle through a driver holding [en-US, zh-CN] and one holding [zh-CN, en-US], plus one that ignores orderBy entirely. All three answer en-US, because the where clause names it.
  • pnpm build --filter=...@objectstack/plugin-email --filter=...@objectstack/spec70/70 successful.
  • plugin-email suite — 21 files / 320 tests pass (18 new).
  • packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts2/2 pass against a real booted kernel + driver, so the new orderBy queries are exercised end-to-end, not only against a fake engine.
  • pnpm typecheck (plugin-email, spec) and eslint --no-inline-config on every changed file — clean.
  • pnpm check:docs-audit-scope — green.
  • Changeset: .changeset/email-template-default-locale.md (patch × @objectstack/plugin-email, @objectstack/spec).

Spec doc-comment (second commit)

SendTemplateInput.locale documented the preference but was silent on the two rungs a caller actually depends on — that a call omitting locale starts at en-US, and what happens to a bundle with no en-US row. Rung 3 succeeding where the old TEMPLATE_NOT_FOUND bullet read as a hard failure is behaviour a caller can rely on, so it is declared where the contract lives. Doc-comment only: no schema, no shape, no behaviour change, no generated page derives from it.

Notes for the reviewer

  • #7733 (runtime PUT /meta materialization, same package) is untouched. This change is confined to the read side; the metadata-subscription path writes rows through upsertDeclaredEmailTemplate and is not on this diff.
  • A no-locale send still renders with renderOpts.locale unset (the runtime locale) even though the row resolved is now reliably en-US. That is pre-existing {{ ts | datetime }} behaviour and no contract speaks to it, so it is left alone rather than changed on the way past.

Generated by Claude Code

claude added 2 commits August 11, 2026 14:39
…, not an arbitrary row (#7731)

With an i18n bundle in `sys_email_template` (`en-US` + `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 chain asked the driver instead, in
two places that each had to be right for the default to hold:

- `EmailServicePlugin`'s inline loader built `where = { name }` and only added
  `locale` when one was passed, then ran `limit: 1` with NO ordering. "First
  row of an unordered set" is whatever the driver yields.
- `EmailService.sendTemplate`'s en-US fallback ran only when a locale HAD been
  named, so the no-locale path never reached it.

The loader moves to its own module, `createSysEmailTemplateLoader`, whose every
branch pins the row it wants in the `where` clause and carries an `orderBy`, so
neither locale selection nor duplicate-row tie-breaking depends on storage
order. `sendTemplate` asks for `DEFAULT_TEMPLATE_LOCALE` by name when the
caller named none.

A bundle with no en-US row at all keeps rendering — a single-locale tenant had
exactly one row to pick arbitrarily from before, and hard-failing it now would
swap one bug for an outage — but it resolves to the bundle's lowest locale tag,
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsLtjETXfWzP78bMxkyXmB
…ives (#7731)

`SendTemplateInput.locale` said "Falls back to `'en-US'`" and the
`TEMPLATE_NOT_FOUND` bullet said "no row matches `(name, locale|en-US)`" —
true, but silent about the two rungs a caller actually depends on: that a call
omitting `locale` STARTS at en-US rather than at an arbitrary row, and what
happens to a bundle that has no en-US row at all. Behaviour a caller can rely
on has to be declared where the contract is, not only where it is implemented.

Doc-comment only; no schema, no shape, no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsLtjETXfWzP78bMxkyXmB
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 6:10pm

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-email, @objectstack/spec.

107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/plugin-email, @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/environment-variables.mdx (via @objectstack/plugin-email)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-email, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/plugin-email, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Collaborator Author

CI note: the Test Core (1/3) red here is spec#test api-methods-batch-conformance — the base-branch red tracked in #7793 (sys_api_key gained update without bulk via #7769 on main; this PR's merge ref inherits it). Failing on the base, zero relation to this diff — ESLint / TypeScript / the other Test Core shards are green. Holding the ready-flip; will update the branch and land once #7793's stopgap merges.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review August 11, 2026 19:18
@huangyiirene
huangyiirene added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit e906126 Aug 11, 2026
27 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7731-email-default-locale branch August 11, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

email-template-render (b): sendTemplate with no locale renders an arbitrary locale instead of the en-US default

2 participants