Skip to content

fix(i18n): localize invite, magic-link, and recovery emails - #1944

Open
swissky wants to merge 5 commits into
emdash-cms:mainfrom
swissky:fix/localized-system-emails
Open

fix(i18n): localize invite, magic-link, and recovery emails#1944
swissky wants to merge 5 commits into
emdash-cms:mainfrom
swissky:fix/localized-system-emails

Conversation

@swissky

@swissky swissky commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

System emails (user invites, magic-link sign-in, admin-initiated account recovery) were always sent in hardcoded English, bypassing the locale system entirely. On a site configured for German/Japanese/Arabic, invitees received English emails.

This PR routes the email copy through the admin's Lingui catalogs:

  • @emdash-cms/auth: buildInviteEmail() and the new buildMagicLinkEmail() accept an optional strings object (InviteEmailStrings / MagicLinkEmailStrings) and fall back to the existing English copy — the auth package stays i18n-free and fully backwards compatible.
  • @emdash-cms/admin: new server-side helpers getInviteEmailStrings(locale, siteName) / getMagicLinkEmailStrings(locale, siteName) resolve the copy from the existing Lingui catalogs via module-scope msg descriptors (picked up by locale:extract on merge).
  • emdash core: the invite, magic-link-send, and send-recovery routes resolve the email locale — site-wide emdash:locale option first, then the requesting user's admin locale (cookie / Accept-Language), then English — and pass the localized strings through. Same trade-off WordPress makes: the recipient's language is unknowable, so the site's language is the best signal.
  • Also localizes the last hardcoded string on the invite accept page (the name placeholder).

Localized strings are HTML-escaped in the HTML email body like the site name already was.

Closes #915

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Fable 5

Screenshots / test output

New tests: packages/auth/src/email-templates.test.ts (builder defaults, injected copy, HTML escaping — 6 tests) and packages/admin/tests/locales/emails.test.ts (catalog resolution, site-name interpolation, unknown-locale fallback — 4 tests). All pass locally alongside the existing invite/magic-link suites.

@changeset-bot

changeset-bot Bot commented Jul 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e5019b9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/release-service Patch
@emdash-cms/blocks Minor
create-emdash Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1944

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1944

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1944

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1944

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1944

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1944

emdash

npm i https://pkg.pr.new/emdash@1944

create-emdash

npm i https://pkg.pr.new/create-emdash@1944

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1944

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1944

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1944

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1944

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1944

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@1944

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@1944

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1944

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1944

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1944

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1944

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1944

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1944

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1944

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1944

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1944

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1944

commit: e5019b9

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach is sound: keep the auth package i18n-free by injecting final display strings, let the admin package own Lingui catalog resolution, and have EmDash core pick the locale from the site option or the requesting user's admin language. That separation matches the architecture and keeps the auth package backwards-compatible.

I checked the new/updated files, the export wiring between @emdash-cms/auth, @emdash-cms/admin, and emdash, the HTML-escaping changes in the email builders, the test files, and the changeset. The code is clean and the builders correctly fall back to English when no localized strings are passed.

One gap: the newly-added emdash core helper getEmailLocale implements the locale priority logic, but there is no unit or integration test in packages/core to exercise it. The existing tests cover the auth builders and the admin catalog resolution separately, but they do not prove that the site emdash:locale option wins over the request locale, or that the routes actually wire localized strings through. Per AGENTS.md's TDD convention, a bug fix should include a reproducing test for the new logic.

