setup localization for twenty-emails (#9806)

One of the steps to address #8128 

How to test:
Please change the locale in the settings and click on change password
button. A password reset email in the preferred locale will be sent.


![image](https://github.com/user-attachments/assets/2b0c2f81-5c4d-4e49-b021-8ee76e7872f2)

![image](https://github.com/user-attachments/assets/0453e321-e5aa-42ea-beca-86e2e97dbee2)

Todo:
- Remove the hardcoded locales for invitation, warn suspended workspace
email, clean suspended workspace emails
- Need to test invitation, email verification, warn suspended workspace
email, clean suspended workspace emails
- The duration variable `5 minutes` is always in english. Do we need to
do something about that? It does seems odd in case of chinese
translations.

Notes:
- Only tested the password reset , password update notify templates.
- Cant test email verification due to error during sign up `Internal
server error: New workspace setup is disabled`

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Anne Deepa Prasanna
2025-02-03 01:31:34 +05:30
committed by GitHub
parent 4b9414a002
commit 39e7f6cec3
58 changed files with 1752 additions and 344 deletions

View File

@ -1,5 +1,5 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@ -33,6 +33,7 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@ -336,6 +337,7 @@ export class AuthResolver {
@Mutation(() => EmailPasswordResetLink)
async emailPasswordResetLink(
@Args() emailPasswordResetInput: EmailPasswordResetLinkInput,
@Context() context: I18nContext,
): Promise<EmailPasswordResetLink> {
const resetToken =
await this.resetPasswordService.generatePasswordResetToken(
@ -345,6 +347,7 @@ export class AuthResolver {
return await this.resetPasswordService.sendEmailPasswordResetLink(
resetToken,
emailPasswordResetInput.email,
context.req.headers['x-locale'] || 'en',
);
}
@ -352,13 +355,18 @@ export class AuthResolver {
async updatePasswordViaResetToken(
@Args()
{ passwordResetToken, newPassword }: UpdatePasswordViaResetTokenInput,
@Context() context: I18nContext,
): Promise<InvalidatePassword> {
const { id } =
await this.resetPasswordService.validatePasswordResetToken(
passwordResetToken,
);
await this.authService.updatePassword(id, newPassword);
await this.authService.updatePassword(
id,
newPassword,
context.req.headers['x-locale'] || 'en',
);
return await this.resetPasswordService.invalidatePasswordResetToken(id);
}

View File

@ -3,10 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'node:crypto';
import { render } from '@react-email/components';
import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { APP_LOCALES } from 'twenty-shared';
import { Repository } from 'typeorm';
import { NodeEnvironment } from 'src/engine/core-modules/environment/interfaces/node-environment.interface';
@ -379,6 +380,7 @@ export class AuthService {
async updatePassword(
userId: string,
newPassword: string,
locale: keyof typeof APP_LOCALES,
): Promise<UpdatePassword> {
if (!userId) {
throw new AuthException(
@ -415,14 +417,11 @@ export class AuthService {
userName: `${user.firstName} ${user.lastName}`,
email: user.email,
link: this.domainManagerService.getBaseUrl().toString(),
locale,
});
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
this.emailService.send({
from: `${this.environmentService.get(

View File

@ -9,11 +9,11 @@ import {
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { ResetPasswordService } from './reset-password.service';
@ -149,6 +149,7 @@ describe('ResetPasswordService', () => {
const result = await service.sendEmailPasswordResetLink(
mockToken,
'test@example.com',
'en',
);
expect(result.success).toBe(true);
@ -162,6 +163,7 @@ describe('ResetPasswordService', () => {
service.sendEmailPasswordResetLink(
{} as any,
'nonexistent@example.com',
'en',
),
).rejects.toThrow(AuthException);
});

View File

@ -7,6 +7,7 @@ import { render } from '@react-email/render';
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import ms from 'ms';
import { PasswordResetLinkEmail } from 'twenty-emails';
import { APP_LOCALES } from 'twenty-shared';
import { IsNull, MoreThan, Repository } from 'typeorm';
import {
@ -106,6 +107,7 @@ export class ResetPasswordService {
async sendEmailPasswordResetLink(
resetToken: PasswordResetToken,
email: string,
locale: keyof typeof APP_LOCALES,
): Promise<EmailPasswordResetLink> {
const user = await this.userRepository.findOneBy({
email,
@ -133,16 +135,13 @@ export class ResetPasswordService {
long: true,
},
),
locale,
};
const emailTemplate = PasswordResetLinkEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
this.emailService.send({
from: `${this.environmentService.get(

View File

@ -5,6 +5,7 @@ import { render } from '@react-email/render';
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import ms from 'ms';
import { SendEmailVerificationLinkEmail } from 'twenty-emails';
import { APP_LOCALES } from 'twenty-shared';
import { Repository } from 'typeorm';
import {
@ -55,13 +56,12 @@ export class EmailVerificationService {
const emailData = {
link: verificationLink.toString(),
locale: 'en' as keyof typeof APP_LOCALES,
};
const emailTemplate = SendEmailVerificationLinkEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const html = render(emailTemplate);
const text = render(emailTemplate, {
plainText: true,

View File

@ -1,9 +1,9 @@
import { SendMailOptions } from 'nodemailer';
import { EmailSenderService } from 'src/engine/core-modules/email/email-sender.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@Processor(MessageQueue.emailQueue)
export class EmailSenderJob {

View File

@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
import { SendMailOptions } from 'nodemailer';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@Injectable()
export class EmailService {

View File

@ -7,15 +7,17 @@ import { messages as enMessages } from 'src/engine/core-modules/i18n/locales/gen
import { messages as esMessages } from 'src/engine/core-modules/i18n/locales/generated/es';
import { messages as frMessages } from 'src/engine/core-modules/i18n/locales/generated/fr';
import { messages as itMessages } from 'src/engine/core-modules/i18n/locales/generated/it';
import { messages as jaMessages } from 'src/engine/core-modules/i18n/locales/generated/ja';
import { messages as koMessages } from 'src/engine/core-modules/i18n/locales/generated/ko';
import { messages as pseudoEnMessages } from 'src/engine/core-modules/i18n/locales/generated/pseudo-en';
import { messages as ptBRMessages } from 'src/engine/core-modules/i18n/locales/generated/pt-BR';
import { messages as ptPTMessages } from 'src/engine/core-modules/i18n/locales/generated/pt-PT';
import { messages as zhHansMessages } from 'src/engine/core-modules/i18n/locales/generated/zh-Hans';
import { messages as zhHantMessages } from 'src/engine/core-modules/i18n/locales/generated/zh-Hant';
@Injectable()
export class I18nService implements OnModuleInit {
async onModuleInit() {
async loadTranslations() {
i18n.load('en', enMessages);
i18n.load('fr', frMessages);
i18n.load('pseudo-en', pseudoEnMessages);
@ -23,6 +25,7 @@ export class I18nService implements OnModuleInit {
i18n.load('de', deMessages);
i18n.load('it', itMessages);
i18n.load('es', esMessages);
i18n.load('ja', jaMessages);
i18n.load('pt-PT', ptPTMessages);
i18n.load('pt-BR', ptBRMessages);
i18n.load('zh-Hans', zhHansMessages);
@ -30,4 +33,8 @@ export class I18nService implements OnModuleInit {
i18n.activate('en');
}
async onModuleInit() {
this.loadTranslations();
}
}

View File

@ -7,6 +7,7 @@ import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { SendInviteLinkEmail } from 'twenty-emails';
import { APP_LOCALES } from 'twenty-shared';
import { IsNull, Repository } from 'typeorm';
import {
@ -296,13 +297,11 @@ export class WorkspaceInvitationService {
lastName: sender.lastName,
},
serverUrl: this.environmentService.get('SERVER_URL'),
locale: 'en' as keyof typeof APP_LOCALES,
};
const emailTemplate = SendInviteLinkEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const html = render(emailTemplate);
const text = render(emailTemplate, {
plainText: true,
});

View File

@ -4,7 +4,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { i18n } from '@lingui/core';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import isEmpty from 'lodash.isempty';
import { FieldMetadataType, isDefined } from 'twenty-shared';
import { APP_LOCALES, FieldMetadataType, isDefined } from 'twenty-shared';
import { DataSource, FindOneOptions, Repository } from 'typeorm';
import { v4 as uuidV4, v4 } from 'uuid';
@ -778,7 +778,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
async resolveTranslatableString(
fieldMetadata: FieldMetadataDTO,
labelKey: 'label' | 'description',
locale: string | undefined,
locale: keyof typeof APP_LOCALES | undefined,
): Promise<string> {
if (fieldMetadata.isCustom) {
return fieldMetadata[labelKey] ?? '';

View File

@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { render } from '@react-email/components';
import { render } from '@react-email/render';
import {
CleanSuspendedWorkspaceEmail,
WarnSuspendedWorkspaceEmail,
@ -176,12 +176,8 @@ export class CleanerWorkspaceService {
workspaceDisplayName,
};
const emailTemplate = CleanSuspendedWorkspaceEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
this.emailService.send({
to: workspaceMember.userEmail,