Skip to content

Commit

Permalink
email integration
Browse files Browse the repository at this point in the history
* Nest email module with smtp, postmark and console log drivers
* react-email package
  • Loading branch information
Philipinho committed May 2, 2024
1 parent 48be0c2 commit 4c573b9
Show file tree
Hide file tree
Showing 26 changed files with 2,763 additions and 524 deletions.
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,17 @@ AWS_S3_BUCKET=
AWS_S3_ENDPOINT=
AWS_S3_URL=
AWS_S3_USE_PATH_STYLE_ENDPOINT=false


# EMAIL drivers: smtp / postmark / log
MAIL_DRIVER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS=[email protected]
MAIL_FROM_NAME=

# for postmark driver
POSTMARK_TOKEN=

6 changes: 5 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@nestjs/platform-socket.io": "^10.3.8",
"@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.3.8",
"@react-email/render": "^0.0.13",
"@types/pg": "^8.11.5",
"bcrypt": "^5.1.1",
"bytes": "^3.1.2",
Expand All @@ -52,9 +53,11 @@
"kysely-migration-cli": "^0.4.0",
"mime-types": "^2.1.35",
"nestjs-kysely": "^0.1.7",
"nodemailer": "^6.9.13",
"passport-jwt": "^4.0.1",
"pg": "^8.11.5",
"pg-tsquery": "^8.4.2",
"postmark": "^4.0.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sanitize-filename-ts": "^1.0.2",
Expand All @@ -75,13 +78,14 @@
"@types/jest": "^29.5.12",
"@types/mime-types": "^2.1.4",
"@types/node": "^20.12.7",
"@types/nodemailer": "^6.4.14",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0",
"eslint": "^9.1.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0",
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/core/core.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
import { DomainMiddleware } from '../middlewares/domain.middleware';
import { MailModule } from '../integrations/mail/mail.module';

@Module({
imports: [
Expand All @@ -27,6 +28,9 @@ import { DomainMiddleware } from '../middlewares/domain.middleware';
StorageModule.forRootAsync({
imports: [EnvironmentModule],
}),
MailModule.forRootAsync({
imports: [EnvironmentModule],
}),
AttachmentModule,
CommentModule,
SearchModule,
Expand Down
32 changes: 32 additions & 0 deletions apps/server/src/integrations/environment/environment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,36 @@ export class EnvironmentService {
isSelfHosted(): boolean {
return !this.isCloud();
}

getMailDriver(): string {
return this.configService.get<string>('MAIL_DRIVER', 'log');
}

getMailHost(): string {
return this.configService.get<string>('MAIL_HOST', '127.0.0.1');
}

getMailPort(): number {
return this.configService.get<number>('MAIL_PORT');
}

getMailUsername(): string {
return this.configService.get<string>('MAIL_USERNAME');
}

getMailPassword(): string {
return this.configService.get<string>('MAIL_PASSWORD');
}

getMailFromAddress(): string {
return this.configService.get<string>('MAIL_FROM_ADDRESS');
}

getMailFromName(): string {
return this.configService.get<string>('MAIL_FROM_NAME');
}

getPostmarkToken(): string {
return this.configService.get<string>('POSTMARK_TOKEN');
}
}
3 changes: 3 additions & 0 deletions apps/server/src/integrations/mail/drivers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { SmtpDriver } from './smtp.driver';
export { PostmarkDriver } from './postmark.driver';
export { LogDriver } from './log.driver';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { MailMessage } from '../../interfaces/mail.message';

export interface MailDriver {
sendMail(message: MailMessage): Promise<void>;
}
18 changes: 18 additions & 0 deletions apps/server/src/integrations/mail/drivers/log.driver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { MailDriver } from './interfaces/mail-driver.interface';
import { Logger } from '@nestjs/common';
import { MailMessage } from '../interfaces/mail.message';
import { mailLogName } from '../mail.utils';

export class LogDriver implements MailDriver {
private readonly logger = new Logger(mailLogName(LogDriver.name));

async sendMail(message: MailMessage): Promise<void> {
const mailLog = {
to: message.to,
subject: message.subject,
text: message.text,
};

this.logger.log(`Logged mail: ${JSON.stringify(mailLog)}`);
}
}
30 changes: 30 additions & 0 deletions apps/server/src/integrations/mail/drivers/postmark.driver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { MailDriver } from './interfaces/mail-driver.interface';
import { PostmarkConfig } from '../interfaces';
import { ServerClient } from 'postmark';
import { MailMessage } from '../interfaces/mail.message';
import { Logger } from '@nestjs/common';
import { mailLogName } from '../mail.utils';

export class PostmarkDriver implements MailDriver {
private readonly logger = new Logger(mailLogName(PostmarkDriver.name));
private readonly postmarkClient: ServerClient;

constructor(config: PostmarkConfig) {
this.postmarkClient = new ServerClient(config.postmarkToken);
}

async sendMail(message: MailMessage): Promise<void> {
try {
await this.postmarkClient.sendEmail({
From: message.from,
To: message.to,
Subject: message.subject,
TextBody: message.text,
HtmlBody: message.html,
});
this.logger.debug(`Sent mail to ${message.to}`);
} catch (err) {
this.logger.warn(`Failed to send mail to ${message.to}: ${err}`);
}
}
}
32 changes: 32 additions & 0 deletions apps/server/src/integrations/mail/drivers/smtp.driver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { MailDriver } from './interfaces/mail-driver.interface';
import { SMTPConfig } from '../interfaces';
import { Transporter } from 'nodemailer';
import * as nodemailer from 'nodemailer';
import { MailMessage } from '../interfaces/mail.message';
import { Logger } from '@nestjs/common';
import { mailLogName } from '../mail.utils';

export class SmtpDriver implements MailDriver {
private readonly logger = new Logger(mailLogName(SmtpDriver.name));
private readonly transporter: Transporter;

constructor(config: SMTPConfig) {
this.transporter = nodemailer.createTransport(config);
}

async sendMail(message: MailMessage): Promise<void> {
try {
await this.transporter.sendMail({
from: message.from,
to: message.to,
subject: message.subject,
text: message.text,
html: message.html,
});

this.logger.debug(`Sent mail to ${message.to}`);
} catch (err) {
this.logger.warn(`Failed to send mail to ${message.to}: ${err}`);
}
}
}
1 change: 1 addition & 0 deletions apps/server/src/integrations/mail/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './mail.interface';
30 changes: 30 additions & 0 deletions apps/server/src/integrations/mail/interfaces/mail.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import SMTPTransport from 'nodemailer/lib/smtp-transport';

export enum MailOption {
SMTP = 'smtp',
Postmark = 'postmark',
Log = 'log',
}

export type MailConfig =
| { driver: MailOption.SMTP; config: SMTPConfig }
| { driver: MailOption.Postmark; config: PostmarkConfig }
| { driver: MailOption.Log; config: LogConfig };

export interface SMTPConfig extends SMTPTransport.Options {}
export interface PostmarkConfig {
postmarkToken: string;
}
export interface LogConfig {}

export interface MailOptions {
mail: MailConfig;
}

export interface MailOptionsFactory {
createMailOptions(): Promise<MailConfig> | MailConfig;
}

export interface MailModuleOptions {
imports?: any[];
}
7 changes: 7 additions & 0 deletions apps/server/src/integrations/mail/interfaces/mail.message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface MailMessage {
from: string;
to: string;
subject: string;
text?: string;
html?: string;
}
2 changes: 2 additions & 0 deletions apps/server/src/integrations/mail/mail.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const MAIL_DRIVER_TOKEN = 'MAIL_DRIVER_TOKEN';
export const MAIL_CONFIG_TOKEN = 'MAIL_CONFIG_TOKEN';
20 changes: 20 additions & 0 deletions apps/server/src/integrations/mail/mail.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { DynamicModule, Global, Module } from '@nestjs/common';
import {
mailDriverConfigProvider,
mailDriverProvider,
} from './providers/mail.provider';
import { MailModuleOptions } from './interfaces';
import { MailService } from './mail.service';

@Global()
@Module({})
export class MailModule {
static forRootAsync(options: MailModuleOptions): DynamicModule {
return {
module: MailModule,
imports: options.imports || [],
providers: [mailDriverConfigProvider, mailDriverProvider, MailService],
exports: [MailService],
};
}
}
18 changes: 18 additions & 0 deletions apps/server/src/integrations/mail/mail.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Inject, Injectable } from '@nestjs/common';
import { MAIL_DRIVER_TOKEN } from './mail.constants';
import { MailDriver } from './drivers/interfaces/mail-driver.interface';
import { MailMessage } from './interfaces/mail.message';
import { EnvironmentService } from '../environment/environment.service';

@Injectable()
export class MailService {
constructor(
@Inject(MAIL_DRIVER_TOKEN) private mailDriver: MailDriver,
private readonly environmentService: EnvironmentService,
) {}

async sendMail(message: Omit<MailMessage, 'from'>): Promise<void> {
const sender = `${this.environmentService.getMailFromName()} <${this.environmentService.getMailFromAddress()}> `;
await this.mailDriver.sendMail({ from: sender, ...message });
}
}
3 changes: 3 additions & 0 deletions apps/server/src/integrations/mail/mail.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const mailLogName = (driverName: string) => {
return `Mail::${driverName}`;
};
67 changes: 67 additions & 0 deletions apps/server/src/integrations/mail/providers/mail.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { EnvironmentService } from '../../environment/environment.service';
import { MailOption, PostmarkConfig, SMTPConfig } from '../interfaces';
import { SmtpDriver, PostmarkDriver, LogDriver } from '../drivers';
import { MailDriver } from '../drivers/interfaces/mail-driver.interface';
import { MailConfig } from '../interfaces';
import { MAIL_CONFIG_TOKEN, MAIL_DRIVER_TOKEN } from '../mail.constants';
import SMTPTransport from 'nodemailer/lib/smtp-transport';

function createMailDriver(mail: MailConfig): MailDriver {
switch (mail.driver) {
case MailOption.SMTP:
return new SmtpDriver(mail.config as SMTPConfig);
case MailOption.Postmark:
return new PostmarkDriver(mail.config as PostmarkConfig);
case MailOption.Log:
return new LogDriver();
default:
throw new Error(`Unknown mail driver`);
}
}

export const mailDriverConfigProvider = {
provide: MAIL_CONFIG_TOKEN,
useFactory: async (environmentService: EnvironmentService) => {
const driver = environmentService.getMailDriver().toLocaleLowerCase();

if (driver === MailOption.SMTP) {
return {
driver,
config: {
host: environmentService.getMailHost(),
port: environmentService.getMailPort(),
connectionTimeout: 30 * 1000, // 30 seconds
auth: {
user: environmentService.getMailUsername(),
pass: environmentService.getMailPassword(),
},
} as SMTPTransport.Options,
};
}

if (driver === MailOption.Postmark) {
return {
driver,
config: {
postmarkToken: environmentService.getPostmarkToken(),
} as PostmarkConfig,
};
}

if (driver === MailOption.Log) {
return {
driver,
};
}

throw new Error(`Unknown mail driver: ${driver}`);
},

inject: [EnvironmentService],
};

export const mailDriverProvider = {
provide: MAIL_DRIVER_TOKEN,
useFactory: (config: MailConfig) => createMailDriver(config),
inject: [MAIL_CONFIG_TOKEN],
};
1 change: 1 addition & 0 deletions apps/server/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"strict": true,
"jsx": "react",
"paths": {
"@docmost/db": ["./src/kysely"],
"@docmost/db/*": ["./src/kysely/*"],
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"editor-ext:editor-ext": "nx run editor-ext:build",
"client:dev": "nx run client:dev",
"server:dev": "nx run server:start:dev",
"server:start": "nx run server:start:prod"
"server:start": "nx run server:start:prod",
"email:dev": "nx run @docmost/transactional:dev"

},
"dependencies": {
"@docmost/editor-ext": "workspace:*",
Expand Down
16 changes: 16 additions & 0 deletions packages/transactional/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.env
# compiled output
/dist
/node_modules

# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# OS
.DS_Store
Loading

0 comments on commit 4c573b9

Please sign in to comment.