Comment thread packages/core/src/api/email-locale.ts Outdated
Comment on lines +20 to +27
export async function getEmailLocale(db: Kysely<Database>, request: Request): Promise<string> {
const options = new OptionsRepository(db);
const siteLocale = await options.get<string>("emdash:locale");
// loadMessages falls back to English for unsupported codes, so a
// free-form option value degrades safely.
if (typeof siteLocale === "string" && siteLocale) return siteLocale;
return resolveLocale(request);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This helper implements the locale-priority decision (site emdash:locale → request cookie/Accept-Language → English), but there is no test for it in packages/core/tests. The PR tests the auth builders and the admin catalog resolution in isolation, so the priority logic and route-level wiring are unverified. Add a unit test that stubs OptionsRepository and checks the fallback chain, or an integration test that sends an invite with a non-English site locale and asserts the email subject is localized.

Suggested change
export async function getEmailLocale(db: Kysely<Database>, request: Request): Promise<string> {
const options = new OptionsRepository(db);
const siteLocale = await options.get<string>("emdash:locale");
// loadMessages falls back to English for unsupported codes, so a
// free-form option value degrades safely.
if (typeof siteLocale === "string" && siteLocale) return siteLocale;
return resolveLocale(request);
}
export async function getEmailLocale(db: Kysely<Database>, request: Request): Promise<string> {
const options = new OptionsRepository(db);
const siteLocale = await options.get<string>("emdash:locale");
if (typeof siteLocale === "string" && siteLocale) return siteLocale;
return resolveLocale(request);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 8f009d6tests/unit/api/email-locale.test.ts covers the full chain against a real test DB: site emdash:locale wins over cookie and Accept-Language, cookie wins when no site locale is set, Accept-Language matching (including region tags like es-ES), unsupported cookie values falling through, and the English default with no signals.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond size/XL review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet size/L review/awaiting-author Reviewed; waiting on the author to respond labels Jul 11, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 11, 2026
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 17, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of #1944. The approach is still sound: keep @emdash-cms/auth i18n-free by injecting final display strings, resolve those strings in @emdash-cms/admin from the Lingui catalogs, and pick the locale in emdash core from the site option / request locale. My previous concern about missing test coverage for the new getEmailLocale helper is fixed by the new packages/core/tests/unit/api/email-locale.test.ts, which exercises site-option priority, cookie fallback, Accept-Language fallback, and the English default.

I re-read the new/updated files, the cross-package export wiring, the HTML-escaping changes in the email builders, the changeset, and the test files. The code is clean and backwards-compatible: the auth builders fall back to English when no localized strings are passed, and the new emailStrings fields are optional. No logic bugs or security issues found.

Two non-blocking suggestions remain:

  1. packages/core/src/api/email-locale.ts imports resolveLocale through the @emdash-cms/admin/locales barrel, which pulls in the Lingui macro module (emails.ts) even though getEmailLocale only needs locale resolution. Importing from @emdash-cms/admin/locales/config.js would keep the dependency narrower and avoid evaluating the macro-heavy module for this code path.

  2. The HTML email templates now support Arabic/Farsi/etc. content, but the root <html> element has no dir or lang attributes, so RTL text will render left-to-right. Adding direction/language metadata (e.g., dir="auto" and a plumbed lang) would make localized emails actually direction-correct for RTL locales.

Neither blocks merge.

Comment thread packages/core/src/api/email-locale.ts Outdated
* makes for its system mails.
*/

import { resolveLocale } from "@emdash-cms/admin/locales";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] getEmailLocale only needs resolveLocale, but this imports the @emdash-cms/admin/locales barrel. That transitively loads emails.ts, which contains Lingui msg macros that have to be compiled by the admin build / Astro Vite plugin. Importing the locale config directly avoids pulling the macro module into this code path.

Suggested change
import { resolveLocale } from "@emdash-cms/admin/locales";
import { resolveLocale } from "@emdash-cms/admin/locales/config.js";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked at this — importing ./locales/config.js directly doesn't actually avoid the issue: config.ts itself uses import.meta.env.DEV and the __EMDASH_PSEUDO_LOCALE__ global, so it needs the same Vite pipeline either way. Meanwhile admin.astro and the magic-link/invite routes already import the barrel (loadMessages, getInviteEmailStrings), so the barrel is on the runtime path regardless and tree-shaking keeps this import to resolveLocale + config.ts only. Moving resolveLocale to a Vite-free module would be a separate refactor of the locales package layout — happy to file it as a follow-up if you want it tracked.

<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.5; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="font-size: 24px; margin-bottom: 20px;">You've been invited to ${safeName}</h1>
<p>Click the button below to create your account:</p>
<h1 style="font-size: 24px; margin-bottom: 20px;">${escapeHtml(s.subject)}</h1>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The localized strings may now be RTL (Arabic, Farsi, etc.), but the email root has no dir or lang attributes, so RTL content will render LTR. Consider threading the locale/direction through the builder and setting them on the root <html> element; the same applies to packages/auth/src/magic-link/index.ts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the resolved locale is now threaded through to the invite/magic-link/recovery builders, which set lang + dir on the root <html> element. Direction is derived from the BCP 47 primary subtag via a small localeDir() helper in @emdash-cms/auth (keeps the auth package free of the admin dependency). Covered by new tests asserting <html lang="ar" dir="rtl">.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 17, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond review/needs-rereview Author pushed changes since the last review and removed review/needs-rereview Author pushed changes since the last review review/awaiting-author Reviewed; waiting on the author to respond labels Jul 17, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 19, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of #1944. The approach is still sound: keep @emdash-cms/auth i18n-free by injecting final display strings, resolve those strings in @emdash-cms/admin from the Lingui catalogs, and pick the locale in emDash core from the site option / request locale. This decouples the packages and keeps the auth API backwards-compatible.

The previous RTL/lang/dir concern is fixed: the email builders now thread locale through and emit <html lang="..." dir="rtl"> (or ltr) based on a primary-subtag RTL list. The new email-templates.test.ts covers this, and the new email-locale.test.ts exercises locale priority.

I re-read the changed files, the cross-package export wiring, the HTML-escaping changes, the changeset, and the tests. No logic bugs or security issues found. All findings are minor suggestions:

  1. Still open from the prior review: packages/core/src/api/email-locale.ts imports resolveLocale through the @emdash-cms/admin/locales barrel, which evaluates the macro-heavy emails.ts module even though getEmailLocale only needs locale resolution. Importing from @emdash-cms/admin/locales/config.js keeps the dependency narrower.
  2. New: the JSDoc for getEmailLocale says it loads the localized copy, but the function only resolves the locale string; callers load the copy.
  3. New: when the site-wide emdash:locale option is set, getEmailLocale returns it verbatim without canonicalizing the BCP 47 tag (unlike resolveLocale, which uses Intl.Locale). A value like "pt-br" would miss the "pt-BR" catalog and silently fall back to English.

Comment thread packages/core/src/api/email-locale.ts Outdated
* makes for its system mails.
*/

import { resolveLocale } from "@emdash-cms/admin/locales";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Importing resolveLocale through the @emdash-cms/admin/locales barrel still evaluates the macro-heavy emails.ts module, even though this file only needs locale resolution. The barrel re-exports getInviteEmailStrings / getMagicLinkEmailStrings, which import @lingui/core/macro at module scope.

This was flagged in the previous review and remains unaddressed. Importing from the narrower subpath avoids loading the macro module for this code path:

Suggested change
import { resolveLocale } from "@emdash-cms/admin/locales";
import { resolveLocale } from "@emdash-cms/admin/locales/config.js";

@emdash-cms/admin already exposes "./locales/*", so @emdash-cms/admin/locales/config.js resolves correctly.

Comment thread packages/core/src/api/email-locale.ts Outdated
Comment on lines +1 to +4
/**
* Resolve the locale for outbound system emails (invite, magic link,
* recovery), and load the matching localized copy from the admin
* catalogs (#915).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The JSDoc says this module "resolve[s] the locale ... and load the matching localized copy from the admin catalogs", but getEmailLocale only returns the locale string. The callers (invite/index.ts, magic-link/send.ts, send-recovery.ts) are the ones that call getInviteEmailStrings / getMagicLinkEmailStrings to load the copy. Update the doc to match the actual responsibility:

Suggested change
/**
* Resolve the locale for outbound system emails (invite, magic link,
* recovery), and load the matching localized copy from the admin
* catalogs (#915).
/**
* Resolve the locale for outbound system emails (invite, magic link,
* recovery) (#915).
*
* Priority: the site-wide `emdash:locale` option (explicit site
* language) -> the requesting user's admin locale (cookie /
* Accept-Language, i.e. the language the inviter works in) -> English.
* The recipient's language is unknowable server-side, so the site's
* language is the best available signal.
*
* Callers load the matching localized copy from the admin catalogs.
*/

// free-form option value degrades safely.
if (typeof siteLocale === "string" && siteLocale) return siteLocale;
return resolveLocale(request);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] When emdash:locale is set, the value is returned verbatim. resolveLocale canonicalizes BCP 47 tags (e.g. "pt-br" -> "pt-BR") and only returns supported locales, but this code bypasses that path.

