Add mail driver (#3205)
* Add node mailer packages * Init mailer module * Add logger transport * Use env variable to get transport * Revert "Add node mailer packages" This reverts commit 3fb954f0caef94266f96ff5f08de750073ab3491. * Add nodemailer * Use driver pattern * Use logger * Fix yarn install * Code review returns * Add configuration examples for smtp * Fix merge conflict * Add missing packages * Fix ci
This commit is contained in:
4
.github/workflows/ci-utils.yaml
vendored
4
.github/workflows/ci-utils.yaml
vendored
@ -28,6 +28,6 @@ jobs:
|
|||||||
- name: Utils / Install Dependencies
|
- name: Utils / Install Dependencies
|
||||||
run: yarn
|
run: yarn
|
||||||
- name: Utils / Run Danger.js
|
- 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:
|
env:
|
||||||
DANGER_GITHUB_API_TOKEN: ${{ github.token }}
|
DANGER_GITHUB_API_TOKEN: ${{ github.token }}
|
||||||
|
|||||||
@ -50,6 +50,7 @@
|
|||||||
"@types/lodash.camelcase": "^4.3.7",
|
"@types/lodash.camelcase": "^4.3.7",
|
||||||
"@types/lodash.merge": "^4.6.7",
|
"@types/lodash.merge": "^4.6.7",
|
||||||
"@types/mailparser": "^3.4.4",
|
"@types/mailparser": "^3.4.4",
|
||||||
|
"@types/nodemailer": "^6.4.14",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"afterframe": "^1.0.2",
|
"afterframe": "^1.0.2",
|
||||||
"apollo-server-express": "^3.12.0",
|
"apollo-server-express": "^3.12.0",
|
||||||
@ -103,6 +104,7 @@
|
|||||||
"nest-commander": "^3.12.0",
|
"nest-commander": "^3.12.0",
|
||||||
"next": "14.0.4",
|
"next": "14.0.4",
|
||||||
"next-mdx-remote": "^4.4.1",
|
"next-mdx-remote": "^4.4.1",
|
||||||
|
"nodemailer": "^6.9.8",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
|||||||
@ -6,6 +6,8 @@ sidebar_custom_props:
|
|||||||
---
|
---
|
||||||
|
|
||||||
import OptionTable from '@site/src/theme/OptionTable'
|
import OptionTable from '@site/src/theme/OptionTable'
|
||||||
|
import Tabs from '@theme/Tabs';
|
||||||
|
import TabItem from '@theme/TabItem';
|
||||||
|
|
||||||
## Frontend
|
## 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'],
|
['FRONT_AUTH_CALLBACK_URL', 'http://localhost:3001/verify ', 'Callback used for Login page'],
|
||||||
]}></OptionTable>
|
]}></OptionTable>
|
||||||
|
|
||||||
|
### Email
|
||||||
|
|
||||||
|
<OptionTable options={[
|
||||||
|
['EMAIL_DRIVER', 'logger', "Email driver: 'logger' (to log emails in console) or 'smtp'"],
|
||||||
|
['EMAIL_SMTP_HOST', '', 'Email Smtp Host'],
|
||||||
|
['EMAIL_SMTP_PORT', '', 'Email Smtp Port'],
|
||||||
|
['EMAIL_SMTP_USER', '', 'Email Smtp User'],
|
||||||
|
['EMAIL_SMTP_PASSWORD', '', 'Email Smtp Password'],
|
||||||
|
]}></OptionTable>
|
||||||
|
|
||||||
|
#### Email SMTP Server configuration examples
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
|
||||||
|
<TabItem value="Gmail" label="Gmail" default>
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem value="Office365" label="Office365">
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem value="Smtp4dev" label="Smtp4dev">
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|
||||||
<OptionTable options={[
|
<OptionTable options={[
|
||||||
@ -82,6 +131,7 @@ import OptionTable from '@site/src/theme/OptionTable'
|
|||||||
|
|
||||||
|
|
||||||
### Data enrichment and AI
|
### Data enrichment and AI
|
||||||
|
|
||||||
<OptionTable options={[
|
<OptionTable options={[
|
||||||
['OPENROUTER_API_KEY', '', "The API key for openrouter.ai, an abstraction layer over models from Mistral, OpenAI and more"]
|
['OPENROUTER_API_KEY', '', "The API key for openrouter.ai, an abstraction layer over models from Mistral, OpenAI and more"]
|
||||||
]}></OptionTable>
|
]}></OptionTable>
|
||||||
@ -96,6 +146,7 @@ import OptionTable from '@site/src/theme/OptionTable'
|
|||||||
]}></OptionTable>
|
]}></OptionTable>
|
||||||
|
|
||||||
### Telemetry
|
### Telemetry
|
||||||
|
|
||||||
<OptionTable options={[
|
<OptionTable options={[
|
||||||
['TELEMETRY_ENABLED', 'true', 'Change this if you want to disable telemetry'],
|
['TELEMETRY_ENABLED', 'true', 'Change this if you want to disable telemetry'],
|
||||||
['TELEMETRY_ANONYMIZATION_ENABLED', 'true', 'Telemetry is anonymized by default, you probably don\'t want to change this'],
|
['TELEMETRY_ANONYMIZATION_ENABLED', 'true', 'Telemetry is anonymized by default, you probably don\'t want to change this'],
|
||||||
|
|||||||
@ -39,3 +39,8 @@ SIGN_IN_PREFILLED=true
|
|||||||
# REDIS_PORT=6379
|
# REDIS_PORT=6379
|
||||||
# DEMO_WORKSPACE_IDS=REPLACE_ME_WITH_A_RANDOM_UUID
|
# DEMO_WORKSPACE_IDS=REPLACE_ME_WITH_A_RANDOM_UUID
|
||||||
# SERVER_URL=http://localhost:3000
|
# SERVER_URL=http://localhost:3000
|
||||||
|
# EMAIL_DRIVER=logger
|
||||||
|
# EMAIL_SMTP_HOST=
|
||||||
|
# EMAIL_SMTP_PORT=
|
||||||
|
# EMAIL_SMTP_USER=
|
||||||
|
# EMAIL_SMTP_PASSWORD=
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
import { SendMailOptions } from 'nodemailer';
|
||||||
|
|
||||||
|
export interface EmailDriver {
|
||||||
|
send(sendMailOptions: SendMailOptions): Promise<void>;
|
||||||
|
}
|
||||||
@ -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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<void> {
|
||||||
|
await this.transport.sendMail(sendMailOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export const EMAIL_DRIVER = Symbol('EMAIL_DRIVER');
|
||||||
@ -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`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<void> {
|
||||||
|
await this.driver.send(sendMailOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ModuleMetadata, 'imports'> &
|
||||||
|
Pick<FactoryProvider, 'inject'>;
|
||||||
@ -2,6 +2,8 @@
|
|||||||
import { Injectable, LogLevel } from '@nestjs/common';
|
import { Injectable, LogLevel } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import { EmailDriver } from 'src/integrations/email/interfaces/email.interface';
|
||||||
|
|
||||||
import { LoggerDriverType } from 'src/integrations/logger/interfaces';
|
import { LoggerDriverType } from 'src/integrations/logger/interfaces';
|
||||||
import { ExceptionHandlerDriver } from 'src/integrations/exception-handler/interfaces';
|
import { ExceptionHandlerDriver } from 'src/integrations/exception-handler/interfaces';
|
||||||
import { StorageDriverType } from 'src/integrations/file-storage/interfaces';
|
import { StorageDriverType } from 'src/integrations/file-storage/interfaces';
|
||||||
@ -170,6 +172,28 @@ export class EnvironmentService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEmailDriver(): EmailDriver {
|
||||||
|
return (
|
||||||
|
this.configService.get<EmailDriver>('EMAIL_DRIVER') ?? EmailDriver.Logger
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmailHost(): string | undefined {
|
||||||
|
return this.configService.get<string>('EMAIL_SMTP_HOST');
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmailPort(): number | undefined {
|
||||||
|
return this.configService.get<number>('EMAIL_SMTP_PORT');
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmailUser(): string | undefined {
|
||||||
|
return this.configService.get<string>('EMAIL_SMTP_USER');
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmailPassword(): string | undefined {
|
||||||
|
return this.configService.get<string>('EMAIL_SMTP_PASSWORD');
|
||||||
|
}
|
||||||
|
|
||||||
getSupportDriver(): string {
|
getSupportDriver(): string {
|
||||||
return (
|
return (
|
||||||
this.configService.get<string>('SUPPORT_DRIVER') ?? SupportDriver.None
|
this.configService.get<string>('SUPPORT_DRIVER') ?? SupportDriver.None
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { exceptionHandlerModuleFactory } from 'src/integrations/exception-handle
|
|||||||
import { fileStorageModuleFactory } from 'src/integrations/file-storage/file-storage.module-factory';
|
import { fileStorageModuleFactory } from 'src/integrations/file-storage/file-storage.module-factory';
|
||||||
import { loggerModuleFactory } from 'src/integrations/logger/logger.module-factory';
|
import { loggerModuleFactory } from 'src/integrations/logger/logger.module-factory';
|
||||||
import { messageQueueModuleFactory } from 'src/integrations/message-queue/message-queue.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 { EnvironmentModule } from './environment/environment.module';
|
||||||
import { EnvironmentService } from './environment/environment.service';
|
import { EnvironmentService } from './environment/environment.service';
|
||||||
@ -32,6 +34,10 @@ import { MessageQueueModule } from './message-queue/message-queue.module';
|
|||||||
useFactory: exceptionHandlerModuleFactory,
|
useFactory: exceptionHandlerModuleFactory,
|
||||||
inject: [EnvironmentService, HttpAdapterHost],
|
inject: [EnvironmentService, HttpAdapterHost],
|
||||||
}),
|
}),
|
||||||
|
EmailModule.forRoot({
|
||||||
|
useFactory: emailModuleFactory,
|
||||||
|
inject: [EnvironmentService],
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
exports: [],
|
exports: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
"name": "twenty-utils",
|
"name": "twenty-utils",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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"
|
"release": "node release.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
yarn.lock
13
yarn.lock
@ -14422,6 +14422,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@types/normalize-package-data@npm:^2.4.0":
|
||||||
version: 2.4.4
|
version: 2.4.4
|
||||||
resolution: "@types/normalize-package-data@npm:2.4.4"
|
resolution: "@types/normalize-package-data@npm:2.4.4"
|
||||||
@ -33184,7 +33193,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"nodemailer@npm:6.9.8":
|
"nodemailer@npm:6.9.8, nodemailer@npm:^6.9.8":
|
||||||
version: 6.9.8
|
version: 6.9.8
|
||||||
resolution: "nodemailer@npm:6.9.8"
|
resolution: "nodemailer@npm:6.9.8"
|
||||||
checksum: 9332587975240ac648e1295b1df15e339fcace3f7fab8af0382e7f2dd10e48296344dfa698d58f1667f220f7fe13c779d55d39144c9cd9ed6f5f559714183c75
|
checksum: 9332587975240ac648e1295b1df15e339fcace3f7fab8af0382e7f2dd10e48296344dfa698d58f1667f220f7fe13c779d55d39144c9cd9ed6f5f559714183c75
|
||||||
@ -41568,6 +41577,7 @@ __metadata:
|
|||||||
"@types/mailparser": "npm:^3.4.4"
|
"@types/mailparser": "npm:^3.4.4"
|
||||||
"@types/ms": "npm:^0.7.31"
|
"@types/ms": "npm:^0.7.31"
|
||||||
"@types/node": "npm:^20.10.6"
|
"@types/node": "npm:^20.10.6"
|
||||||
|
"@types/nodemailer": "npm:^6.4.14"
|
||||||
"@types/passport-google-oauth20": "npm:^2.0.11"
|
"@types/passport-google-oauth20": "npm:^2.0.11"
|
||||||
"@types/passport-jwt": "npm:^3.0.8"
|
"@types/passport-jwt": "npm:^3.0.8"
|
||||||
"@types/react": "npm:^18.2.39"
|
"@types/react": "npm:^18.2.39"
|
||||||
@ -41655,6 +41665,7 @@ __metadata:
|
|||||||
nest-commander: "npm:^3.12.0"
|
nest-commander: "npm:^3.12.0"
|
||||||
next: "npm:14.0.4"
|
next: "npm:14.0.4"
|
||||||
next-mdx-remote: "npm:^4.4.1"
|
next-mdx-remote: "npm:^4.4.1"
|
||||||
|
nodemailer: "npm:^6.9.8"
|
||||||
nx: "npm:^17.2.8"
|
nx: "npm:^17.2.8"
|
||||||
openapi-types: "npm:^12.1.3"
|
openapi-types: "npm:^12.1.3"
|
||||||
passport: "npm:^0.6.0"
|
passport: "npm:^0.6.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user