Skip to content

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

Open
swissky wants to merge 2 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 2 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: 8f009d6

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

This PR includes changesets to release 16 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/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash 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/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: 8f009d6

@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 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 and removed review/needs-review No maintainer or bot review yet labels Jul 11, 2026
Review follow-up: unit-tests the site-locale -> cookie ->
Accept-Language -> English fallback against a real test database.
@github-actions github-actions Bot added size/XL review/needs-rereview Author pushed changes since the last review and removed 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/admin area/auth area/core bot:review Trigger an emdashbot code review on this PR review/needs-rereview Author pushed changes since the last review size/XL

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)

1 participant