A lowercase or otherwise non-canonical site option (e.g. "pt-br") will miss the "./pt-BR/messages.mjs" catalog and silently fall back to English. Reusing the same canonicalization/normalization that resolveLocale applies would keep behavior consistent and avoid surprising locale mismatches.

Suggested change
}
const siteLocale = await options.get<string>("emdash:locale");
if (typeof siteLocale === "string" && siteLocale) {
// Canonicalize the same way resolveLocale does so "pt-br" finds pt-BR.
const canonical = new Intl.Locale(siteLocale.trim()).baseName;
if (canonical) return canonical;
}
return resolveLocale(request);

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 19, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 19, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.

If you're still working on this, please push an update or leave a comment.

@github-actions github-actions Bot added the stale label Aug 3, 2026
@github-actions github-actions Bot removed the stale label Sep 6, 2026
@swissky
swissky force-pushed the fix/localized-system-emails branch from bb84501 to 8a8cdd2 Compare September 10, 2026 12:47
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Sep 10, 2026
@swissky

swissky commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (the branch was ~500 commits behind; the InviteAcceptPage placeholder fix from this PR has since landed on main and dropped out of the diff) and addressed the three open suggestions in 8a8cdd2:

  • Site locale canonicalization: emdash:locale now goes through the shared matchLocale (newly exported from the admin locales barrel), so pt-br finds the pt-BR catalog, script tags match (zh-Hantzh-TW), and unsupported values fall through to the requester's locale instead of silently degrading to English. Covered by two new tests. I used matchLocale rather than the suggested bare new Intl.Locale(...) because the latter throws on free-form values like "not a locale", which would have turned a bad option value into a 500 on the invite route.
  • Module doc: rewritten — the helper only resolves the locale; callers load the copy.
  • Barrel import: staying on @emdash-cms/admin/locales, now with a harder reason than my earlier reply: dist/locales/config.js doesn't exist as a build artifact. tsdown bundles config.ts into dist/locales/index.js; the ./locales/* wildcard export only serves the copied per-locale catalogs, so @emdash-cms/admin/locales/config.js would fail to resolve at runtime.

