From 5693618de4c9c06d9a8aacea2b6ac2a102a1f1c5 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 20 Mar 2026 23:28:51 -0400 Subject: [PATCH] Add translation hooks to transactional emails (#11785) * First pass * fix: Missing translations * fix: Missing translations * welcome * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * translations --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- plugins/email/server/auth/email.ts | 3 + .../email/templates/PasskeyCreatedEmail.tsx | 38 +++-- .../processors/PasskeyCreatedProcessor.ts | 1 + .../server/tasks/DeliverWebhookTask.ts | 1 + server/commands/accountProvisioner.ts | 1 + server/commands/userInviter.ts | 1 + server/commands/userProvisioner.ts | 1 + server/emails/templates/BaseEmail.tsx | 20 ++- .../templates/CollectionCreatedEmail.tsx | 32 +++-- .../templates/CollectionSharedEmail.tsx | 32 +++-- .../emails/templates/CommentCreatedEmail.tsx | 52 +++++-- .../templates/CommentMentionedEmail.tsx | 42 ++++-- .../emails/templates/CommentResolvedEmail.tsx | 37 +++-- .../templates/ConfirmTeamDeleteEmail.tsx | 19 ++- .../emails/templates/ConfirmUpdateEmail.tsx | 36 +++-- .../templates/ConfirmUserDeleteEmail.tsx | 24 ++-- .../templates/DocumentMentionedEmail.tsx | 22 +-- .../DocumentPublishedOrUpdatedEmail.tsx | 56 ++++++-- .../emails/templates/DocumentSharedEmail.tsx | 27 ++-- .../emails/templates/ExportFailureEmail.tsx | 29 ++-- .../emails/templates/ExportSuccessEmail.tsx | 27 ++-- .../templates/GroupCommentMentionedEmail.tsx | 47 +++++-- .../templates/GroupDocumentMentionedEmail.tsx | 28 ++-- .../emails/templates/InviteAcceptedEmail.tsx | 32 +++-- server/emails/templates/InviteEmail.tsx | 38 +++-- .../emails/templates/InviteReminderEmail.tsx | 48 ++++--- server/emails/templates/SigninEmail.tsx | 53 ++++--- .../emails/templates/WebhookDisabledEmail.tsx | 24 ++-- server/emails/templates/WelcomeEmail.tsx | 44 ++++-- server/emails/templates/components/Footer.tsx | 7 +- server/queues/processors/EmailsProcessor.ts | 10 ++ server/queues/tasks/ExportTask.ts | 2 + server/queues/tasks/InviteReminderTask.ts | 1 + server/routes/api/teams/teams.ts | 1 + server/routes/api/users/users.ts | 3 + shared/i18n/locales/en_US/translation.json | 131 ++++++++++++++++++ 36 files changed, 714 insertions(+), 256 deletions(-) diff --git a/plugins/email/server/auth/email.ts b/plugins/email/server/auth/email.ts index b1291ac149..e329b180d8 100644 --- a/plugins/email/server/auth/email.ts +++ b/plugins/email/server/auth/email.ts @@ -80,6 +80,7 @@ router.post( // send email to users email address with a short-lived token and code await new SigninEmail({ to: user.email, + language: user.language, token, teamUrl: team.url, client, @@ -171,6 +172,7 @@ const emailCallback = async (ctx: APIContext) => { if (user.isInvited) { await new WelcomeEmail({ to: user.email, + language: user.language, role: user.role, teamUrl: user.team.url, }).schedule(); @@ -179,6 +181,7 @@ const emailCallback = async (ctx: APIContext) => { if (inviter?.subscribedToEventType(NotificationEventType.InviteAccepted)) { await new InviteAcceptedEmail({ to: inviter.email, + language: inviter.language, inviterId: inviter.id, invitedName: user.name, teamUrl: user.team.url, diff --git a/plugins/passkeys/server/email/templates/PasskeyCreatedEmail.tsx b/plugins/passkeys/server/email/templates/PasskeyCreatedEmail.tsx index d8701973a1..af080247d3 100644 --- a/plugins/passkeys/server/email/templates/PasskeyCreatedEmail.tsx +++ b/plugins/passkeys/server/email/templates/PasskeyCreatedEmail.tsx @@ -29,29 +29,31 @@ export class PasskeyCreatedEmail extends BaseEmail { } protected subject() { - return `New passkey added to your ${env.APP_NAME} account`; + return this.t("New passkey added to your {{ appName }} account", { + appName: env.APP_NAME, + }); } protected preview() { - return "A new passkey was created for your account."; + return this.t("A new passkey was created for your account."); } protected renderAsText({ passkeyName, teamUrl }: Props) { return ` -New Passkey Created +${this.t("New Passkey Created")} -A new passkey has been added to your ${env.APP_NAME} account: +${this.t("A new passkey has been added to your {{ appName }} account", { appName: env.APP_NAME }) + ":"} ${passkeyName} -Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately. +${this.t("Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately.")} -You can manage your passkeys at any time: +${this.t("You can manage your passkeys at any time")}: ${teamUrl}/settings/passkeys --- -If you have any concerns about your account security, please contact a workspace admin. +${this.t("If you have any concerns about your account security, please contact a workspace admin.")} `; } @@ -63,24 +65,30 @@ If you have any concerns about your account security, please contact a workspace
- New Passkey Created -

A new passkey has been added to your {env.APP_NAME} account:

+ {this.t("New Passkey Created")} +

+ {this.t( + "A new passkey has been added to your {{ appName }} account", + { appName: env.APP_NAME } + ) + ":"} +

{passkeyName}

- Passkeys provide a secure, passwordless way to sign in to your - account. If you did not create this passkey, please review your - account security settings immediately. + {this.t( + "Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately." + )}

- +

- If you have any concerns about your account security, please contact - a workspace admin. + {this.t( + "If you have any concerns about your account security, please contact a workspace admin." + )}

diff --git a/plugins/passkeys/server/processors/PasskeyCreatedProcessor.ts b/plugins/passkeys/server/processors/PasskeyCreatedProcessor.ts index 7020e880aa..86fa185dd3 100644 --- a/plugins/passkeys/server/processors/PasskeyCreatedProcessor.ts +++ b/plugins/passkeys/server/processors/PasskeyCreatedProcessor.ts @@ -19,6 +19,7 @@ export class PasskeyCreatedProcessor extends BaseProcessor { await new PasskeyCreatedEmail({ to: user.email, + language: user.language, userId: user.id, passkeyId: userPasskey.id, passkeyName: userPasskey.name, diff --git a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts index ccb19ce458..883c740927 100644 --- a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts +++ b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts @@ -886,6 +886,7 @@ export default class DeliverWebhookTask extends BaseTask { if (createdBy && team) { await new WebhookDisabledEmail({ to: createdBy.email, + language: createdBy.language, teamUrl: team.url, webhookName: subscription.name, }).schedule(); diff --git a/server/commands/accountProvisioner.ts b/server/commands/accountProvisioner.ts index 32664cd9cc..e5fc6c1e87 100644 --- a/server/commands/accountProvisioner.ts +++ b/server/commands/accountProvisioner.ts @@ -198,6 +198,7 @@ async function accountProvisioner( if (isNewUser) { await new WelcomeEmail({ to: user.email, + language: user.language, role: user.role, teamUrl: team.url, }).schedule(); diff --git a/server/commands/userInviter.ts b/server/commands/userInviter.ts index af985cead5..25e493ebff 100644 --- a/server/commands/userInviter.ts +++ b/server/commands/userInviter.ts @@ -96,6 +96,7 @@ export default async function userInviter( await new InviteEmail({ to: invite.email, + language: newUser.language, name: invite.name, actorName: user.name, actorEmail: user.email, diff --git a/server/commands/userProvisioner.ts b/server/commands/userProvisioner.ts index e326f6aee5..d2d15cece9 100644 --- a/server/commands/userProvisioner.ts +++ b/server/commands/userProvisioner.ts @@ -185,6 +185,7 @@ export default async function userProvisioner( if (inviter) { await new InviteAcceptedEmail({ to: inviter.email, + language: inviter.language, inviterId: inviter.id, invitedName: existingUser.name, teamUrl: existingUser.team.url, diff --git a/server/emails/templates/BaseEmail.tsx b/server/emails/templates/BaseEmail.tsx index fe9c4d599a..fd4521b12f 100644 --- a/server/emails/templates/BaseEmail.tsx +++ b/server/emails/templates/BaseEmail.tsx @@ -2,10 +2,12 @@ import type { EmailAddress } from "addressparser"; import addressparser from "addressparser"; import type Bull from "bull"; import invariant from "invariant"; +import { t as i18nT } from "i18next"; import { subMinutes } from "date-fns"; import type { Node } from "prosemirror-model"; import { randomString } from "@shared/random"; import { TeamPreference } from "@shared/types"; +import { unicodeCLDRtoBCP47 } from "@shared/utils/date"; import { Day } from "@shared/utils/time"; import mailer from "@server/emails/mailer"; import env from "@server/env"; @@ -31,6 +33,8 @@ export enum EmailMessageCategory { export interface EmailProps { /** The email address being sent to. */ to: string | null; + /** The language of the receiving user in CLDR format (e.g. "en_US"). */ + language?: string | null; /** The notification that triggered the email, if any. */ notification?: Notification; } @@ -151,7 +155,7 @@ export default abstract class BaseEmail< let subject = this.subject(data); if (notification) { if (notification.createdAt < subMinutes(new Date(), 30)) { - subject = `Delayed notification: ${subject}`; + subject = `${this.t("Delayed notification")}: ${subject}`; } } @@ -224,6 +228,20 @@ export default abstract class BaseEmail< return ; } + /** + * Translate a string using the receiving user's language preference. + * + * @param key The translation key (plain English string). + * @param options Optional interpolation values. + * @returns The translated string. + */ + protected t(key: string, options?: Record): string { + return i18nT(key, { + ...options, + lng: unicodeCLDRtoBCP47(this.props.language ?? env.DEFAULT_LANGUAGE), + }) as string; + } + /** * Returns the subject of the email. * diff --git a/server/emails/templates/CollectionCreatedEmail.tsx b/server/emails/templates/CollectionCreatedEmail.tsx index c562c7a155..d3434a88ae 100644 --- a/server/emails/templates/CollectionCreatedEmail.tsx +++ b/server/emails/templates/CollectionCreatedEmail.tsx @@ -60,20 +60,24 @@ export default class CollectionCreatedEmail extends BaseEmail< } protected subject({ collection }: Props) { - return `“${collection.name}” created`; + return this.t("“{{ collectionName }}” created", { + collectionName: collection.name, + }); } protected preview({ collection }: Props) { - return `${collection.user.name} created a collection`; + return this.t("{{ userName }} created a collection", { + userName: collection.user.name, + }); } protected renderAsText({ teamUrl, collection }: Props) { return ` ${collection.name} -${collection.user.name} created the collection "${collection.name}" +${this.t("{{ userName }} created the collection “{{ collectionName }}”", { userName: collection.user.name, collectionName: collection.name })} -Open Collection: ${teamUrl}${collection.path} +${this.t("Open Collection")}: ${teamUrl}${collection.path} `; } @@ -84,22 +88,34 @@ Open Collection: ${teamUrl}${collection.path} return (
{collection.name}

- {collection.user.name} created the collection "{collection.name}". + {this.t( + "{{ userName }} created the collection “{{ collectionName }}”.", + { + userName: collection.user.name, + collectionName: collection.name, + } + )}

- +

-