diff --git a/.changeset/localized-system-emails.md b/.changeset/localized-system-emails.md new file mode 100644 index 0000000000..3f902ab569 --- /dev/null +++ b/.changeset/localized-system-emails.md @@ -0,0 +1,9 @@ +--- +"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. Email HTML sets `lang` and `dir` on the root element, so right-to-left languages render correctly. A non-canonical site locale (`pt-br`) is normalized to its catalog (`pt-BR`); an unsupported value falls back to the requesting user's admin language. + +`@emdash-cms/auth`'s invite and magic-link builders (`buildInviteEmail`, `buildMagicLinkEmail`, now exported) accept optional injected copy and locale via new `emailStrings`/`emailLocale` config options (`InviteEmailStrings`/`MagicLinkEmailStrings`). `@emdash-cms/admin/locales` exports the copy resolvers `getInviteEmailStrings`/`getMagicLinkEmailStrings` and the BCP 47 matcher `matchLocale`. diff --git a/packages/admin/package.json b/packages/admin/package.json index fde211fc45..73ffa533be 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -17,6 +17,14 @@ "types": "./dist/locales/index.d.ts", "default": "./dist/locales/index.js" }, + "./locales/config": { + "types": "./dist/locales/config.d.ts", + "default": "./dist/locales/config.js" + }, + "./locales/emails": { + "types": "./dist/locales/emails.d.ts", + "default": "./dist/locales/emails.js" + }, "./locales/*": "./dist/locales/*", "./slugify": { "types": "./dist/slugify.d.ts", diff --git a/packages/admin/src/locales/config.ts b/packages/admin/src/locales/config.ts index b5946e5b85..c216332e64 100644 --- a/packages/admin/src/locales/config.ts +++ b/packages/admin/src/locales/config.ts @@ -71,7 +71,7 @@ for (const l of SUPPORTED_LOCALES) { * don't prevent matching. Supports script codes (zh-Hant -> zh-TW, zh-Hans -> zh-CN) * and falls back to base language (pt-PT -> pt-BR). */ -function matchLocale(tag: string): string | undefined { +export function matchLocale(tag: string): string | undefined { const trimmed = tag.trim(); if (!trimmed) return undefined; let canonical: string; diff --git a/packages/admin/src/locales/emails.ts b/packages/admin/src/locales/emails.ts new file mode 100644 index 0000000000..ed1ca36d92 --- /dev/null +++ b/packages/admin/src/locales/emails.ts @@ -0,0 +1,108 @@ +/** + * 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. + * 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. The shared `i18n` + * singleton holds the admin SPA's active locale; activating a different + * locale on it server-side would race with the UI. + */ +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.id, { siteName }, { message: descriptor.message }); +} + +/** 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..5661d780ba 100644 --- a/packages/admin/src/locales/index.ts +++ b/packages/admin/src/locales/index.ts @@ -1,12 +1,15 @@ 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, DEFAULT_LOCALE, getLocaleLabel, getLocaleDir, + matchLocale, resolveLocale, } from "./config.js"; export type { SupportedLocale } from "./config.js"; diff --git a/packages/admin/tests/locales/emails.test.ts b/packages/admin/tests/locales/emails.test.ts new file mode 100644 index 0000000000..656001060a --- /dev/null +++ b/packages/admin/tests/locales/emails.test.ts @@ -0,0 +1,51 @@ +/** + * System email copy resolution tests: 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 { SUPPORTED_LOCALES } from "../../src/locales/config.js"; +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 { code } of SUPPORTED_LOCALES) { + const strings = await getInviteEmailStrings(code, "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/admin/tsdown.config.ts b/packages/admin/tsdown.config.ts index 93954c7f60..a9d8224467 100644 --- a/packages/admin/tsdown.config.ts +++ b/packages/admin/tsdown.config.ts @@ -24,7 +24,17 @@ function linguiMacroPlugin(): Plugin { } export default defineConfig({ - entry: ["src/index.ts", "src/locales/index.ts", "src/portable-text-table.ts", "src/slugify.ts"], + // locales/config and locales/emails are separate server-safe entries: + // EmDash core imports them from API routes, where the locales barrel's + // React/Kumo graph must not be pulled into the server bundle. + entry: [ + "src/index.ts", + "src/locales/index.ts", + "src/locales/config.ts", + "src/locales/emails.ts", + "src/portable-text-table.ts", + "src/slugify.ts", + ], format: ["esm"], dts: true, clean: true, diff --git a/packages/auth/src/email-templates.test.ts b/packages/auth/src/email-templates.test.ts new file mode 100644 index 0000000000..0960ac53dc --- /dev/null +++ b/packages/auth/src/email-templates.test.ts @@ -0,0 +1,111 @@ +/** + * System email builder tests: the builders default to English + * and render injected localized copy verbatim (text) / escaped (HTML). + */ + +import { describe, expect, it } from "vitest"; + +import { buildInviteEmail, localeDir, 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("localeDir", () => { + it("flags RTL primary subtags as rtl", () => { + expect(localeDir("ar")).toBe("rtl"); + expect(localeDir("fa-IR")).toBe("rtl"); + expect(localeDir("he")).toBe("rtl"); + }); + + it("treats LTR and empty locales as ltr", () => { + expect(localeDir("en")).toBe("ltr"); + expect(localeDir("de-CH")).toBe("ltr"); + expect(localeDir("")).toBe("ltr"); + }); +}); + +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"`); + }); + + it("sets lang/dir on the root element when a locale is provided (RTL)", () => { + const message = buildInviteEmail(URL, "new@example.com", "Acme", undefined, "ar"); + + expect(message.html).toContain(''); + }); + + it("omits lang/dir when no locale is provided (defaults to ltr)", () => { + const message = buildInviteEmail(URL, "new@example.com", "Acme"); + + expect(message.html).toContain(""); + expect(message.html).not.toContain("dir="); + }); +}); + +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("