Also in this revision: the locale option is read in the same getMany as the site title, so no route (including the anonymous magic-link send) gains a query — resolveEmailLocale is now a pure function; a new route-level test covers the invite wiring end to end (site locale ar → sent email carries lang="ar" dir="rtl"); and the changeset now names the new public API surface of @emdash-cms/auth and @emdash-cms/admin/locales.

Full suites green locally (core 6447, auth 94, admin 2051).

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 10, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 10, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 10, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This remains a sound fix for the right problem: system emails were hard-coded in English, and the PR keeps @emdash-cms/auth i18n-free while localizing copy in @emdash-cms/admin and choosing the locale in emdash core. The approach fits EmDash’s package split, the new public options are backwards-compatible, and the prior lang/dir and BCP 47 normalization concerns are addressed.

I re-read the diff, the full changed files, the cross-package export wiring, the tests, and the changeset. The only remaining item from the prior review is a minor import-coupling issue; everything else is either fixed or clean. No logic bugs, security issues, or AGENTS.md violations found. Two minor suggestions below — both non-blocking.

Comment thread packages/core/src/api/email-locale.ts Outdated
* Accept-Language, i.e. the language the inviter works in) -> English.
*/

import { matchLocale, resolveLocale } from "@emdash-cms/admin/locales";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This module only needs matchLocale and resolveLocale, but it imports them from the @emdash-cms/admin/locales barrel. That barrel also re-exports getInviteEmailStrings / getMagicLinkEmailStrings (which depend on the Lingui macro) and React-wrapped providers/hooks, so loading email-locale.ts pulls in unrelated runtime surface.

