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, + } + )}

- +

-