From cc36b16d316b3915e1136af239accc00eb5863eb Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 18 Sep 2024 17:33:12 +0100 Subject: [PATCH] fix: smtp config --- Makefile | 11 +++- api/src/app.module.ts | 40 ++++++++------- api/src/app.service.ts | 9 +--- api/src/config/i18n/en/messages.json | 9 ++-- api/src/config/i18n/fr/messages.json | 3 ++ api/src/config/index.ts | 31 ++++-------- api/src/config/types.ts | 27 ++-------- api/src/extended-i18n.service.ts | 8 +-- api/src/setting/schemas/types.ts | 6 --- api/src/setting/seeds/setting.seed-model.ts | 50 ------------------- api/src/templates/account_confirmation.mjml | 6 +-- api/src/templates/invitation.mjml | 4 +- api/src/templates/password_reset.mjml | 6 +-- api/src/user/services/invitation.service.ts | 43 +++++++++------- .../user/services/passwordReset.service.ts | 44 +++++++++------- .../user/services/validate-account.service.ts | 26 ++++++---- docker/.env.example | 4 +- docker/docker-compose.dev.yml | 18 ------- docker/docker-compose.smtp4dev.yml | 20 ++++++++ frontend/src/i18n/en/translation.json | 3 +- frontend/src/i18n/fr/translation.json | 2 +- 21 files changed, 157 insertions(+), 213 deletions(-) create mode 100644 docker/docker-compose.smtp4dev.yml diff --git a/Makefile b/Makefile index 9e7ba3b..1167bb8 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,10 @@ define add_service COMPOSE_FILES += -f ./docker/docker-compose.$(1).prod.yml endif else - COMPOSE_FILES += -f ./docker/docker-compose.$(1).yml -f ./docker/docker-compose.$(1).dev.yml + COMPOSE_FILES += -f ./docker/docker-compose.$(1).yml + ifneq ($(wildcard ./docker/docker-compose.$(1).dev.yml),) + COMPOSE_FILES += -f ./docker/docker-compose.$(1).dev.yml + endif endif endef @@ -23,6 +26,10 @@ ifneq ($(NLU),) $(eval $(call add_service,nlu)) endif +ifneq ($(SMTP4DEV),) + $(eval $(call add_service,smtp4dev)) +endif + # Ensure .env file exists and matches .env.example check-env: @if [ ! -f "./docker/.env" ]; then \ @@ -48,4 +55,4 @@ destroy: check-env docker compose $(COMPOSE_FILES) down -v migrate-up: - docker-compose $(COMPOSE_FILES) up --no-deps -d database-init \ No newline at end of file + docker-compose $(COMPOSE_FILES) up --no-deps -d database-init diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 5ee186b..53f6ef0 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -57,24 +57,28 @@ const i18nOptions: I18nOptions = { @Module({ imports: [ - MailerModule.forRoot({ - transport: new SMTPTransport({ - ...config.emails.smtp, - logger: true, - }), - template: { - adapter: new MjmlAdapter('ejs', { inlineCssEnabled: false }), - dir: './src/templates', - options: { - context: { - appName: config.parameters.appName, - appUrl: config.parameters.appUrl, - // TODO: add i18n support - }, - }, - }, - defaults: { from: config.parameters.email.main }, - }), + ...(config.emails.isEnabled + ? [ + MailerModule.forRoot({ + transport: new SMTPTransport({ + ...config.emails.smtp, + logger: true, + debug: false, + }), + template: { + adapter: new MjmlAdapter('ejs', { inlineCssEnabled: false }), + dir: './src/templates', + options: { + context: { + appName: config.parameters.appName, + appUrl: config.parameters.appUrl, + }, + }, + }, + defaults: { from: config.emails.from }, + }), + ] + : []), MongooseModule.forRoot(config.mongo.uri, { dbName: config.mongo.dbName, connectionFactory: (connection) => { diff --git a/api/src/app.service.ts b/api/src/app.service.ts index 09a07e5..a78e0d3 100644 --- a/api/src/app.service.ts +++ b/api/src/app.service.ts @@ -8,19 +8,14 @@ */ import { Injectable } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; import { ExtendedI18nService } from './extended-i18n.service'; @Injectable() export class AppService { - constructor( - private readonly i18n: ExtendedI18nService, - private readonly eventEmitter: EventEmitter2, - ) {} + constructor(private readonly i18n: ExtendedI18nService) {} getHello(): string { - this.eventEmitter.emit('hook:i18n:refresh', []); - return this.i18n.t('Welcome'); + return this.i18n.t('welcome', { lang: 'en' }); } } diff --git a/api/src/config/i18n/en/messages.json b/api/src/config/i18n/en/messages.json index efb9d1c..c9ecb49 100644 --- a/api/src/config/i18n/en/messages.json +++ b/api/src/config/i18n/en/messages.json @@ -1,18 +1,21 @@ { + "invitation_subject": "[Hexabot] Sign-Up Invitation", + "account_confirmation_subject": "[Hexabot] Account Confirmation", + "password_reset_subject": "[Hexabot] Password Reset", "welcome": "Welcome", "hi": "Hi", "invitation_to_join": "Invitation to join", "invitation_for_account_creation": "You have been invited to create a", "account": "account", - "create_account:": "Click on the button below to create your account:", + "create_account": "Click on the button below to create your account:", "registration_failed": "Registration failed! Either email address or invitation token is invalid.", "create_account_button": "Click on the button below to create your account", "join": "Join", "account_successfully_created_confirm_password": "You have successfully created an account but you will need to confirm your email address.", - "confirm_account:": "Click on the button below in order to confirm your account:", + "confirm_account": "Click on the button below in order to confirm your account:", "password_reset_request": "A request to reset the password for your account has been made.", "reset": "Reset", - "click_to_reset_password:": "Click on the button below in order to set a new password:", + "click_to_reset_password": "Click on the button below in order to set a new password:", "confirm": "Confirm", "best_regards": "Best Regards," } diff --git a/api/src/config/i18n/fr/messages.json b/api/src/config/i18n/fr/messages.json index 5280a1f..5a09570 100644 --- a/api/src/config/i18n/fr/messages.json +++ b/api/src/config/i18n/fr/messages.json @@ -1,4 +1,7 @@ { + "invitation_subject": "[Hexabot] Invitation à s'inscrire", + "account_confirmation_subject": "[Hexabot] Confirmation de compte", + "password_reset_subject": "[Hexabot] Réinitialisation du mot de passe", "welcome": "Bienvenue", "hi": "Bonjour", "invitation_to_join": "Invitation", diff --git a/api/src/config/index.ts b/api/src/config/index.ts index 98d541c..3b51ff4 100644 --- a/api/src/config/index.ts +++ b/api/src/config/index.ts @@ -13,7 +13,7 @@ import { Config } from './types'; export const config: Config = { i18n: { - translationFilename: process.env.I18N_TRANSLATION_FILENAME || '', + translationFilename: process.env.I18N_TRANSLATION_FILENAME || 'messages', }, appPath: process.cwd(), apiPath: process.env.API_ORIGIN, @@ -77,7 +77,7 @@ export const config: Config = { : [undefined], // ['http://example.com', 'https://example.com'], }, session: { - secret: process.env.SESSION_SECRET || '4fac3596aeb0d048e7b6b38235c29248', + secret: process.env.SESSION_SECRET || 'changeme', name: process.env.SESSION_NAME || 'hex.sid', adapter: 'connect-mongo', url: 'mongodb://localhost:27017/hexabot', @@ -90,23 +90,18 @@ export const config: Config = { }, }, emails: { + isEnabled: process.env.EMAIL_SMTP_ENABLED === 'true' || false, smtp: { port: parseInt(process.env.EMAIL_SMTP_PORT) || 25, - host: process.env.EMAIL_SMTP_HOST || 'smtp.mailgun.org', + host: process.env.EMAIL_SMTP_HOST || 'localhost', + ignoreTLS: false, secure: process.env.EMAIL_SMTP_SECURE === 'true' || false, auth: { - user: - process.env.EMAIL_SMTP_USER || - 'postmaster@sandbox9471202ff10448c7ac917618fe94d8e1.mailgun.org', - pass: process.env.EMAIL_SMTP_PASS || 'e58526b30ad640394b5c77a211a19c5b', + user: process.env.EMAIL_SMTP_USER || '', + pass: process.env.EMAIL_SMTP_PASS || '', }, }, - }, - datastores: { - default: { - adapter: 'sails-mongo', - url: 'mongodb://localhost:27017/hexabot', - }, + from: process.env.EMAIL_SMTP_FROM || 'noreply@example.com', }, parameters: { uploadDir: @@ -117,17 +112,9 @@ export const config: Config = { maxUploadSize: process.env.UPLOAD_MAX_SIZE_IN_BYTES ? Number(process.env.UPLOAD_MAX_SIZE_IN_BYTES) : 2000000, - transport: 'smtp', - email: { - main: 'postmaster@sandbox9471202ff10448c7ac917618fe94d8e1.mailgun.org', - }, appName: 'Hexabot.ai', apiUrl: 'http://localhost:4000', appUrl: 'http://localhost:8081', - geocoder: { - provider: 'opencage', - apiKey: 'c2a490d593b14612aefa6ec2e6b77c47', - }, }, pagination: { limit: 10, @@ -135,7 +122,7 @@ export const config: Config = { chatbot: { lang: { default: 'en', - available: ['en', 'fr', 'ar', 'tn'], + available: ['en', 'fr'], }, messages: { track_delivery: false, diff --git a/api/src/config/types.ts b/api/src/config/types.ts index 80150ce..971a1be 100644 --- a/api/src/config/types.ts +++ b/api/src/config/types.ts @@ -7,6 +7,7 @@ * 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited. */ +import SMTPConnection from 'nodemailer/lib/smtp-connection'; import type { ServerOptions, Socket } from 'socket.io'; type TJwtOptions = { @@ -69,38 +70,18 @@ export type Config = { }; }; emails: { - smtp: { - port: number; - host: string; - secure: boolean; - auth: { - user: string; - pass: string; - }; - }; - }; - datastores: { - default: { - adapter: string; - url: string; - }; + isEnabled: boolean; + smtp: Partial; + from: string; }; parameters: { uploadDir: string; avatarDir: string; storageMode: 'disk' | 'memory'; maxUploadSize: number; - transport: string; - email: { - main: string; - }; appName: string; apiUrl: string; appUrl: string; - geocoder: { - provider: string; - apiKey: string; - }; }; pagination: { limit: number; diff --git a/api/src/extended-i18n.service.ts b/api/src/extended-i18n.service.ts index 64b7a1c..5bb4e3d 100644 --- a/api/src/extended-i18n.service.ts +++ b/api/src/extended-i18n.service.ts @@ -58,9 +58,11 @@ export class ExtendedI18nService< initDynamicTranslations(translations: Translation[]) { this.dynamicTranslations = translations.reduce((acc, curr) => { const { str, translations } = curr; - Object.entries(translations).forEach(([lang, t]) => { - acc[lang][str] = t; - }); + Object.entries(translations) + .filter(([lang]) => lang in acc) + .forEach(([lang, t]) => { + acc[lang][str] = t; + }); return acc; }, this.dynamicTranslations); diff --git a/api/src/setting/schemas/types.ts b/api/src/setting/schemas/types.ts index f81c735..f13f849 100644 --- a/api/src/setting/schemas/types.ts +++ b/api/src/setting/schemas/types.ts @@ -111,10 +111,4 @@ export type Settings = { fallback_message: string[]; fallback_block: string; }; - email_settings: { - mailer: string; - auth_user: string; - auth_pass: string; - from: string; - }; } & Record; diff --git a/api/src/setting/seeds/setting.seed-model.ts b/api/src/setting/seeds/setting.seed-model.ts index 3103685..855aafa 100644 --- a/api/src/setting/seeds/setting.seed-model.ts +++ b/api/src/setting/seeds/setting.seed-model.ts @@ -99,56 +99,6 @@ export const settingModels: SettingCreateDto[] = [ }, weight: 6, }, - { - group: 'email_settings', - label: 'from', - value: 'no-reply@domain.com', - type: SettingType.text, - weight: 1, - }, - { - group: 'email_settings', - label: 'mailer', - value: 'sendmail', - options: ['sendmail', 'smtp'], - type: SettingType.select, - weight: 2, - }, - { - group: 'email_settings', - label: 'host', - value: 'localhost', - type: SettingType.text, - weight: 3, - }, - { - group: 'email_settings', - label: 'port', - value: '25', - type: SettingType.text, - weight: 4, - }, - { - group: 'email_settings', - label: 'secure', - value: true, - type: SettingType.checkbox, - weight: 5, - }, - { - group: 'email_settings', - label: 'auth_user', - value: '', - type: SettingType.text, - weight: 6, - }, - { - group: 'email_settings', - label: 'auth_pass', - value: '', - type: SettingType.text, - weight: 7, - }, { group: 'contact', label: 'contact_email_recipient', diff --git a/api/src/templates/account_confirmation.mjml b/api/src/templates/account_confirmation.mjml index dbb9f89..cf10ff8 100644 --- a/api/src/templates/account_confirmation.mjml +++ b/api/src/templates/account_confirmation.mjml @@ -24,10 +24,10 @@ - <%= t('best_regards') %>,<%= t('best_regards') %> - <%= this.appName %> diff --git a/api/src/templates/invitation.mjml b/api/src/templates/invitation.mjml index 197d78d..e608bc0 100644 --- a/api/src/templates/invitation.mjml +++ b/api/src/templates/invitation.mjml @@ -26,11 +26,11 @@ - <%= t('best_regards') %> - <%= this.appName %> diff --git a/api/src/templates/password_reset.mjml b/api/src/templates/password_reset.mjml index 0ea9794..3497502 100644 --- a/api/src/templates/password_reset.mjml +++ b/api/src/templates/password_reset.mjml @@ -23,10 +23,10 @@ - <%= t('best_regards') %>,<%= t('best_regards') %> - <%= this.appName %> diff --git a/api/src/user/services/invitation.service.ts b/api/src/user/services/invitation.service.ts index db0c8a7..84bc02c 100644 --- a/api/src/user/services/invitation.service.ts +++ b/api/src/user/services/invitation.service.ts @@ -11,6 +11,7 @@ import { Inject, Injectable, InternalServerErrorException, + Optional, } from '@nestjs/common'; import { JwtService, JwtSignOptions } from '@nestjs/jwt'; import { MailerService } from '@nestjs-modules/mailer'; @@ -32,7 +33,7 @@ export class InvitationService extends BaseService { @Inject(InvitationRepository) readonly repository: InvitationRepository, @Inject(JwtService) private readonly jwtService: JwtService, - private readonly mailerService: MailerService, + @Optional() private readonly mailerService: MailerService | undefined, private logger: LoggerService, protected readonly i18n: ExtendedI18nService, ) { @@ -54,24 +55,28 @@ export class InvitationService extends BaseService { */ async create(dto: InvitationCreateDto): Promise { const jwt = await this.sign(dto); - try { - await this.mailerService.sendMail({ - to: dto.email, - template: 'invitation.mjml', - context: { - token: jwt, - // TODO: Which language should we use? - t: (key: string) => this.i18n.t(key), - }, - }); - } catch (e) { - this.logger.error( - 'Could not send email', - e.message, - e.stack, - 'InvitationService', - ); - throw new InternalServerErrorException('Could not send email'); + if (this.mailerService) { + try { + await this.mailerService.sendMail({ + to: dto.email, + template: 'invitation.mjml', + context: { + token: jwt, + // TODO: Which language should we use? + t: (key: string) => + this.i18n.t(key, { lang: config.chatbot.lang.default }), + }, + 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.ts b/api/src/user/services/passwordReset.service.ts index 0143c1c..60143f8 100644 --- a/api/src/user/services/passwordReset.service.ts +++ b/api/src/user/services/passwordReset.service.ts @@ -13,6 +13,7 @@ import { Injectable, InternalServerErrorException, NotFoundException, + Optional, UnauthorizedException, } from '@nestjs/common'; import { JwtService, JwtSignOptions } from '@nestjs/jwt'; @@ -30,7 +31,7 @@ import { UserRequestResetDto, UserResetPasswordDto } from '../dto/user.dto'; export class PasswordResetService { constructor( @Inject(JwtService) private readonly jwtService: JwtService, - private readonly mailerService: MailerService, + @Optional() private readonly mailerService: MailerService | undefined, private logger: LoggerService, private readonly userService: UserService, public readonly i18n: ExtendedI18nService, @@ -55,24 +56,29 @@ export class PasswordResetService { throw new NotFoundException('User not found'); } const jwt = await this.sign(dto); - try { - await this.mailerService.sendMail({ - to: dto.email, - template: 'password_reset.mjml', - context: { - token: jwt, - first_name: user.first_name, - t: (key: string) => this.i18n.t(key), - }, - }); - } catch (e) { - this.logger.error( - 'Could not send email', - e.message, - e.stack, - 'InvitationService', - ); - throw new InternalServerErrorException('Could not send email'); + + if (this.mailerService) { + try { + await this.mailerService.sendMail({ + to: dto.email, + template: 'password_reset.mjml', + context: { + token: jwt, + first_name: user.first_name, + t: (key: string) => + this.i18n.t(key, { lang: config.chatbot.lang.default }), + }, + subject: this.i18n.t('password_reset_subject'), + }); + } catch (e) { + this.logger.error( + 'Could not send email', + e.message, + e.stack, + 'InvitationService', + ); + throw new InternalServerErrorException('Could not send email'); + } } // TODO: hash the token before saving it diff --git a/api/src/user/services/validate-account.service.ts b/api/src/user/services/validate-account.service.ts index 3dcdf80..4c2d06c 100644 --- a/api/src/user/services/validate-account.service.ts +++ b/api/src/user/services/validate-account.service.ts @@ -11,6 +11,7 @@ import { Inject, Injectable, InternalServerErrorException, + Optional, UnauthorizedException, } from '@nestjs/common'; import { JwtService, JwtSignOptions } from '@nestjs/jwt'; @@ -33,7 +34,7 @@ export class ValidateAccountService { constructor( @Inject(JwtService) private readonly jwtService: JwtService, private readonly userService: UserService, - private readonly mailerService: MailerService, + @Optional() private readonly mailerService: MailerService | undefined, private readonly i18n: ExtendedI18nService, ) {} @@ -71,16 +72,19 @@ export class ValidateAccountService { ) { const confirmationToken = await this.sign({ email: dto.email }); - await this.mailerService.sendMail({ - to: dto.email, - template: 'account_confirmation.mjml', - context: { - token: confirmationToken, - first_name: dto.first_name, - t: (key: string) => this.i18n.t(key), - }, - subject: 'Account confirmation Email', - }); + if (this.mailerService) { + await this.mailerService.sendMail({ + to: dto.email, + template: 'account_confirmation.mjml', + context: { + token: confirmationToken, + first_name: dto.first_name, + t: (key: string) => + this.i18n.t(key, { lang: config.chatbot.lang.default }), + }, + subject: this.i18n.t('account_confirmation_subject'), + }); + } } /** diff --git a/docker/.env.example b/docker/.env.example index 2104dcc..8e4f837 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -30,13 +30,15 @@ MONGO_PASSWORD=dev_only MONGO_URI=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongo:27017/ MONGO_DB=hexabot -# SMTP Config for local dev env +# SMTP Config (for local dev env, use smtp4dev by doing `make start SMTP4DEV=1`) APP_SMTP_4_DEV_PORT=9002 +EMAIL_SMTP_ENABLED=false EMAIL_SMTP_HOST=smtp4dev EMAIL_SMTP_PORT=25 EMAIL_SMTP_SECURE=false EMAIL_SMTP_USER=dev_only EMAIL_SMTP_PASS=dev_only +EMAIL_SMTP_FROM=noreply@example.com # NLU Server AUTH_TOKEN=token123 diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 724b656..3ba63cc 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -17,21 +17,6 @@ services: #- ../api/node_modules:/app/node_modules command: ["npm", "run", "start:debug"] - smtp4dev: - image: rnwood/smtp4dev:v3 - restart: always - ports: - - ${APP_SMTP_4_DEV_PORT}:80 - - "25:25" - - "143:143" - volumes: - - smtp4dev-data:/smtp4dev - environment: - - ServerOptions__HostName=smtp4dev - - ServerOptions__LockSettings=true - networks: - - db-network - mongo-express: container_name: mongoUi image: mongo-express:1-20 @@ -52,6 +37,3 @@ services: - ../widget/src:/app/src ports: - ${APP_WIDGET_PORT}:5173 - -volumes: - smtp4dev-data: diff --git a/docker/docker-compose.smtp4dev.yml b/docker/docker-compose.smtp4dev.yml new file mode 100644 index 0000000..4db737a --- /dev/null +++ b/docker/docker-compose.smtp4dev.yml @@ -0,0 +1,20 @@ +version: "3.8" + +services: + smtp4dev: + image: rnwood/smtp4dev:v3 + restart: always + ports: + - ${APP_SMTP_4_DEV_PORT}:80 + - "25:25" + - "143:143" + volumes: + - smtp4dev-data:/smtp4dev + environment: + - ServerOptions__HostName=smtp4dev + - ServerOptions__LockSettings=true + networks: + - app-network + +volumes: + smtp4dev-data: diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index bbe36a8..250e5d4 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -40,7 +40,7 @@ "edit_account_email": "A valid e-mail address. All e-mails from the system will be sent to this address. The e-mail address is not made public and will only be used if you wish to receive a new password or wish to receive certain news or notifications by e-mail.", "new_password": "To change the current password, enter the new password in both fields.", "account_update_success": "Account has been updated successfully", - "account_disabled": "Your account has been disabled!", + "account_disabled": "Your account has been either disabled or is pending confirmation.", "success_invitation_sent": "Invitation to join has been successfully sent.", "item_delete_confirm": "Are you sure you want to delete this item?", "item_delete_success": "Item has been deleted successfully", @@ -213,7 +213,6 @@ "offline": "Web Channel", "twitter": "Twitter", "dimelo": "Dimelo", - "email_settings": "Email", "contact": "Contact Infos", "chatbot_settings": "Chatbot", "nlp_settings": "NLP Provider", diff --git a/frontend/src/i18n/fr/translation.json b/frontend/src/i18n/fr/translation.json index 2cc08f8..e9fb58e 100644 --- a/frontend/src/i18n/fr/translation.json +++ b/frontend/src/i18n/fr/translation.json @@ -40,7 +40,7 @@ "edit_account_email": "Une adresse e-mail valide Tous les e-mails du système seront envoyés à cette adresse. L'adresse e-mail n'est pas rendue publique et ne sera utilisée que si vous souhaitez recevoir un nouveau mot de passe ou si vous souhaitez recevoir certaines nouvelles ou notifications par e-mail.", "new_password": "Pour changer le mot de passe actuel, entrez le nouveau mot de passe dans les deux champs.", "account_update_success": "Le compte a été mis à jour avec succès", - "account_disabled": "Votre compte a été désactivé!", + "account_disabled": "Votre compte a été désactivé ou est en attente de confirmation.", "success_invitation_sent": "L'invitation a été envoyée avec succès.", "item_delete_confirm": "Êtes-vous sûr de bien vouloir supprimer cet élément?", "item_delete_success": "L'élément a été supprimé avec succès",