Because matchLocale and resolveLocale are already exported from ./locales/config.js, import from the narrower subpath instead. This was also flagged in the prior review and is still open.

Suggested change
import { matchLocale, resolveLocale } from "@emdash-cms/admin/locales";
import { matchLocale, resolveLocale } from "@emdash-cms/admin/locales/config.js";

Comment on lines +1 to +10
/**
* Resolve the locale for outbound system emails (invite, magic link,
* recovery). Callers read the `emdash:locale` option (batched with
* their other options reads) and load the matching localized copy from
* the admin catalogs.
*
* Priority: the site-wide `emdash:locale` option (explicit site
* language) -> the requesting user's admin locale (cookie /
* Accept-Language, i.e. the language the inviter works in) -> English.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The JSDoc says callers read the option and load the matching localized copy, which reads as if this helper is responsible for both. It only resolves the locale string; callers load the catalog strings themselves. Clarify the doc so the boundary is obvious.

Suggested change
/**
* Resolve the locale for outbound system emails (invite, magic link,
* recovery). Callers read the `emdash:locale` option (batched with
* their other options reads) and load the matching localized copy from
* the admin catalogs.
*
* Priority: the site-wide `emdash:locale` option (explicit site
* language) -> the requesting user's admin locale (cookie /
* Accept-Language, i.e. the language the inviter works in) -> English.
*/
/**
* Resolve the locale for outbound system emails (invite, magic link,
* recovery). Callers use the returned locale to load localized copy from
* the admin catalogs.
*
* Priority: the site-wide `emdash:locale` option (explicit site
* language) -> the requesting user's admin locale (cookie /
* Accept-Language, i.e. the language the inviter works in) -> English.
*/

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 10, 2026
@swissky

swissky commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

The Smoke Tests failure on the previous push was real and is fixed in 6770bc5. Root cause, reproduced locally with the isolated-install cold-start test: the API routes' import of the @emdash-cms/admin/locales barrel pulled the React/Kumo chunk into the Cloudflare dev runner's server graph, and @lingui/core (newly reachable server-side through the email copy resolvers) was not in the workerd optimizeDeps pre-bundle list — its CJS pieces were served raw, so the packed install cold-started into exports is not defined.

Fix, following existing patterns in the repo:

  • locales/config and locales/emails are now dedicated server-safe build entries with their own subpath exports (mirroring ./slugify), so core's routes import exactly the locale/copy modules without the React graph. This also lands the narrower-import suggestion from the earlier review — it just needed the subpaths to exist as build artifacts first.
  • emdash > @emdash-cms/admin > @lingui/core joins the existing @lingui/react entry in the workerd pre-bundle list.

The isolated-install suite now passes locally on the Cloudflare target (6/6). The remaining E2E 2/8 failure is the known content-actions.spec.ts publication-date flake.

@swissky

swissky commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Both suggestions are in: the narrower locales/config subpath import landed in 6770bc5 (the review raced that push — the subpath now exists as a dedicated build entry, see the comment above), and the JSDoc boundary clarification is in the latest commit.

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 10, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is a sound, carefully-scoped fix for the right problem: system emails (invite, magic-link, recovery) were hard-coded in English, and the change localizes them without making @emdash-cms/auth depend on i18n machinery.

