From 6c9541a23081c71224139f6f5f8c2e29bcee2dba Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Thu, 19 Jun 2025 13:13:49 +0100 Subject: [PATCH] feat(api): add a mailer Module --- api/src/app.module.ts | 36 +-------- api/src/mailer/mailer.module.ts | 78 +++++++++++++++++++ api/src/mailer/mailer.service.ts | 19 +++++ .../user/controllers/auth.controller.spec.ts | 3 +- .../user/controllers/user.controller.spec.ts | 4 +- .../user/services/invitation.service.spec.ts | 3 +- api/src/user/services/invitation.service.ts | 53 ++++++------- .../services/passwordReset.service.spec.ts | 3 +- .../user/services/passwordReset.service.ts | 53 ++++++------- .../services/validate-account.service.spec.ts | 3 +- .../user/services/validate-account.service.ts | 53 ++++++------- api/src/user/user.module.ts | 2 + 12 files changed, 183 insertions(+), 127 deletions(-) create mode 100644 api/src/mailer/mailer.module.ts create mode 100644 api/src/mailer/mailer.service.ts diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 72465eb3..e3ce2694 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -9,14 +9,11 @@ import path from 'path'; import { CacheModule } from '@nestjs/cache-manager'; -// eslint-disable-next-line import/order -import { MailerModule } from '@nestjs-modules/mailer'; import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { MongooseModule } from '@nestjs/mongoose'; // eslint-disable-next-line import/order -import { MjmlAdapter } from '@nestjs-modules/mailer/dist/adapters/mjml.adapter'; import { CsrfGuard, CsrfModule } from '@tekuconcept/nestjs-csrf'; import { redisStore } from 'cache-manager-redis-yet'; import { @@ -24,7 +21,6 @@ import { I18nOptions, QueryResolver, } from 'nestjs-i18n'; -import SMTPTransport from 'nodemailer/lib/smtp-transport'; import { RedisClientOptions } from 'redis'; import { AnalyticsModule } from './analytics/analytics.module'; @@ -40,6 +36,7 @@ import extraModules from './extra'; import { HelperModule } from './helper/helper.module'; import { I18nModule } from './i18n/i18n.module'; import { LoggerModule } from './logger/logger.module'; +import { MailerModule } from './mailer/mailer.module'; import { MigrationModule } from './migration/migration.module'; import { NlpModule } from './nlp/nlp.module'; import { PluginsModule } from './plugins/plugins.module'; @@ -63,36 +60,7 @@ const i18nOptions: I18nOptions = { @Module({ imports: [ - ...(config.emails.isEnabled - ? [ - MailerModule.forRoot({ - transport: new SMTPTransport({ - ...config.emails.smtp, - logger: true, - debug: false, - }), - template: { - adapter: new MjmlAdapter( - 'handlebars', - { - inlineCssEnabled: false, - }, - { - handlebar: {}, - }, - ), - dir: path.join(process.cwd(), 'dist', 'templates'), - options: { - context: { - appName: config.parameters.appName, - appUrl: config.uiBaseUrl, - }, - }, - }, - defaults: { from: config.emails.from }, - }), - ] - : []), + MailerModule, MongooseModule.forRoot(config.mongo.uri, { dbName: config.mongo.dbName, connectionFactory: (connection) => { diff --git a/api/src/mailer/mailer.module.ts b/api/src/mailer/mailer.module.ts new file mode 100644 index 00000000..f9006e04 --- /dev/null +++ b/api/src/mailer/mailer.module.ts @@ -0,0 +1,78 @@ +/* + * Copyright © 2025 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import path from 'path'; + +import { Global, Module } from '@nestjs/common'; +import { + ISendMailOptions, + MAILER_OPTIONS, + MailerModule as NestjsMailerModule, +} from '@nestjs-modules/mailer'; +import { MjmlAdapter } from '@nestjs-modules/mailer/dist/adapters/mjml.adapter'; +import SMTPTransport from 'nodemailer/lib/smtp-transport'; + +import { config } from '@/config'; + +import { MailerService } from './mailer.service'; + +const mailerOptions = { + transport: new SMTPTransport({ + ...config.emails.smtp, + logger: true, + debug: false, + }), + template: { + adapter: new MjmlAdapter( + 'handlebars', + { + inlineCssEnabled: false, + }, + { + handlebar: {}, + }, + ), + dir: path.join(process.cwd(), 'dist', 'templates'), + options: { + context: { + appName: config.parameters.appName, + appUrl: config.uiBaseUrl, + }, + }, + }, + defaults: { from: config.emails.from }, +}; + +@Global() +@Module({ + imports: [ + ...(config.emails.isEnabled + ? [NestjsMailerModule.forRoot(mailerOptions)] + : []), + ], + providers: [ + { + provide: MAILER_OPTIONS, + useValue: mailerOptions, + }, + ...(config.emails.isEnabled + ? [MailerService] + : [ + { + provide: MailerService, + useValue: { + sendMail(_options: ISendMailOptions) { + throw new Error('Email Service is not enabled'); + }, + }, + }, + ]), + ], + exports: [MailerService], +}) +export class MailerModule {} diff --git a/api/src/mailer/mailer.service.ts b/api/src/mailer/mailer.service.ts new file mode 100644 index 00000000..ddde9172 --- /dev/null +++ b/api/src/mailer/mailer.service.ts @@ -0,0 +1,19 @@ +/* + * Copyright © 2025 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { MailerService as NestjsMailerService } from '@nestjs-modules/mailer'; + +/* + * Copyright © 2025 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +export class MailerService extends NestjsMailerService {} diff --git a/api/src/user/controllers/auth.controller.spec.ts b/api/src/user/controllers/auth.controller.spec.ts index 4b64c26c..5a8c6421 100644 --- a/api/src/user/controllers/auth.controller.spec.ts +++ b/api/src/user/controllers/auth.controller.spec.ts @@ -12,10 +12,11 @@ import { } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common/exceptions/bad-request.exception'; import { JwtService } from '@nestjs/jwt'; -import { ISendMailOptions, MailerService } from '@nestjs-modules/mailer'; +import { ISendMailOptions } from '@nestjs-modules/mailer'; import { SentMessageInfo } from 'nodemailer'; import { I18nService } from '@/i18n/services/i18n.service'; +import { MailerService } from '@/mailer/mailer.service'; import { getRandom } from '@/utils/helpers/safeRandom'; import { installLanguageFixtures } from '@/utils/test/fixtures/language'; import { installUserFixtures } from '@/utils/test/fixtures/user'; diff --git a/api/src/user/controllers/user.controller.spec.ts b/api/src/user/controllers/user.controller.spec.ts index a1bcd6c6..cf73cba9 100644 --- a/api/src/user/controllers/user.controller.spec.ts +++ b/api/src/user/controllers/user.controller.spec.ts @@ -8,11 +8,11 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import { ISendMailOptions, MailerService } from '@nestjs-modules/mailer'; import { Session as ExpressSession } from 'express-session'; import { SentMessageInfo } from 'nodemailer'; import { I18nService } from '@/i18n/services/i18n.service'; +import { MailerService } from '@/mailer/mailer.service'; import { IGNORED_TEST_FIELDS } from '@/utils/test/constants'; import { installLanguageFixtures } from '@/utils/test/fixtures/language'; import { installPermissionFixtures } from '@/utils/test/fixtures/permission'; @@ -64,7 +64,7 @@ describe('UserController', () => { { provide: MailerService, useValue: { - sendMail(_options: ISendMailOptions): Promise { + sendMail(_options: unknown): Promise { return Promise.resolve('Mail sent successfully'); }, }, diff --git a/api/src/user/services/invitation.service.spec.ts b/api/src/user/services/invitation.service.spec.ts index 4e1106f6..9bb9c663 100644 --- a/api/src/user/services/invitation.service.spec.ts +++ b/api/src/user/services/invitation.service.spec.ts @@ -7,10 +7,11 @@ */ import { JwtModule, JwtService } from '@nestjs/jwt'; -import { ISendMailOptions, MailerService } from '@nestjs-modules/mailer'; +import { ISendMailOptions } from '@nestjs-modules/mailer'; import { SentMessageInfo } from 'nodemailer'; import { I18nService } from '@/i18n/services/i18n.service'; +import { MailerService } from '@/mailer/mailer.service'; import { IGNORED_TEST_FIELDS } from '@/utils/test/constants'; import { installInvitationFixtures, diff --git a/api/src/user/services/invitation.service.ts b/api/src/user/services/invitation.service.ts index b510038d..04d1c6c7 100644 --- a/api/src/user/services/invitation.service.ts +++ b/api/src/user/services/invitation.service.ts @@ -6,19 +6,17 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -// eslint-disable-next-line import/order -import { MailerService } from '@nestjs-modules/mailer'; import { Inject, Injectable, InternalServerErrorException, - Optional, } from '@nestjs/common'; import { JwtService, JwtSignOptions } from '@nestjs/jwt'; import { config } from '@/config'; import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; +import { MailerService } from '@/mailer/mailer.service'; import { BaseService } from '@/utils/generics/base-service'; import { InvitationCreateDto } from '../dto/invitation.dto'; @@ -41,7 +39,7 @@ export class InvitationService extends BaseService< @Inject(JwtService) private readonly jwtService: JwtService, protected readonly i18n: I18nService, public readonly languageService: LanguageService, - @Optional() private readonly mailerService?: MailerService, + private readonly mailerService: MailerService, ) { super(repository); } @@ -61,31 +59,28 @@ export class InvitationService extends BaseService< */ async create(dto: InvitationCreateDto): Promise { const jwt = await this.sign({ ...dto }); - if (this.mailerService) { - try { - const defaultLanguage = await this.languageService.getDefaultLanguage(); - await this.mailerService.sendMail({ - to: dto.email, - template: 'invitation.mjml', - context: { - appName: config.parameters.appName, - appUrl: config.uiBaseUrl, - token: jwt, - // TODO: Which language should we use? - t: (key: string) => - this.i18n.t(key, { lang: defaultLanguage.code }), - }, - subject: this.i18n.t('invitation_subject'), - }); - } catch (e) { - this.logger.error( - 'Could not send email', - e.message, - e.stack, - 'InvitationService', - ); - throw new InternalServerErrorException('Could not send email'); - } + try { + const defaultLanguage = await this.languageService.getDefaultLanguage(); + await this.mailerService.sendMail({ + to: dto.email, + template: 'invitation.mjml', + context: { + appName: config.parameters.appName, + appUrl: config.uiBaseUrl, + token: jwt, + // TODO: Which language should we use? + t: (key: string) => this.i18n.t(key, { lang: defaultLanguage.code }), + }, + subject: this.i18n.t('invitation_subject'), + }); + } catch (e) { + this.logger.error( + 'Could not send email', + e.message, + e.stack, + 'InvitationService', + ); + throw new InternalServerErrorException('Could not send email'); } const newInvitation = await super.create({ ...dto, token: jwt }); return { ...newInvitation, token: jwt }; diff --git a/api/src/user/services/passwordReset.service.spec.ts b/api/src/user/services/passwordReset.service.spec.ts index 925401f3..44b0d8b9 100644 --- a/api/src/user/services/passwordReset.service.spec.ts +++ b/api/src/user/services/passwordReset.service.spec.ts @@ -9,12 +9,13 @@ import { NotFoundException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { getModelToken } from '@nestjs/mongoose'; -import { ISendMailOptions, MailerService } from '@nestjs-modules/mailer'; +import { ISendMailOptions } from '@nestjs-modules/mailer'; import { compareSync } from 'bcryptjs'; import { Model } from 'mongoose'; import { SentMessageInfo } from 'nodemailer'; import { I18nService } from '@/i18n/services/i18n.service'; +import { MailerService } from '@/mailer/mailer.service'; import { installLanguageFixtures } from '@/utils/test/fixtures/language'; import { installUserFixtures, users } from '@/utils/test/fixtures/user'; import { diff --git a/api/src/user/services/passwordReset.service.ts b/api/src/user/services/passwordReset.service.ts index 5edea610..27fa6867 100644 --- a/api/src/user/services/passwordReset.service.ts +++ b/api/src/user/services/passwordReset.service.ts @@ -6,15 +6,12 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -// eslint-disable-next-line import/order -import { MailerService } from '@nestjs-modules/mailer'; import { BadRequestException, Inject, Injectable, InternalServerErrorException, NotFoundException, - Optional, UnauthorizedException, } from '@nestjs/common'; import { JwtService, JwtSignOptions } from '@nestjs/jwt'; @@ -24,6 +21,7 @@ import { config } from '@/config'; import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; import { LoggerService } from '@/logger/logger.service'; +import { MailerService } from '@/mailer/mailer.service'; import { UserRequestResetDto, UserResetPasswordDto } from '../dto/user.dto'; @@ -37,7 +35,7 @@ export class PasswordResetService { private readonly userService: UserService, public readonly i18n: I18nService, public readonly languageService: LanguageService, - @Optional() private readonly mailerService?: MailerService, + private readonly mailerService: MailerService, ) {} public readonly jwtSignOptions: JwtSignOptions = { @@ -60,31 +58,28 @@ export class PasswordResetService { } const jwt = await this.sign({ ...dto }); - if (this.mailerService) { - try { - const defaultLanguage = await this.languageService.getDefaultLanguage(); - await this.mailerService.sendMail({ - to: dto.email, - template: 'password_reset.mjml', - context: { - appName: config.parameters.appName, - appUrl: config.uiBaseUrl, - token: jwt, - first_name: user.first_name, - t: (key: string) => - this.i18n.t(key, { lang: defaultLanguage.code }), - }, - subject: this.i18n.t('password_reset_subject'), - }); - } catch (e) { - this.logger.error( - 'Could not send email', - e.message, - e.stack, - 'PasswordResetService', - ); - throw new InternalServerErrorException('Could not send email'); - } + try { + const defaultLanguage = await this.languageService.getDefaultLanguage(); + await this.mailerService.sendMail({ + to: dto.email, + template: 'password_reset.mjml', + context: { + appName: config.parameters.appName, + appUrl: config.uiBaseUrl, + token: jwt, + first_name: user.first_name, + t: (key: string) => this.i18n.t(key, { lang: defaultLanguage.code }), + }, + subject: this.i18n.t('password_reset_subject'), + }); + } catch (e) { + this.logger.error( + 'Could not send email', + e.message, + e.stack, + 'PasswordResetService', + ); + throw new InternalServerErrorException('Could not send email'); } // TODO: hash the token before saving it diff --git a/api/src/user/services/validate-account.service.spec.ts b/api/src/user/services/validate-account.service.spec.ts index ea88db2d..641232c1 100644 --- a/api/src/user/services/validate-account.service.spec.ts +++ b/api/src/user/services/validate-account.service.spec.ts @@ -6,10 +6,11 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { ISendMailOptions, MailerService } from '@nestjs-modules/mailer'; +import { ISendMailOptions } from '@nestjs-modules/mailer'; import { SentMessageInfo } from 'nodemailer'; import { I18nService } from '@/i18n/services/i18n.service'; +import { MailerService } from '@/mailer/mailer.service'; import { installLanguageFixtures } from '@/utils/test/fixtures/language'; import { installUserFixtures, users } from '@/utils/test/fixtures/user'; import { diff --git a/api/src/user/services/validate-account.service.ts b/api/src/user/services/validate-account.service.ts index f8fd354c..408c0ee5 100644 --- a/api/src/user/services/validate-account.service.ts +++ b/api/src/user/services/validate-account.service.ts @@ -6,13 +6,10 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -// eslint-disable-next-line import/order -import { MailerService } from '@nestjs-modules/mailer'; import { Inject, Injectable, InternalServerErrorException, - Optional, UnauthorizedException, } from '@nestjs/common'; import { JwtService, JwtSignOptions } from '@nestjs/jwt'; @@ -21,6 +18,7 @@ import { config } from '@/config'; import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; import { LoggerService } from '@/logger/logger.service'; +import { MailerService } from '@/mailer/mailer.service'; import { UserCreateDto } from '../dto/user.dto'; @@ -40,7 +38,7 @@ export class ValidateAccountService { private logger: LoggerService, private readonly i18n: I18nService, private readonly languageService: LanguageService, - @Optional() private readonly mailerService?: MailerService, + private readonly mailerService: MailerService, ) {} /** @@ -77,31 +75,28 @@ export class ValidateAccountService { ) { const confirmationToken = await this.sign({ email: dto.email }); - if (this.mailerService) { - try { - const defaultLanguage = await this.languageService.getDefaultLanguage(); - await this.mailerService.sendMail({ - to: dto.email, - template: 'account_confirmation.mjml', - context: { - appName: config.parameters.appName, - appUrl: config.uiBaseUrl, - token: confirmationToken, - first_name: dto.first_name, - t: (key: string) => - this.i18n.t(key, { lang: defaultLanguage.code }), - }, - subject: this.i18n.t('account_confirmation_subject'), - }); - } catch (e) { - this.logger.error( - 'Could not send email', - e.message, - e.stack, - 'ValidateAccount', - ); - throw new InternalServerErrorException('Could not send email'); - } + try { + const defaultLanguage = await this.languageService.getDefaultLanguage(); + await this.mailerService.sendMail({ + to: dto.email, + template: 'account_confirmation.mjml', + context: { + appName: config.parameters.appName, + appUrl: config.uiBaseUrl, + token: confirmationToken, + first_name: dto.first_name, + t: (key: string) => this.i18n.t(key, { lang: defaultLanguage.code }), + }, + subject: this.i18n.t('account_confirmation_subject'), + }); + } catch (e) { + this.logger.error( + 'Could not send email', + e.message, + e.stack, + 'ValidateAccount', + ); + throw new InternalServerErrorException('Could not send email'); } } diff --git a/api/src/user/user.module.ts b/api/src/user/user.module.ts index 39d1591e..89055947 100644 --- a/api/src/user/user.module.ts +++ b/api/src/user/user.module.ts @@ -12,6 +12,7 @@ import { MongooseModule } from '@nestjs/mongoose'; import { PassportModule } from '@nestjs/passport'; import { AttachmentModule } from '@/attachment/attachment.module'; +import { MailerModule } from '@/mailer/mailer.module'; import { LocalAuthController } from './controllers/auth.controller'; import { ModelController } from './controllers/model.controller'; @@ -46,6 +47,7 @@ import { ValidateAccountService } from './services/validate-account.service'; @Module({ imports: [ + MailerModule, MongooseModule.forFeature([ UserModel, ModelModel,