From ddc3ba163dac5509c1f5d17bef7929bb8d00ef46 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:10:02 +0200 Subject: [PATCH 1/4] fix(i18n): localize invite, magic-link, and recovery emails (#915) --- .changeset/localized-system-emails.md | 7 ++ .../admin/src/components/InviteAcceptPage.tsx | 2 +- packages/admin/src/locales/emails.ts | 107 ++++++++++++++++++ packages/admin/src/locales/index.ts | 2 + packages/admin/tests/locales/emails.test.ts | 50 ++++++++ packages/auth/src/email-templates.test.ts | 84 ++++++++++++++ packages/auth/src/index.ts | 4 + packages/auth/src/invite.ts | 63 +++++++++-- packages/auth/src/magic-link/index.ts | 77 +++++++++++-- packages/core/src/api/email-locale.ts | 27 +++++ .../api/admin/users/[id]/send-recovery.ts | 7 ++ .../src/astro/routes/api/auth/invite/index.ts | 9 ++ .../astro/routes/api/auth/magic-link/send.ts | 7 ++ 13 files changed, 425 insertions(+), 21 deletions(-) create mode 100644 .changeset/localized-system-emails.md create mode 100644 packages/admin/src/locales/emails.ts create mode 100644 packages/admin/tests/locales/emails.test.ts create mode 100644 packages/auth/src/email-templates.test.ts create mode 100644 packages/core/src/api/email-locale.ts diff --git a/.changeset/localized-system-emails.md b/.changeset/localized-system-emails.md new file mode 100644 index 0000000000..f9ef8fe467 --- /dev/null +++ b/.changeset/localized-system-emails.md @@ -0,0 +1,7 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +"@emdash-cms/auth": minor +--- + +Localizes invite, magic-link, and account-recovery emails: they now follow the site locale (falling back to the requesting user's admin language) instead of always being sent in English. diff --git a/packages/admin/src/components/InviteAcceptPage.tsx b/packages/admin/src/components/InviteAcceptPage.tsx index 4ddd13b84d..21ecb1e946 100644 --- a/packages/admin/src/components/InviteAcceptPage.tsx +++ b/packages/admin/src/components/InviteAcceptPage.tsx @@ -65,7 +65,7 @@ function RegisterStep({ inviteData, token }: RegisterStepProps) { type="text" value={name} onChange={(e) => setName(e.target.value)} - placeholder="Jane Doe" + placeholder={t`Jane Doe`} autoComplete="name" autoFocus /> diff --git a/packages/admin/src/locales/emails.ts b/packages/admin/src/locales/emails.ts new file mode 100644 index 0000000000..4ffd47cd84 --- /dev/null +++ b/packages/admin/src/locales/emails.ts @@ -0,0 +1,107 @@ +/** + * Localized copy for system emails (invite, magic link / recovery). + * + * The email builders live in `@emdash-cms/auth`, which has no i18n + * machinery — they take final display strings and fall back to English + * (#915). This module resolves those strings from the admin's Lingui + * catalogs so the emails follow the site locale like the rest of the + * admin. It is server-side only (called from EmDash core API routes). + * + * The return shapes mirror `InviteEmailStrings` / `MagicLinkEmailStrings` + * in `@emdash-cms/auth` structurally; the types are duplicated here so + * the admin package doesn't need a dependency on the auth package. + */ + +import { setupI18n, type I18n, type MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; + +import { loadMessages } from "./loadMessages.js"; + +/** Mirrors `InviteEmailStrings` in `@emdash-cms/auth`. */ +export interface InviteEmailStrings { + subject: string; + textIntro: string; + textLinkInstruction: string; + htmlInstruction: string; + buttonLabel: string; + expiryNote: string; +} + +/** Mirrors `MagicLinkEmailStrings` in `@emdash-cms/auth`. */ +export interface MagicLinkEmailStrings { + subject: string; + textLinkInstruction: string; + htmlInstruction: string; + buttonLabel: string; + expiryNote: string; + ignoreNote: string; +} + +// Module-scope descriptors (msg) so Lingui extraction picks them up; +// resolved per call with the requested locale's catalog. The {siteName} +// placeholder is ICU MessageFormat, interpolated at resolve time. +const INVITE: Record = { + subject: msg({ message: "You've been invited to {siteName}" }), + textIntro: msg({ message: "You've been invited to join {siteName}." }), + textLinkInstruction: msg({ message: "Click this link to create your account:" }), + htmlInstruction: msg({ message: "Click the button below to create your account:" }), + buttonLabel: msg({ message: "Accept Invite" }), + expiryNote: msg({ message: "This link expires in 7 days." }), +}; + +const MAGIC_LINK: Record = { + subject: msg({ message: "Sign in to {siteName}" }), + textLinkInstruction: msg({ message: "Click this link to sign in to {siteName}:" }), + htmlInstruction: msg({ message: "Click the button below to sign in:" }), + buttonLabel: msg({ message: "Sign in" }), + expiryNote: msg({ message: "This link expires in 15 minutes." }), + ignoreNote: msg({ message: "If you didn't request this, you can safely ignore this email." }), +}; + +/** + * Build a standalone i18n instance for one resolution. Deliberately not + * the shared `i18n` singleton — these calls run server-side and must not + * race with (or reactivate) the admin SPA's active locale. + */ +async function i18nFor(locale: string): Promise { + // loadMessages falls back to the default (English) catalog for + // unknown locales, so an unconfigured/garbage locale yields English. + const messages = await loadMessages(locale); + return setupI18n({ locale, messages: { [locale]: messages } }); +} + +function resolver(i18n: I18n, siteName: string) { + return (descriptor: MessageDescriptor): string => i18n._({ ...descriptor, values: { siteName } }); +} + +/** Localized copy for the invite email, in the given locale. */ +export async function getInviteEmailStrings( + locale: string, + siteName: string, +): Promise { + const resolve = resolver(await i18nFor(locale), siteName); + return { + subject: resolve(INVITE.subject), + textIntro: resolve(INVITE.textIntro), + textLinkInstruction: resolve(INVITE.textLinkInstruction), + htmlInstruction: resolve(INVITE.htmlInstruction), + buttonLabel: resolve(INVITE.buttonLabel), + expiryNote: resolve(INVITE.expiryNote), + }; +} + +/** Localized copy for the sign-in (magic link / recovery) email. */ +export async function getMagicLinkEmailStrings( + locale: string, + siteName: string, +): Promise { + const resolve = resolver(await i18nFor(locale), siteName); + return { + subject: resolve(MAGIC_LINK.subject), + textLinkInstruction: resolve(MAGIC_LINK.textLinkInstruction), + htmlInstruction: resolve(MAGIC_LINK.htmlInstruction), + buttonLabel: resolve(MAGIC_LINK.buttonLabel), + expiryNote: resolve(MAGIC_LINK.expiryNote), + ignoreNote: resolve(MAGIC_LINK.ignoreNote), + }; +} diff --git a/packages/admin/src/locales/index.ts b/packages/admin/src/locales/index.ts index 4e0b6a7273..6b3fd78d95 100644 --- a/packages/admin/src/locales/index.ts +++ b/packages/admin/src/locales/index.ts @@ -1,6 +1,8 @@ export { useLocale } from "./useLocale.js"; export { LocaleDirectionProvider } from "./LocaleDirectionProvider.js"; export { loadMessages } from "./loadMessages.js"; +export { getInviteEmailStrings, getMagicLinkEmailStrings } from "./emails.js"; +export type { InviteEmailStrings, MagicLinkEmailStrings } from "./emails.js"; export { SUPPORTED_LOCALES, SUPPORTED_LOCALE_CODES, diff --git a/packages/admin/tests/locales/emails.test.ts b/packages/admin/tests/locales/emails.test.ts new file mode 100644 index 0000000000..f7c0313812 --- /dev/null +++ b/packages/admin/tests/locales/emails.test.ts @@ -0,0 +1,50 @@ +/** + * System email copy resolution tests (#915): the helpers resolve the + * module-scope descriptors through the Lingui catalog for the requested + * locale, interpolate the site name, and fall back to English for + * unknown locales. (Whether individual strings are translated depends + * on catalog coverage, so assertions stick to the English source and + * the fallback path.) + */ + +import { describe, expect, test } from "vitest"; + +import { getInviteEmailStrings, getMagicLinkEmailStrings } from "../../src/locales/emails.js"; + +describe("getInviteEmailStrings", () => { + test("resolves English copy with the site name interpolated", async () => { + const strings = await getInviteEmailStrings("en", "Acme"); + + expect(strings.subject).toBe("You've been invited to Acme"); + expect(strings.textIntro).toBe("You've been invited to join Acme."); + expect(strings.buttonLabel).toBe("Accept Invite"); + expect(strings.expiryNote).toBe("This link expires in 7 days."); + }); + + test("falls back to English for an unknown locale", async () => { + const strings = await getInviteEmailStrings("xx-XX", "Acme"); + + expect(strings.subject).toBe("You've been invited to Acme"); + }); + + test("resolves every field to a non-empty string for all enabled locales", async () => { + for (const locale of ["de", "ja", "ar", "pt-BR"]) { + const strings = await getInviteEmailStrings(locale, "Acme"); + for (const value of Object.values(strings)) { + expect(value).toBeTruthy(); + } + } + }); +}); + +describe("getMagicLinkEmailStrings", () => { + test("resolves English copy with the site name interpolated", async () => { + const strings = await getMagicLinkEmailStrings("en", "Acme"); + + expect(strings.subject).toBe("Sign in to Acme"); + expect(strings.textLinkInstruction).toBe("Click this link to sign in to Acme:"); + expect(strings.ignoreNote).toBe( + "If you didn't request this, you can safely ignore this email.", + ); + }); +}); diff --git a/packages/auth/src/email-templates.test.ts b/packages/auth/src/email-templates.test.ts new file mode 100644 index 0000000000..edd950363b --- /dev/null +++ b/packages/auth/src/email-templates.test.ts @@ -0,0 +1,84 @@ +/** + * System email builder tests (#915): the builders default to English + * and render injected localized copy verbatim (text) / escaped (HTML). + */ + +import { describe, expect, it } from "vitest"; + +import { buildInviteEmail, type InviteEmailStrings } from "./invite.js"; +import { buildMagicLinkEmail, type MagicLinkEmailStrings } from "./magic-link/index.js"; + +const URL = "https://example.com/_emdash/admin/invite/accept?token=abc"; + +describe("buildInviteEmail", () => { + it("defaults to English copy with the site name interpolated", () => { + const message = buildInviteEmail(URL, "new@example.com", "Acme"); + + expect(message.to).toBe("new@example.com"); + expect(message.subject).toBe("You've been invited to Acme"); + expect(message.text).toContain("You've been invited to join Acme."); + expect(message.text).toContain(URL); + expect(message.html).toContain("Accept Invite"); + }); + + it("renders injected localized copy", () => { + const strings: InviteEmailStrings = { + subject: "Du wurdest zu Acme eingeladen", + textIntro: "Du wurdest eingeladen, Acme beizutreten.", + textLinkInstruction: "Klicke auf diesen Link, um dein Konto zu erstellen:", + htmlInstruction: "Klicke auf den Button unten, um dein Konto zu erstellen:", + buttonLabel: "Einladung annehmen", + expiryNote: "Dieser Link läuft in 7 Tagen ab.", + }; + + const message = buildInviteEmail(URL, "new@example.com", "Acme", strings); + + expect(message.subject).toBe("Du wurdest zu Acme eingeladen"); + expect(message.text).toContain("Du wurdest eingeladen, Acme beizutreten."); + expect(message.text).toContain(URL); + expect(message.html).toContain("Einladung annehmen"); + expect(message.html).not.toContain("Accept Invite"); + }); + + it("HTML-escapes localized strings (site names and translations are untrusted)", () => { + const message = buildInviteEmail(URL, "new@example.com", `"Acme"`); + + expect(message.html).toContain("<b>"Acme"</b>"); + expect(message.html).not.toContain(`"Acme"`); + }); +}); + +describe("buildMagicLinkEmail", () => { + it("defaults to English copy with the site name interpolated", () => { + const message = buildMagicLinkEmail(URL, "user@example.com", "Acme"); + + expect(message.subject).toBe("Sign in to Acme"); + expect(message.text).toContain("Click this link to sign in to Acme:"); + expect(message.text).toContain(URL); + expect(message.html).toContain("Sign in"); + }); + + it("renders injected localized copy", () => { + const strings: MagicLinkEmailStrings = { + subject: "Bei Acme anmelden", + textLinkInstruction: "Klicke auf diesen Link, um dich bei Acme anzumelden:", + htmlInstruction: "Klicke auf den Button unten, um dich anzumelden:", + buttonLabel: "Anmelden", + expiryNote: "Dieser Link läuft in 15 Minuten ab.", + ignoreNote: "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.", + }; + + const message = buildMagicLinkEmail(URL, "user@example.com", "Acme", strings); + + expect(message.subject).toBe("Bei Acme anmelden"); + expect(message.text).toContain(URL); + expect(message.html).toContain("Anmelden"); + expect(message.html).not.toContain("Sign in to Acme"); + }); + + it("HTML-escapes localized strings", () => { + const message = buildMagicLinkEmail(URL, "user@example.com", ``); + + expect(message.html).not.toContain("