I re-read the full diff and the files it touches, traced the cross-package wiring, and checked the tests and changeset against AGENTS.md.

  • Approach: Keeping string resolution in @emdash-cms/admin and passing final display strings into @emdash-cms/auth’s builders fits EmDash’s package split, preserves backwards compatibility, and keeps the auth package i18n-free. Resolving the locale from emdash:locale → requester locale → English matches the documented trade-off.
  • Correctness: The resolveEmailLocale helper canonicalizes BCP-47 tags and falls back cleanly; builders HTML-escape both the site name and localized strings; email HTML sets lang/dir for RTL; the invite accept page placeholder is now wrapped for translation.
  • Queries: The new options.getMany(["emdash:site_title", "emdash:locale"]) call batches the existing site-title read with the new locale read, so there is no extra database round-trip on the affected routes.
  • Subpath exports: @emdash-cms/admin/locales/config and @emdash-cms/admin/locales/emails are added as separate server-safe build entries, so core routes do not pull the React/Kumo graph into the server bundle.
  • Changeset: The new changeset is user-facing, names the affected capability and public API additions, and follows the .changeset/README.md guidance.
  • Tests: Auth builder tests cover defaults, injected copy, escaping, and RTL lang/dir; admin tests cover catalog resolution and fallback; core locale tests cover priority/canonicalization; a route-level test covers the invite wiring.

The previously noted minor import-coupling concern (auth’s escapeHtml/localeDir shared from invite.ts into magic-link/index.ts) is still present, but it remains non-blocking for this bug fix and does not require refactoring here.

No new blocking issues, security problems, or AGENDS.md violations were found. This is clean work.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 10, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since needs-rebase and removed review/needs-rereview Author pushed changes since the last review labels Sep 10, 2026
System emails were always sent in hardcoded English, bypassing the
locale system. Route the copy through the admin Lingui catalogs:
auth builders accept optional strings objects (English fallback),
admin exposes getInviteEmailStrings/getMagicLinkEmailStrings, core
resolves the email locale (site option > requesting user's admin
locale > English) and threads lang/dir through for RTL.
Review follow-up: a non-canonical emdash:locale value (pt-br) now
matches its catalog via the shared matchLocale helper; unsupported
values fall through to the requester's locale. The locale option is
read in the same getMany as the site title, so no route gains a
query. resolveEmailLocale is a pure function; a route-level test
covers the invite wiring (site locale -> email lang/dir). Changeset
names the new public API surface of auth and admin.
The API routes' import of the admin locales barrel dragged the
React/Kumo chunk into the Cloudflare dev runner, and @lingui/core was
never pre-bundled for workerd, so the packed-install cold start died
with 'exports is not defined'. Give locales/config and locales/emails
their own server-safe build entries and subpath exports (mirroring
./slugify), import those from core, and pre-bundle @lingui/core like
the other admin SSR deps.
@swissky
swissky force-pushed the fix/localized-system-emails branch from d9488f6 to ac7a845 Compare September 11, 2026 14:41
@swissky

swissky commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (only conflict: the new portable-text-table build entry next to the locales/config / locales/emails entries). No code changes.

@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed needs-rebase review/approved Approved; no new commits since labels Sep 11, 2026
@swissky

swissky commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

The Visual Regression failure was real: pnpm locale:extract crashed on emails.ts because the resolver spread the descriptor into the i18n._() argument, and Lingui's extractor cannot walk a spread property. The resolver now uses the _(id, values, { message }) form. The Browser Tests (toolbar focus) and D1 Tests (migration replay timeout) failures touch no files from this PR; the same browser suite failed on main earlier today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n: InviteAcceptPage and buildInviteEmail() bypass the locale system (still English when site locale is non-English)

2 participants