email integration

* Nest email module with smtp, postmark and console log drivers
* react-email package
This commit is contained in:
Philipinho
2024-05-02 03:12:40 +01:00
parent 48be0c21ae
commit 4c573b9bc2
26 changed files with 2685 additions and 446 deletions

View File

@ -21,3 +21,17 @@ AWS_S3_BUCKET=
AWS_S3_ENDPOINT= AWS_S3_ENDPOINT=
AWS_S3_URL= AWS_S3_URL=
AWS_S3_USE_PATH_STYLE_ENDPOINT=false 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=hello@example.com
MAIL_FROM_NAME=
# for postmark driver
POSTMARK_TOKEN=

View File

@ -41,6 +41,7 @@
"@nestjs/platform-socket.io": "^10.3.8", "@nestjs/platform-socket.io": "^10.3.8",
"@nestjs/serve-static": "^4.0.2", "@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.3.8", "@nestjs/websockets": "^10.3.8",
"@react-email/render": "^0.0.13",
"@types/pg": "^8.11.5", "@types/pg": "^8.11.5",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bytes": "^3.1.2", "bytes": "^3.1.2",
@ -52,9 +53,11 @@
"kysely-migration-cli": "^0.4.0", "kysely-migration-cli": "^0.4.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"nestjs-kysely": "^0.1.7", "nestjs-kysely": "^0.1.7",
"nodemailer": "^6.9.13",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.11.5", "pg": "^8.11.5",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"postmark": "^4.0.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sanitize-filename-ts": "^1.0.2", "sanitize-filename-ts": "^1.0.2",
@ -75,13 +78,14 @@
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/nodemailer": "^6.4.14",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^7.8.0", "@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0", "@typescript-eslint/parser": "^7.8.0",
"eslint": "^9.1.1", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0", "jest": "^29.7.0",

View File

@ -17,6 +17,7 @@ import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module'; import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module'; import { CaslModule } from './casl/casl.module';
import { DomainMiddleware } from '../middlewares/domain.middleware'; import { DomainMiddleware } from '../middlewares/domain.middleware';
import { MailModule } from '../integrations/mail/mail.module';
@Module({ @Module({
imports: [ imports: [
@ -27,6 +28,9 @@ import { DomainMiddleware } from '../middlewares/domain.middleware';
StorageModule.forRootAsync({ StorageModule.forRootAsync({
imports: [EnvironmentModule], imports: [EnvironmentModule],
}), }),
MailModule.forRootAsync({
imports: [EnvironmentModule],
}),
AttachmentModule, AttachmentModule,
CommentModule, CommentModule,
SearchModule, SearchModule,

View File

@ -70,4 +70,36 @@ export class EnvironmentService {
isSelfHosted(): boolean { isSelfHosted(): boolean {
return !this.isCloud(); 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');
}
} }

View File

@ -0,0 +1,3 @@
export { SmtpDriver } from './smtp.driver';
export { PostmarkDriver } from './postmark.driver';
export { LogDriver } from './log.driver';

View File

@ -0,0 +1,5 @@
import { MailMessage } from '../../interfaces/mail.message';
export interface MailDriver {
sendMail(message: MailMessage): Promise<void>;
}

View File

@ -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)}`);
}
}

View File

@ -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}`);
}
}
}

View File

