diff --git a/.github/workflows/ci-utils.yaml b/.github/workflows/ci-utils.yaml index 87f6a65681..c1625bc120 100644 --- a/.github/workflows/ci-utils.yaml +++ b/.github/workflows/ci-utils.yaml @@ -28,6 +28,6 @@ jobs: - name: Utils / Install Dependencies run: yarn - name: Utils / Run Danger.js - run: cd packages/twenty-utils && yarn danger ci --use-github-checks --failOnErrors + run: cd packages/twenty-utils && yarn nx danger:ci env: - DANGER_GITHUB_API_TOKEN: ${{ github.token }} \ No newline at end of file + DANGER_GITHUB_API_TOKEN: ${{ github.token }} diff --git a/package.json b/package.json index d96b1ef6d2..5f356cb494 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@types/lodash.camelcase": "^4.3.7", "@types/lodash.merge": "^4.6.7", "@types/mailparser": "^3.4.4", + "@types/nodemailer": "^6.4.14", "add": "^2.0.6", "afterframe": "^1.0.2", "apollo-server-express": "^3.12.0", @@ -103,6 +104,7 @@ "nest-commander": "^3.12.0", "next": "14.0.4", "next-mdx-remote": "^4.4.1", + "nodemailer": "^6.9.8", "openapi-types": "^12.1.3", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", diff --git a/packages/twenty-docs/docs/start/self-hosting/enviroment-variables.mdx b/packages/twenty-docs/docs/start/self-hosting/enviroment-variables.mdx index 3a44a81e9a..926489ac93 100644 --- a/packages/twenty-docs/docs/start/self-hosting/enviroment-variables.mdx +++ b/packages/twenty-docs/docs/start/self-hosting/enviroment-variables.mdx @@ -6,6 +6,8 @@ sidebar_custom_props: --- import OptionTable from '@site/src/theme/OptionTable' +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; ## Frontend @@ -56,6 +58,53 @@ import OptionTable from '@site/src/theme/OptionTable' ['FRONT_AUTH_CALLBACK_URL', 'http://localhost:3001/verify ', 'Callback used for Login page'], ]}> +### Email + + + +#### Email SMTP Server configuration examples + + + + + + You will need to provision an [App Password](https://support.google.com/accounts/answer/185833). + - EMAIL_SMTP_HOST=smtp.gmail.com + - EMAIL_SERVER_PORT=465 + - EMAIL_SERVER_USER=gmail_email_address + - EMAIL_SERVER_PASSWORD='gmail_app_password' + + + + + + Keep in mind that if you have 2FA enabled, you will need to provision an [App Password](https://support.microsoft.com/en-us/account-billing/manage-app-passwords-for-two-step-verification-d6dc8c6d-4bf7-4851-ad95-6d07799387e9). + - EMAIL_SMTP_HOST=smtp.office365.com + - EMAIL_SERVER_PORT=587 + - EMAIL_SERVER_USER=office365_email_address + - EMAIL_SERVER_PASSWORD='office365_password' + + + + + + **smtp4dev** is a fake SMTP email server for development and testing. + - Run the smtp4dev image: `docker run --rm -it -p 8090:80 -p 2525:25 rnwood/smtp4dev` + - Access the smtp4dev ui here: [http://localhost:8090](http://localhost:8090) + - Set the following env variables: + - EMAIL_SERVER_HOST=localhost + - EMAIL_SERVER_PORT=2525 + + + + + ### Storage @@ -96,6 +146,7 @@ import OptionTable from '@site/src/theme/OptionTable' ]}> ### Telemetry + ; +} diff --git a/packages/twenty-server/src/integrations/email/drivers/logger.driver.ts b/packages/twenty-server/src/integrations/email/drivers/logger.driver.ts new file mode 100644 index 0000000000..90ca586b91 --- /dev/null +++ b/packages/twenty-server/src/integrations/email/drivers/logger.driver.ts @@ -0,0 +1,20 @@ +import { Logger } from '@nestjs/common'; + +import { SendMailOptions } from 'nodemailer'; + +import { EmailDriver } from 'src/integrations/email/drivers/interfaces/email-driver.interface'; + +export class LoggerDriver implements EmailDriver { + private readonly logger = new Logger(LoggerDriver.name); + + async send(sendMailOptions: SendMailOptions): Promise { + const info = + `Sent email to: ${sendMailOptions.to}\n` + + `From: ${sendMailOptions.from}\n` + + `Subject: ${sendMailOptions.subject}\n` + + `Content Text: ${sendMailOptions.text}\n` + + `Content HTML: ${sendMailOptions.html}`; + + this.logger.log(info); + } +} diff --git a/packages/twenty-server/src/integrations/email/drivers/smtp.driver.ts b/packages/twenty-server/src/integrations/email/drivers/smtp.driver.ts new file mode 100644 index 0000000000..4cf1cbf0da --- /dev/null +++ b/packages/twenty-server/src/integrations/email/drivers/smtp.driver.ts @@ -0,0 +1,16 @@ +import { createTransport, Transporter, SendMailOptions } from 'nodemailer'; +import SMTPConnection from 'nodemailer/lib/smtp-connection'; + +import { EmailDriver } from 'src/integrations/email/drivers/interfaces/email-driver.interface'; + +export class SmtpDriver implements EmailDriver { + private transport: Transporter; + + constructor(options: SMTPConnection.Options) { + this.transport = createTransport(options); + } + + async send(sendMailOptions: SendMailOptions): Promise { + await this.transport.sendMail(sendMailOptions); + } +} diff --git a/packages/twenty-server/src/integrations/email/email.constants.ts b/packages/twenty-server/src/integrations/email/email.constants.ts new file mode 100644 index 0000000000..45649fcb22 --- /dev/null +++ b/packages/twenty-server/src/integrations/email/email.constants.ts @@ -0,0 +1 @@ +export const EMAIL_DRIVER = Symbol('EMAIL_DRIVER'); diff --git a/packages/twenty-server/src/integrations/email/email.module-factory.ts b/packages/twenty-server/src/integrations/email/email.module-factory.ts new file mode 100644 index 0000000000..c7da0d8d8c --- /dev/null +++ b/packages/twenty-server/src/integrations/email/email.module-factory.ts @@ -0,0 +1,40 @@ +import { + EmailDriver, + EmailModuleOptions, +} from 'src/integrations/email/interfaces/email.interface'; + +import { EnvironmentService } from 'src/integrations/environment/environment.service'; + +export const emailModuleFactory = ( + environmentService: EnvironmentService, +): EmailModuleOptions => { + const driver = environmentService.getEmailDriver(); + + switch (driver) { + case EmailDriver.Logger: { + return; + } + case EmailDriver.Smtp: { + const host = environmentService.getEmailHost(); + const port = environmentService.getEmailPort(); + const user = environmentService.getEmailUser(); + const pass = environmentService.getEmailPassword(); + + if (!(host && port)) { + throw new Error( + `${driver} email driver requires host: ${host} and port: ${port} to be defined, check your .env file`, + ); + } + + const auth = user && pass ? { user, pass } : undefined; + + if (auth) { + return { host, port, auth }; + } + + return { host, port }; + } + default: + throw new Error(`Invalid email driver (${driver}), check your .env file`); + } +}; diff --git a/packages/twenty-server/src/integrations/email/email.module.ts b/packages/twenty-server/src/integrations/email/email.module.ts new file mode 100644 index 0000000000..2e4c8d9a18 --- /dev/null +++ b/packages/twenty-server/src/integrations/email/email.module.ts @@ -0,0 +1,29 @@ +import { DynamicModule, Global } from '@nestjs/common'; + +import { EmailModuleAsyncOptions } from 'src/integrations/email/interfaces/email.interface'; + +import { EMAIL_DRIVER } from 'src/integrations/email/email.constants'; +import { LoggerDriver } from 'src/integrations/email/drivers/logger.driver'; +import { SmtpDriver } from 'src/integrations/email/drivers/smtp.driver'; +import { EmailService } from 'src/integrations/email/email.service'; + +@Global() +export class EmailModule { + static forRoot(options: EmailModuleAsyncOptions): DynamicModule { + const provider = { + provide: EMAIL_DRIVER, + useFactory: (...args: any[]) => { + const config = options.useFactory(...args); + + return config ? new SmtpDriver(config) : new LoggerDriver(); + }, + inject: options.inject || [], + }; + + return { + module: EmailModule, + providers: [EmailService, provider], + exports: [EmailService], + }; + } +} diff --git a/packages/twenty-server/src/integrations/email/email.service.ts b/packages/twenty-server/src/integrations/email/email.service.ts new file mode 100644 index 0000000000..1c94c33b11 --- /dev/null +++ b/packages/twenty-server/src/integrations/email/email.service.ts @@ -0,0 +1,16 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { SendMailOptions } from 'nodemailer'; + +import { EmailDriver } from 'src/integrations/email/drivers/interfaces/email-driver.interface'; + +import { EMAIL_DRIVER } from 'src/integrations/email/email.constants'; + +@Injectable() +export class EmailService implements EmailDriver { + constructor(@Inject(EMAIL_DRIVER) private driver: EmailDriver) {} + + async send(sendMailOptions: SendMailOptions): Promise { + await this.driver.send(sendMailOptions); + } +} diff --git a/packages/twenty-server/src/integrations/email/interfaces/email.interface.ts b/packages/twenty-server/src/integrations/email/interfaces/email.interface.ts new file mode 100644 index 0000000000..91d159262c --- /dev/null +++ b/packages/twenty-server/src/integrations/email/interfaces/email.interface.ts @@ -0,0 +1,15 @@ +import { FactoryProvider, ModuleMetadata } from '@nestjs/common'; + +import SMTPConnection from 'nodemailer/lib/smtp-connection'; + +export enum EmailDriver { + Logger = 'logger', + Smtp = 'smtp', +} + +export type EmailModuleOptions = SMTPConnection.Options | undefined; + +export type EmailModuleAsyncOptions = { + useFactory: (...args: any[]) => EmailModuleOptions; +} & Pick & + Pick; diff --git a/packages/twenty-server/src/integrations/environment/environment.service.ts b/packages/twenty-server/src/integrations/environment/environment.service.ts index 8f31defd82..7a6b1f318e 100644 --- a/packages/twenty-server/src/integrations/environment/environment.service.ts +++ b/packages/twenty-server/src/integrations/environment/environment.service.ts @@ -2,6 +2,8 @@ import { Injectable, LogLevel } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { EmailDriver } from 'src/integrations/email/interfaces/email.interface'; + import { LoggerDriverType } from 'src/integrations/logger/interfaces'; import { ExceptionHandlerDriver } from 'src/integrations/exception-handler/interfaces'; import { StorageDriverType } from 'src/integrations/file-storage/interfaces'; @@ -170,6 +172,28 @@ export class EnvironmentService { ); } + getEmailDriver(): EmailDriver { + return ( + this.configService.get('EMAIL_DRIVER') ?? EmailDriver.Logger + ); + } + + getEmailHost(): string | undefined { + return this.configService.get('EMAIL_SMTP_HOST'); + } + + getEmailPort(): number | undefined { + return this.configService.get('EMAIL_SMTP_PORT'); + } + + getEmailUser(): string | undefined { + return this.configService.get('EMAIL_SMTP_USER'); + } + + getEmailPassword(): string | undefined { + return this.configService.get('EMAIL_SMTP_PASSWORD'); + } + getSupportDriver(): string { return ( this.configService.get('SUPPORT_DRIVER') ?? SupportDriver.None diff --git a/packages/twenty-server/src/integrations/integrations.module.ts b/packages/twenty-server/src/integrations/integrations.module.ts index 5624bca094..51ac6f0895 100644 --- a/packages/twenty-server/src/integrations/integrations.module.ts +++ b/packages/twenty-server/src/integrations/integrations.module.ts @@ -6,6 +6,8 @@ import { exceptionHandlerModuleFactory } from 'src/integrations/exception-handle import { fileStorageModuleFactory } from 'src/integrations/file-storage/file-storage.module-factory'; import { loggerModuleFactory } from 'src/integrations/logger/logger.module-factory'; import { messageQueueModuleFactory } from 'src/integrations/message-queue/message-queue.module-factory'; +import { emailModuleFactory } from 'src/integrations/email/email.module-factory'; +import { EmailModule } from 'src/integrations/email/email.module'; import { EnvironmentModule } from './environment/environment.module'; import { EnvironmentService } from './environment/environment.service'; @@ -32,6 +34,10 @@ import { MessageQueueModule } from './message-queue/message-queue.module'; useFactory: exceptionHandlerModuleFactory, inject: [EnvironmentService, HttpAdapterHost], }), + EmailModule.forRoot({ + useFactory: emailModuleFactory, + inject: [EnvironmentService], + }), ], exports: [], providers: [], diff --git a/packages/twenty-utils/package.json b/packages/twenty-utils/package.json index 1345221fed..e7dce62e38 100644 --- a/packages/twenty-utils/package.json +++ b/packages/twenty-utils/package.json @@ -2,6 +2,8 @@ "name": "twenty-utils", "private": true, "scripts": { + "nx": "NX_DEFAULT_PROJECT=twenty-front node ../../node_modules/nx/bin/nx.js", + "danger:ci": "danger ci --use-github-checks --failOnErrors", "release": "node release.js" } } diff --git a/yarn.lock b/yarn.lock index 4cd4327175..ae2b9428cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14422,6 +14422,15 @@ __metadata: languageName: node linkType: hard +"@types/nodemailer@npm:^6.4.14": + version: 6.4.14 + resolution: "@types/nodemailer@npm:6.4.14" + dependencies: + "@types/node": "npm:*" + checksum: b5958843576cde76dc532aa7b726182fef8b466fa9fcaf1aa03f89f02e896bec4e28b593ffa1a289a46bd0b7fdf34da0640ab7ef8f0811948016f58f77e16307 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -33184,7 +33193,7 @@ __metadata: languageName: node linkType: hard -"nodemailer@npm:6.9.8": +"nodemailer@npm:6.9.8, nodemailer@npm:^6.9.8": version: 6.9.8 resolution: "nodemailer@npm:6.9.8" checksum: 9332587975240ac648e1295b1df15e339fcace3f7fab8af0382e7f2dd10e48296344dfa698d58f1667f220f7fe13c779d55d39144c9cd9ed6f5f559714183c75 @@ -41568,6 +41577,7 @@ __metadata: "@types/mailparser": "npm:^3.4.4" "@types/ms": "npm:^0.7.31" "@types/node": "npm:^20.10.6" + "@types/nodemailer": "npm:^6.4.14" "@types/passport-google-oauth20": "npm:^2.0.11" "@types/passport-jwt": "npm:^3.0.8" "@types/react": "npm:^18.2.39" @@ -41655,6 +41665,7 @@ __metadata: nest-commander: "npm:^3.12.0" next: "npm:14.0.4" next-mdx-remote: "npm:^4.4.1" + nodemailer: "npm:^6.9.8" nx: "npm:^17.2.8" openapi-types: "npm:^12.1.3" passport: "npm:^0.6.0"