Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/localized-system-emails.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/admin/src/components/InviteAcceptPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
/>
Expand Down
107 changes: 107 additions & 0 deletions packages/admin/src/locales/emails.ts
Original file line number Diff line number Diff line change
@@ -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<keyof InviteEmailStrings, MessageDescriptor> = {
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<keyof MagicLinkEmailStrings, MessageDescriptor> = {
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<I18n> {
// 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<InviteEmailStrings> {
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<MagicLinkEmailStrings> {
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),
};
}
2 changes: 2 additions & 0 deletions packages/admin/src/locales/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
50 changes: 50 additions & 0 deletions packages/admin/tests/locales/emails.test.ts
Original file line number Diff line number Diff line change
@@ -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.",
);
});
});
111 changes: 111 additions & 0 deletions packages/auth/src/email-templates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* 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, 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", `<b>"Acme"</b>`);

expect(message.html).toContain("&lt;b&gt;&quot;Acme&quot;&lt;/b&gt;");
expect(message.html).not.toContain(`<b>"Acme"</b>`);
});

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('<html lang="ar" dir="rtl">');
});

it("omits lang/dir when no locale is provided (defaults to ltr)", () => {
const message = buildInviteEmail(URL, "new@example.com", "Acme");

expect(message.html).toContain("<html>");
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", `<script>Acme</script>`);

expect(message.html).not.toContain("<script>");
});
});
4 changes: 4 additions & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ export {
sendMagicLink,
verifyMagicLink,
MagicLinkError,
buildMagicLinkEmail,
type MagicLinkConfig,
type MagicLinkEmailStrings,
} from "./magic-link/index.js";

// Invite
Expand All @@ -88,10 +90,12 @@ export {
createInviteToken,
validateInvite,
completeInvite,
buildInviteEmail,
InviteError,
escapeHtml,
type InviteConfig,
type InviteTokenResult,
type InviteEmailStrings,
type EmailSendFn,
} from "./invite.js";

Expand Down
Loading
Loading