@ -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}`);
}
}
}

View File

@ -0,0 +1 @@
export * from './mail.interface';

View File

@ -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[];
}

View File

@ -0,0 +1,7 @@
export interface MailMessage {
from: string;
to: string;
subject: string;
text?: string;
html?: string;
}

View File

@ -0,0 +1,2 @@
export const MAIL_DRIVER_TOKEN = 'MAIL_DRIVER_TOKEN';
export const MAIL_CONFIG_TOKEN = 'MAIL_CONFIG_TOKEN';

View File

@ -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],
};
}
}

View File

@ -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 });
}
}

View File

@ -0,0 +1,3 @@
export const mailLogName = (driverName: string) => {
return `Mail::${driverName}`;
};

View File

@ -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],
};

View File

@ -18,6 +18,7 @@
"forceConsistentCasingInFileNames": false, "forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false, "noFallthroughCasesInSwitch": false,
"strict": true, "strict": true,
"jsx": "react",
"paths": { "paths": {
"@docmost/db": ["./src/kysely"], "@docmost/db": ["./src/kysely"],
"@docmost/db/*": ["./src/kysely/*"], "@docmost/db/*": ["./src/kysely/*"],

View File

@ -9,7 +9,9 @@
"editor-ext:editor-ext": "nx run editor-ext:build", "editor-ext:editor-ext": "nx run editor-ext:build",
"client:dev": "nx run client:dev", "client:dev": "nx run client:dev",
"server:dev": "nx run server:start: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": { "dependencies": {
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",

16
packages/transactional/.gitignore vendored Normal file
View File

@ -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

View File

@ -0,0 +1,53 @@
export const fontFamily = "HelveticaNeue,Helvetica,Arial,sans-serif";
export const main = {
backgroundColor: "#edf2f7",
fontFamily,
};
export const container = {
maxWidth: "580px",
margin: "30px auto",
backgroundColor: "#ffffff",
borderColor: "#e8e5ef",
borderRadius: "2px",
borderWidth: "1px",
boxShadow: "0 2px 0 rgba(0, 0, 150, 0.025), 2px 4px 0 rgba(0, 0, 150, 0.015)",
};
export const content = {
padding: "5px 20px 10px 20px",
};
export const paragraph = {
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
color: "#333",
lineHeight: 1.5,
fontSize: 14,
};
export const h1 = {
color: "#333",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "24px",
fontWeight: "bold",
padding: "0",
};
export const logo = {
display: "flex",
justifyContent: "center",
alingItems: "center",
padding: 4,
};
export const link = {
textDecoration: "underline",
};
export const footer = {
maxWidth: "580px",
margin: "0 auto",
};

View File

@ -0,0 +1,26 @@
import { Container, Section, Text } from "@react-email/components";
import * as React from "react";
import { container, content, paragraph } from "../css/styles";
import { MailBody, MailFooter } from "../partials/partials";
interface WelcomeEmailProps {
username?: string;
}
export const TestEmail = ({ username }: WelcomeEmailProps) => {
return (
<MailBody>
<Container style={container}>
<Section style={content}>
<Text style={paragraph}>Hi {username},</Text>
<Text style={paragraph}>
This is a test email. Make sure to read it.
</Text>
</Section>
</Container>
<MailFooter />
</MailBody>
);
};
export default TestEmail;

View File

@ -0,0 +1,10 @@
{
"name": "@docmost/transactional",
"scripts": {
"dev": "email dev -p 5019"
},
"dependencies": {
"@react-email/components": "0.0.17",
"react-email": "^2.1.2"
}
}

View File

@ -0,0 +1,43 @@
import { footer, h1, logo, main } from "../css/styles";
import {
Body,
Head,
Heading,
Html,
Row,
Section,
Text,
} from "@react-email/components";
import * as React from "react";
interface MailBodyProps {
children: React.ReactNode;
}
export function MailBody({ children }: MailBodyProps) {
return (
<Html>
<Head />
<Body style={main}>{children}</Body>
</Html>
);
}
export function MailHeader() {
return (
<Section style={logo}>
<Heading style={h1}>logo/text</Heading>
</Section>
);
}
export function MailFooter() {
return (
<Section style={footer}>
<Row>
<Text style={{ textAlign: "center", color: "#706a7b" }}>
© {new Date().getFullYear()}, All Rights Reserved <br />
</Text>
</Row>
</Section>
);
}

View File

@ -0,0 +1,6 @@
export const formatDate = (date: Date) => {
new Intl.DateTimeFormat("en", {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
};

2680
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff