├── . dockerignore ├── src ├── commons │ ├── index.ts │ ├── mongoIndexes.ts │ ├── interfaces │ │ ├── ITMap.ts │ │ └── index.ts │ ├── const.ts │ └── test-helper.ts ├── middlewares │ ├── index.ts │ ├── request.logger.ts │ └── graceful.shutdown.ts ├── services │ ├── index.ts │ ├── mta │ │ ├── index.ts │ │ ├── mta.interface.ts │ │ ├── mailgun.mta.ts │ │ ├── sendgrid.mta.ts │ │ ├── mta.base.ts │ │ ├── mta.base.spec.ts │ │ ├── mailgun.mta.spec.ts │ │ └── sendgrid.mta.spec.ts │ ├── jobs │ │ ├── jobabstract.interface.ts │ │ ├── mail.recover.job.ts │ │ ├── mail.recover.job.spec.ts │ │ ├── jobabstract.ts │ │ └── jobabstract.spec.ts │ ├── workers │ │ ├── mta.strategy.service.interface.ts │ │ ├── mta.strategy.service.ts │ │ ├── mail.sending.worker.ts │ │ ├── mta.strategy.service.spec.ts │ │ └── mail.sending.worker.spec.ts │ ├── queue │ │ ├── main.queue.spec.ts │ │ ├── dead.queue.spec.ts │ │ ├── main.queue.ts │ │ ├── dead.queue.ts │ │ ├── queue.base.interface.ts │ │ ├── queue.base.ts │ │ ├── queue.consumer.base.spec.ts │ │ ├── queue.consumer.base.ts │ │ └── queue.base.spec.ts │ ├── mail.service.interface.ts │ ├── mail.service.ts │ └── mail.service.spec.ts ├── providers │ ├── index.ts │ ├── global.validation.provider.ts │ ├── logger.provider.ts │ ├── redis.provider.spec.ts │ ├── config.provider.ts │ ├── logger.provider.spec.ts │ ├── error.filter.provider.ts │ ├── redis.provider.ts │ ├── database.provider.ts │ └── database.provider.spec.ts ├── conf │ ├── test.json │ ├── travis.json │ ├── production.json │ ├── mylocal.json │ └── default.json ├── models │ └── mail.model.ts ├── controllers │ ├── health.controller.ts │ ├── mail.controller.ts │ └── mail.controller.spec.ts ├── modules │ ├── mail.module.ts │ ├── worker.module.ts │ ├── app.module.ts │ └── global.module.ts ├── dto │ ├── queue.dto.ts │ ├── index.ts │ └── mail.controller.dto.ts ├── app.ts └── repositories │ └── index.ts ├── .coveralls.yml ├── docs ├── diagram.png ├── modules.png └── how_worker_fetch_pending_emails.png ├── .deepsource.toml ├── scripts └── deploy.sh ├── .gitignore ├── .npmignore ├── Dockerfile ├── tsconfig-prod.json ├── jest-e2e.json ├── tslint.json ├── jest-cover.json ├── release.changelog.angular.js ├── .vscode ├── settings.json └── launch.json ├── docker-compose.yaml ├── .travis.yml ├── tsconfig.json ├── release.config.js ├── test ├── app.e2e-spec.ts └── mail.e2e-spec.ts ├── CHANGELOG.md ├── package.json └── README.md /. dockerignore: -------------------------------------------------------------------------------- 1 | test/ 2 | coverage/ 3 | .vscode/ 4 | scripts/ 5 | -------------------------------------------------------------------------------- /src/commons/index.ts: -------------------------------------------------------------------------------- 1 | export * from './const'; 2 | export * from './interfaces'; 3 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-pro 2 | repo_token: iHWDmVmZIP19Raa9m6sTCfEhPTyL0PV5X 3 | -------------------------------------------------------------------------------- /docs/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immanuel192/nest-mail-service/HEAD/docs/diagram.png -------------------------------------------------------------------------------- /docs/modules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immanuel192/nest-mail-service/HEAD/docs/modules.png -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './graceful.shutdown'; 2 | export * from './request.logger'; 3 | -------------------------------------------------------------------------------- /docs/how_worker_fetch_pending_emails.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immanuel192/nest-mail-service/HEAD/docs/how_worker_fetch_pending_emails.png -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "shell" 5 | enabled = true 6 | 7 | [[analyzers]] 8 | name = "javascript" 9 | enabled = true -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mail.service.interface'; 2 | export * from './queue/queue.base.interface'; 3 | export * from './mta/mta.interface'; 4 | -------------------------------------------------------------------------------- /src/services/mta/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mta.interface'; 2 | export { MailGunMTA } from './mailgun.mta'; 3 | export { SendGridMTA } from './sendgrid.mta'; 4 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then 5 | echo "This is PR, no need to deploy" 6 | exit 0 7 | fi 8 | 9 | npm run dist 10 | echo "Deploying to PRODUCTION...." 11 | -------------------------------------------------------------------------------- /src/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config.provider'; 2 | export * from './error.filter.provider'; 3 | export * from './global.validation.provider'; 4 | export * from './logger.provider'; 5 | export * from './database.provider'; 6 | export * from './redis.provider'; 7 | -------------------------------------------------------------------------------- /src/conf/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "mongodb": { 3 | "host": "localhost:27020", 4 | "user": "", 5 | "password": "", 6 | "database": "mails-test", 7 | "replicaSet": "" 8 | }, 9 | "redis": { 10 | "host": "localhost", 11 | "port": "6380", 12 | "db": 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/conf/travis.json: -------------------------------------------------------------------------------- 1 | { 2 | "mongodb": { 3 | "host": "localhost:27017", 4 | "user": "", 5 | "password": "", 6 | "database": "mails-test", 7 | "replicaSet": "" 8 | }, 9 | "redis": { 10 | "host": "localhost", 11 | "port": "6379", 12 | "db": 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .nyc_output 3 | .project 4 | .vscode/ 5 | app.zip 6 | coverage/ 7 | current.zip 8 | current/* 9 | dist.zip 10 | dist/* 11 | **/dist/* 12 | **/node_modules/* 13 | npm-debug.log 14 | reports/ 15 | package-lock.json 16 | yarn.lock 17 | *.js 18 | *.d.ts 19 | .npmrc 20 | local.json 21 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nvmrc 3 | src 4 | .gitignore 5 | .npmignore 6 | .npmrc 7 | .nvmrc 8 | tsconfig.json 9 | tsconfig-prod.json 10 | tslint.json 11 | .babelrc 12 | .vscode 13 | .nyc_output 14 | coverage 15 | docs 16 | test 17 | .editorconfig 18 | .travis.yml 19 | *.ts 20 | !*.d.ts 21 | package-lock.json 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mhart/alpine-node:latest 2 | 3 | RUN mkdir -p /var/app/current/src 4 | WORKDIR /var/app/current 5 | COPY *.json /var/app/current/ 6 | COPY src/. /var/app/current/src/ 7 | RUN npm install 8 | RUN npm run dist:build 9 | RUN rm -rf /var/app/current/src 10 | ENV NODE_ENV production 11 | EXPOSE 9000 12 | 13 | CMD npm run start 14 | -------------------------------------------------------------------------------- /src/providers/global.validation.provider.ts: -------------------------------------------------------------------------------- 1 | import { FactoryProvider } from '@nestjs/common/interfaces'; 2 | import { APP_PIPE } from '@nestjs/core'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | 5 | export const providerGlobalValidation: FactoryProvider = { 6 | provide: APP_PIPE, 7 | useFactory: () => new ValidationPipe({ transform: true }) 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig-prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "sourceMap": false, 6 | "inlineSourceMap": false 7 | }, 8 | "include": [ 9 | "*.ts", 10 | "**/*.ts" 11 | ], 12 | "exclude": [ 13 | "**/node_modules", 14 | "**/*.spec.ts", 15 | "test/*.*" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/services/jobs/jobabstract.interface.ts: -------------------------------------------------------------------------------- 1 | import { ICronJobConfig } from 'nest-schedule'; 2 | 3 | export enum EJobType { 4 | cron = 'cron', 5 | interval = 'interval', 6 | timeout = 'timeout' 7 | } 8 | 9 | export interface IJobConfig extends ICronJobConfig { 10 | type: EJobType; 11 | cron?: string; 12 | interval?: number; 13 | timeout?: number; 14 | } 15 | -------------------------------------------------------------------------------- /src/conf/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "mongodb": { 3 | "host": "mongo:27017", 4 | "user": "", 5 | "password": "", 6 | "database": "mails", 7 | "replicaSet": "" 8 | }, 9 | "redis": { 10 | "host": "redis", 11 | "port": "6379", 12 | "db": 2 13 | }, 14 | "jobs": { 15 | "mail-recover": { 16 | "enable": true 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/providers/logger.provider.ts: -------------------------------------------------------------------------------- 1 | import { FactoryProvider } from '@nestjs/common/interfaces'; 2 | import { Logger } from '@nestjs/common'; 3 | import { PROVIDERS } from '../commons/const'; 4 | 5 | export const providerLogger: FactoryProvider = { 6 | provide: PROVIDERS.ROOT_LOGGER, 7 | /** 8 | * @todo Implement custom logger here 9 | */ 10 | useFactory: () => Logger, 11 | }; 12 | -------------------------------------------------------------------------------- /src/providers/redis.provider.spec.ts: -------------------------------------------------------------------------------- 1 | import { providerRedis } from './redis.provider'; 2 | import { PROVIDERS } from '../commons'; 3 | 4 | describe('/src/providers/redis.provider.ts', () => { 5 | it('should register as REDIS factory provider', () => { 6 | expect(providerRedis).toMatchObject({ 7 | provide: PROVIDERS.REDIS, 8 | useFactory: expect.anything() 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/services/workers/mta.strategy.service.interface.ts: -------------------------------------------------------------------------------- 1 | import { MailStatusDto } from '../../dto'; 2 | import { IMailTransferAgent } from '../mta'; 3 | 4 | export abstract class IMTAStategyService { 5 | /** 6 | * Smart fetch the correspond MTA accoridng to the MTA config 7 | * @param lastStatus Last email status 8 | */ 9 | abstract getMTA(lastStatus: MailStatusDto): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /src/conf/mylocal.json: -------------------------------------------------------------------------------- 1 | { 2 | "mongodb": { 3 | "host": "localhost:27020", 4 | "user": "", 5 | "password": "", 6 | "database": "mails", 7 | "replicaSet": "" 8 | }, 9 | "redis": { 10 | "host": "localhost", 11 | "port": "6380", 12 | "db": 1 13 | }, 14 | "jobs": { 15 | "mail-recover": { 16 | "enable": true, 17 | "type": "timeout", 18 | "timeout": 3000 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/providers/config.provider.ts: -------------------------------------------------------------------------------- 1 | import { FactoryProvider } from '@nestjs/common/interfaces'; 2 | import { IConfiguration } from '../commons/interfaces'; 3 | 4 | function getConfigurationInstance(): IConfiguration { 5 | process.env['NODE_CONFIG_DIR'] = __dirname + '/../conf'; 6 | const config = require('config'); 7 | return config; 8 | } 9 | 10 | export const providerConfig: FactoryProvider = { 11 | provide: IConfiguration, 12 | useFactory: () => getConfigurationInstance() 13 | }; 14 | -------------------------------------------------------------------------------- /src/models/mail.model.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { MailStatusDto } from '../dto'; 3 | 4 | export interface MailModel { 5 | _id?: ObjectId; 6 | to: string[]; 7 | cc: string[]; 8 | bcc: string[]; 9 | title: string; 10 | content: string; 11 | status: MailStatusDto[]; 12 | 13 | /** 14 | * Actual send on date, when user created this email 15 | */ 16 | sentOn?: Date; 17 | 18 | /** 19 | * Deliveried date 20 | */ 21 | deliveriedDate?: Date; 22 | } 23 | -------------------------------------------------------------------------------- /src/services/queue/main.queue.spec.ts: -------------------------------------------------------------------------------- 1 | import { MainQueue } from './main.queue'; 2 | import { IOC_KEY, QUEUES } from '../../commons'; 3 | 4 | describe('/src/services/queue/main.queue.ts', () => { 5 | describe('IoC', () => { 6 | it('should have class information as expected', () => { 7 | expect(MainQueue[IOC_KEY]).not.toBeUndefined(); 8 | expect(MainQueue[IOC_KEY]).toMatchObject({ 9 | provide: QUEUES.MAIN, 10 | useClass: MainQueue 11 | }); 12 | }); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/controllers/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Head } from '@nestjs/common'; 2 | import { join } from 'path'; 3 | const pkg = require(join(process.cwd(), 'package.json')); 4 | 5 | @Controller('') 6 | export default class HealthController { 7 | @Get('/health') 8 | @Head('/health') 9 | show() { 10 | const healthInfo = ['name', 'version'].reduce((acc: any, key: string) => { 11 | acc[key] = (pkg)[key]; 12 | return acc; 13 | }, {}); 14 | return healthInfo; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/queue/dead.queue.spec.ts: -------------------------------------------------------------------------------- 1 | import { DeadQueue } from './dead.queue'; 2 | import { IOC_KEY, QUEUES } from '../../commons'; 3 | 4 | describe('/src/services/queue/dead.queue.ts', () => { 5 | describe('IoC', () => { 6 | it('should have class information as expected', () => { 7 | expect(DeadQueue[IOC_KEY]).not.toBeUndefined(); 8 | expect(DeadQueue[IOC_KEY]).toMatchObject({ 9 | provide: QUEUES.DEADLETTER, 10 | useClass: DeadQueue 11 | }); 12 | }); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "roots": [ 3 | "/test" 4 | ], 5 | "transform": { 6 | "^.+\\.ts$": "ts-jest" 7 | }, 8 | "testRegex": ".e2e-spec.ts$", 9 | "testPathIgnorePatterns": [ 10 | "/node_modules/" 11 | ], 12 | "coveragePathIgnorePatterns": [ 13 | "/node_modules/" 14 | ], 15 | "collectCoverageFrom": [ 16 | "/src/**/*.ts", 17 | "/test/**/*.ts", 18 | "!/src/**/*.d.ts", 19 | "!**/node_modules/**" 20 | ], 21 | "testEnvironment": "node" 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/mail.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { IOC_KEY } from '../commons'; 3 | import { IMailCollection } from '../repositories'; 4 | import { MailService } from '../services/mail.service'; 5 | import MailController from '../controllers/mail.controller'; 6 | 7 | @Module({ 8 | controllers: [ 9 | MailController 10 | ], 11 | providers: [ 12 | IMailCollection[IOC_KEY], 13 | MailService[IOC_KEY] 14 | ], 15 | exports: [ 16 | MailService[IOC_KEY] 17 | ] 18 | }) 19 | export class MailModule { 20 | } 21 | -------------------------------------------------------------------------------- /src/commons/mongoIndexes.ts: -------------------------------------------------------------------------------- 1 | import { IDatabaseInstance } from './interfaces'; 2 | 3 | export function createMongoDbIndexes(db: IDatabaseInstance) { 4 | return db.collection('mails') 5 | .then((collection) => { 6 | return collection.createIndexes([ 7 | { 8 | key: { 9 | 'status.type': 1 10 | }, 11 | background: true 12 | }, 13 | { 14 | key: { 15 | 'status.0.type': 1, 16 | sentOn: 1 17 | }, 18 | background: true 19 | } 20 | ]); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/providers/logger.provider.spec.ts: -------------------------------------------------------------------------------- 1 | import { providerLogger } from './logger.provider'; 2 | import { PROVIDERS } from '../commons'; 3 | import { Logger } from '@nestjs/common'; 4 | 5 | describe('/src/providers/logger.provider.ts', () => { 6 | it('should register as ROOT_LOGGER factory provider', () => { 7 | expect(providerLogger).toMatchObject({ 8 | provide: PROVIDERS.ROOT_LOGGER, 9 | useFactory: expect.anything() 10 | }); 11 | }); 12 | 13 | it('should resolve logger instance', () => { 14 | expect(providerLogger.useFactory()).toEqual(Logger); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-airbnb", 3 | "rules": { 4 | "align": false, 5 | "trailing-comma": true, 6 | "strict-boolean-expressions": false, 7 | "no-boolean-literal-compare": false, 8 | "max-line-length": false, 9 | "brace-style": false, 10 | "import-name": false, 11 | "prefer-template": false, 12 | "no-increment-decrement": false, 13 | "indent": [ 14 | true, 15 | "spaces", 16 | 2 17 | ], 18 | "variable-name": [ 19 | true, 20 | "check-format", 21 | "allow-pascal-case", 22 | "allow-leading-underscore" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /jest-cover.json: -------------------------------------------------------------------------------- 1 | { 2 | "roots": [ 3 | "/test", 4 | "/src" 5 | ], 6 | "transform": { 7 | "^.+\\.ts$": "ts-jest" 8 | }, 9 | "testRegex": ".(e2e-spec|spec).ts$", 10 | "testPathIgnorePatterns": [ 11 | "/node_modules/" 12 | ], 13 | "coveragePathIgnorePatterns": [ 14 | "/node_modules/", 15 | "app.ts", 16 | "test-helper.ts", 17 | "error.filter.provider.ts", 18 | "middlewares" 19 | ], 20 | "collectCoverageFrom": [ 21 | "/src/**/*.ts", 22 | "/test/**/*.ts", 23 | "!/src/**/*.d.ts", 24 | "!**/node_modules/**" 25 | ], 26 | "testEnvironment": "node" 27 | } 28 | -------------------------------------------------------------------------------- /release.changelog.angular.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const allowKeys = ['ci', 'refactor', 'doc']; 4 | 5 | module.exports = (() => { 6 | return Promise.resolve(require('conventional-changelog-angular')) 7 | .then((config) => { 8 | const bkTransform = config.writerOpts.transform; 9 | config.writerOpts.transform = (commit, context) => { 10 | const isCustomCommit = allowKeys.some(k => k === commit.type) === true; 11 | isCustomCommit && commit.notes.push({ title: '' }); 12 | const ret = bkTransform(commit, context); 13 | isCustomCommit && commit.notes.pop(); 14 | return ret; 15 | }; 16 | return config; 17 | }); 18 | })(); 19 | -------------------------------------------------------------------------------- /src/modules/worker.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ScheduleModule } from 'nest-schedule'; 3 | import { IOC_KEY } from '../commons'; 4 | import { MailRecoverJob } from '../services/jobs/mail.recover.job'; 5 | import { MailModule } from './mail.module'; 6 | import { MailSendingWorker } from '../services/workers/mail.sending.worker'; 7 | import { MTAStategyService } from '../services/workers/mta.strategy.service'; 8 | 9 | @Module({ 10 | imports: [ 11 | ScheduleModule.register(), 12 | MailModule 13 | ], 14 | providers: [ 15 | MailRecoverJob, 16 | MailSendingWorker, 17 | MTAStategyService[IOC_KEY] 18 | ] 19 | }) 20 | export class WorkerModule { } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tslint.autoFixOnSave": true, 3 | "editor.formatOnPaste": true, 4 | "editor.formatOnSave": true, 5 | "editor.formatOnType": false, 6 | "editor.snippetSuggestions": "bottom", 7 | "editor.suggestSelection": "recentlyUsedByPrefix", 8 | "editor.suggest.filterGraceful": true, 9 | "editor.suggest.snippetsPreventQuickSuggestions": true, 10 | "editor.tabSize": 2, 11 | "git.enableSmartCommit": true, 12 | "typescript.preferences.quoteStyle": "single", 13 | "editor.suggest.localityBonus": true, 14 | "javascript.preferences.importModuleSpecifier": "relative", 15 | "javascript.preferences.quoteStyle": "single", 16 | "typescript.tsdk": "node_modules/typescript/lib", 17 | } 18 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | container_name: nest-mail-service 5 | restart: "always" 6 | build: . 7 | ports: 8 | - "9001:9000" 9 | links: 10 | - mongo 11 | - redis 12 | networks: 13 | - backend 14 | 15 | mongo: 16 | image: mongo 17 | container_name: mongo 18 | ports: 19 | - "27020:27017" 20 | networks: 21 | - backend 22 | environment: 23 | - MONGODB_DATABASE=mails 24 | 25 | redis: 26 | image: "bitnami/redis" 27 | container_name: redis 28 | ports: 29 | - "6380:6379" 30 | networks: 31 | - backend 32 | environment: 33 | - ALLOW_EMPTY_PASSWORD=yes 34 | 35 | networks: 36 | backend: 37 | driver: "bridge" 38 | -------------------------------------------------------------------------------- /src/commons/interfaces/ITMap.ts: -------------------------------------------------------------------------------- 1 | export interface IStringTMap { [key: string]: T; } 2 | export interface INumberTMap { [key: number]: T; } 3 | 4 | export interface INumberAnyMap extends INumberTMap { } 5 | 6 | export interface IStringStringMap extends IStringTMap { } 7 | export interface INumberStringMap extends INumberTMap { } 8 | 9 | export interface IStringNumberMap extends IStringTMap { } 10 | export interface INumberNumberMap extends INumberTMap { } 11 | export interface IStringAnyMap extends IStringTMap { } 12 | 13 | export interface IStringBooleanMap extends IStringTMap { } 14 | export interface INumberBooleanMap extends INumberTMap { } 15 | 16 | export interface Newable { 17 | new(...args: any[]): T; 18 | } 19 | -------------------------------------------------------------------------------- /src/middlewares/request.logger.ts: -------------------------------------------------------------------------------- 1 | import * as morgan from 'morgan'; 2 | import { NestMiddleware, Injectable, Inject } from '@nestjs/common'; 3 | import { ILoggerInstance } from '../commons'; 4 | import { PROVIDERS } from '../commons/const'; 5 | import { RequestHandler } from '@nestjs/common/interfaces'; 6 | 7 | @Injectable() 8 | export class MwRequestLogger implements NestMiddleware { 9 | private mw: RequestHandler; 10 | 11 | constructor( 12 | @Inject(PROVIDERS.ROOT_LOGGER) 13 | private readonly logger: ILoggerInstance 14 | ) { 15 | this.mw = morgan('combined', { 16 | stream: { 17 | write: (message: string) => this.logger.log(message) 18 | } 19 | }); 20 | 21 | } 22 | 23 | use(req: any, res: any, next: () => void) { 24 | this.mw(req, res, next); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/dto/queue.dto.ts: -------------------------------------------------------------------------------- 1 | export interface QueueMessageDto { 2 | /** 3 | * The message's contents. 4 | * 5 | * @type {string} 6 | * @memberof QueueMessage 7 | */ 8 | message: string; 9 | 10 | /** 11 | * The internal message id. 12 | * 13 | * @type {string} 14 | * @memberof QueueMessage 15 | */ 16 | id: number; 17 | 18 | /** 19 | * Timestamp of when this message was sent / created. 20 | * 21 | * @type {number} 22 | * @memberof QueueMessage 23 | */ 24 | sent: number; 25 | 26 | /** 27 | * Timestamp of when this message was first received. 28 | * 29 | * @type {number} 30 | * @memberof QueueMessage 31 | */ 32 | fr: number; 33 | 34 | /** 35 | * Number of times this message was received. 36 | * 37 | * @type {number} 38 | * @memberof QueueMessage 39 | */ 40 | rc: number; 41 | } 42 | -------------------------------------------------------------------------------- /src/services/queue/main.queue.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { ClassProvider } from '@nestjs/common/interfaces'; 3 | import { QueueBase } from './queue.base'; 4 | import { IConfiguration, ILoggerInstance, PROVIDERS, QUEUES, IOC_KEY } from '../../commons'; 5 | 6 | @Injectable() 7 | export class MainQueue extends QueueBase { 8 | 9 | /** 10 | * This queue will support realtime 11 | * 12 | * @protected 13 | * @type {boolean} 14 | * @memberof MainQueue 15 | */ 16 | protected realtime: boolean = true; 17 | 18 | constructor( 19 | protected readonly configService: IConfiguration, 20 | @Inject(PROVIDERS.ROOT_LOGGER) 21 | protected readonly logger: ILoggerInstance 22 | ) { 23 | super(QUEUES.MAIN); 24 | } 25 | 26 | static get [IOC_KEY](): ClassProvider { 27 | return { 28 | provide: QUEUES.MAIN, 29 | useClass: MainQueue 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10.12.0 4 | sudo: required 5 | dist: trusty 6 | services: 7 | - mongodb 8 | - redis-server 9 | - mysql 10 | branches: 11 | only: 12 | - master 13 | - "/^feature.*$/" 14 | - "/^v[0-9].*$/" 15 | git: 16 | depth: false 17 | 18 | cache: 19 | bundler: true 20 | directories: 21 | - node_modules 22 | 23 | before_install: 24 | - chmod +x scripts/* 25 | 26 | jobs: 27 | include: 28 | - stage: test 29 | name: Unit test & Integration test 30 | script: 31 | - npm run cover:travis 32 | - npm run lint:verify:build 33 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 34 | - stage: deploy 35 | if: "(branch = master) OR (tag =~ ^v[0-9].*$)" 36 | name: Deploy 37 | script: 38 | - scripts/deploy.sh 39 | notifications: 40 | email: 41 | recipients: 42 | - trungdt@absoft.vn 43 | env: 44 | matrix: 45 | - NODE_ENV=test 46 | -------------------------------------------------------------------------------- /src/services/queue/dead.queue.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { ClassProvider } from '@nestjs/common/interfaces'; 3 | import { QueueBase } from './queue.base'; 4 | import { IConfiguration, ILoggerInstance, PROVIDERS, QUEUES, IOC_KEY } from '../../commons'; 5 | 6 | @Injectable() 7 | export class DeadQueue extends QueueBase { 8 | /** 9 | * This queue does not need realtime 10 | * 11 | * @protected 12 | * @type {boolean} 13 | * @memberof MainQueue 14 | */ 15 | protected realtime: boolean = false; 16 | 17 | constructor( 18 | protected readonly configService: IConfiguration, 19 | @Inject(PROVIDERS.ROOT_LOGGER) 20 | protected readonly logger: ILoggerInstance 21 | ) { 22 | super(QUEUES.DEADLETTER); 23 | } 24 | 25 | static get [IOC_KEY](): ClassProvider { 26 | return { 27 | provide: QUEUES.DEADLETTER, 28 | useClass: DeadQueue 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/services/queue/queue.base.interface.ts: -------------------------------------------------------------------------------- 1 | import { QueueMessageDto } from '../../dto'; 2 | 3 | /** 4 | * Queue interface for worker 5 | */ 6 | export abstract class IQueueConsumer { 7 | /** 8 | * Fetch message from queue 9 | * @returns Message id 10 | */ 11 | abstract receive(): Promise; 12 | 13 | /** 14 | * Delete message by id 15 | * @param id 16 | */ 17 | abstract delete(id: number): Promise; 18 | 19 | /** 20 | * Update message visibility 21 | * @param id Message id 22 | * @param vt New message visibility, in seconds 23 | */ 24 | abstract updateVisibility(id: number, vt: number): Promise; 25 | } 26 | 27 | /** 28 | * Queue interface in general 29 | */ 30 | export abstract class IQueueProducer { 31 | /** 32 | * Send message to queue. 33 | * @param message 34 | * @returns Message id 35 | */ 36 | abstract send(message: string): Promise; 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, NestModule, MiddlewareConsumer, OnModuleInit } from '@nestjs/common'; 2 | import { GlobalModule } from './global.module'; 3 | import { MwGracefulShutdown, MwRequestLogger } from '../middlewares'; 4 | import { IDatabaseInstance } from '../commons'; 5 | import { createMongoDbIndexes } from '../commons/mongoIndexes'; 6 | import { MailModule } from './mail.module'; 7 | import { WorkerModule } from './worker.module'; 8 | 9 | @Module({ 10 | imports: [ 11 | GlobalModule.forRoot(), 12 | MailModule, 13 | WorkerModule 14 | ] 15 | }) 16 | export class AppModule implements NestModule, OnModuleInit { 17 | constructor( 18 | private readonly database: IDatabaseInstance 19 | ) { } 20 | 21 | configure(consumer: MiddlewareConsumer) { 22 | consumer.apply(MwGracefulShutdown).forRoutes('/'); 23 | consumer.apply(MwRequestLogger).forRoutes('/'); 24 | } 25 | 26 | async onModuleInit() { 27 | await createMongoDbIndexes(this.database); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/services/mta/mta.interface.ts: -------------------------------------------------------------------------------- 1 | import { AttemptMailSendingDto } from '../../dto'; 2 | import { EMailProcessingStatus } from '../../commons'; 3 | 4 | /** 5 | * Mail Transfer Agent Interface 6 | */ 7 | export abstract class IMailTransferAgent { 8 | /** 9 | * MTA Name 10 | * 11 | * @abstract 12 | * @type {string} 13 | * @memberof IMailTransferAgent 14 | */ 15 | abstract name: string; 16 | 17 | /** 18 | * Return true if this MTA is available to be consumed. The value of this property will be controlled by the circuit breaker 19 | * 20 | * @type {boolean} 21 | * @memberof IMailTransferAgent 22 | */ 23 | abstract isAvailable: boolean; 24 | 25 | /** 26 | * Attempt to send email 27 | * @param mail 28 | */ 29 | abstract send(mail: AttemptMailSendingDto): Promise; 30 | 31 | /** 32 | * Try to do anything to init the MTA. Will reject in case of issue 33 | */ 34 | abstract init(): Promise; 35 | 36 | /** 37 | * Get how many retries in maximum this MTA allows 38 | */ 39 | abstract getMaxRetries(): number; 40 | } 41 | -------------------------------------------------------------------------------- /src/commons/const.ts: -------------------------------------------------------------------------------- 1 | export const IOC_KEY = Symbol('ioc'); 2 | 3 | export const PROVIDERS = { 4 | ROOT_LOGGER: 'rootLogger', 5 | REDIS: 'redis' 6 | }; 7 | 8 | export enum EMailStatus { 9 | /** Just insert mail document */ 10 | Init = 'init', 11 | 12 | /** Attempt to send with a mail provider */ 13 | Attempt = 'Attemp', 14 | 15 | /** Mail sending successfully */ 16 | Success = 'Success', 17 | 18 | /** Can not send email after configured retries */ 19 | Fail = 'Fail' 20 | } 21 | 22 | export const QUEUES = { 23 | MAIN: 'main', 24 | DEADLETTER: 'dead' 25 | }; 26 | 27 | export const JOB_IDS = { 28 | MAIL_RECOVERY: 'mail-recover' 29 | }; 30 | 31 | export const QUEUE_NAMESPACE = 'rsmq'; 32 | 33 | export const QUEUE_RETRY_CHECK = 5; // Retry pull this message from the queue again in 5s 34 | 35 | /** 36 | * Maximum items to be fetched when idle 37 | */ 38 | export const QUEUE_MAXFETCH = 50; 39 | /** 40 | * Maximum idle time for the queue consumer 41 | */ 42 | export const QUEUE_MAXIDLE = 1000 * 5; 43 | 44 | export enum EMailProcessingStatus { 45 | Success = 'Success', Retry = 'Retry', Outdated = 'Outdated' 46 | } 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "outDir": "./dist", 5 | "rootDir": "./src", 6 | "baseUrl": "/", 7 | "module": "commonjs", 8 | "target": "es2017", 9 | "noImplicitAny": true, 10 | "skipLibCheck": true, 11 | "alwaysStrict": true, 12 | "allowUnreachableCode": false, 13 | "inlineSourceMap": true, 14 | "lib": [ 15 | "es6", 16 | "es7", 17 | "dom" 18 | ], 19 | "allowSyntheticDefaultImports": false, 20 | "forceConsistentCasingInFileNames": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noImplicitReturns": true, 24 | "noImplicitThis": true, 25 | "importHelpers": false, 26 | "noEmitHelpers": false, 27 | "strict": false, 28 | "moduleResolution": "node", 29 | "experimentalDecorators": true, 30 | "emitDecoratorMetadata": true, 31 | "removeComments": true, 32 | "resolveJsonModule": true, 33 | "declaration": false, 34 | }, 35 | "include": [ 36 | "src/**/*.ts", 37 | "test/**/*.ts", 38 | ], 39 | "exclude": [ 40 | "node_modules", 41 | "dist" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /src/services/mail.service.interface.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { InsertMailInfoDto, MailDto, MailStatusDto } from '../dto'; 3 | 4 | export abstract class IMailService { 5 | /** 6 | * Request send email 7 | * @param mail 8 | */ 9 | abstract insert(mail: InsertMailInfoDto): Promise; 10 | 11 | /** 12 | * Get mail by its id 13 | * @param id 14 | */ 15 | abstract getMailById(id: string): Promise; 16 | 17 | /** 18 | * Fetch all pending mails 19 | * @param inp.limit Default is 100 20 | * @param inp.fromId ObjectId to start query from 21 | * @param inp.bufferTime How many seconds to be buffer 22 | */ 23 | abstract fetchPendingMails( 24 | inp?: { fromId?: ObjectId, limit?: number, bufferTime?: number }) 25 | : Promise; 26 | 27 | /** 28 | * Update mail status 29 | * @param id 30 | * @param status 31 | */ 32 | abstract updateMailStatus(id: ObjectId | string, status: MailStatusDto): Promise; 33 | 34 | /** 35 | * Add new mail status 36 | * @param id 37 | * @param status 38 | */ 39 | abstract addMailStatus(id: ObjectId | string, status: MailStatusDto): Promise; 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/global.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module, DynamicModule } from '@nestjs/common'; 2 | import HealthController from '../controllers/health.controller'; 3 | import { 4 | providerLogger, providerConfig, Database, providerRedis, providerGlobalValidation, providerErrorFilter 5 | } from '../providers'; 6 | import { IOC_KEY } from '../commons'; 7 | import { MainQueue } from '../services/queue/main.queue'; 8 | import { DeadQueue } from '../services/queue/dead.queue'; 9 | 10 | @Global() 11 | @Module({}) 12 | export class GlobalModule { 13 | static forRoot(): DynamicModule { 14 | return { 15 | module: GlobalModule, 16 | controllers: [ 17 | HealthController 18 | ], 19 | providers: [ 20 | providerGlobalValidation, 21 | providerErrorFilter, 22 | providerConfig, 23 | providerLogger, 24 | Database[IOC_KEY], 25 | providerRedis, 26 | MainQueue[IOC_KEY], 27 | DeadQueue[IOC_KEY] 28 | ], 29 | exports: [ 30 | providerConfig, 31 | providerLogger, 32 | Database[IOC_KEY], 33 | providerRedis, 34 | MainQueue[IOC_KEY], 35 | DeadQueue[IOC_KEY] 36 | ] 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/commons/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ITMap'; 2 | import { Collection, Db } from 'mongodb'; 3 | 4 | /** 5 | * Configuration interface 6 | * 7 | * @interface IConfiguration 8 | */ 9 | export abstract class IConfiguration { 10 | abstract get(name?: string): T | null; 11 | } 12 | 13 | export type ILoggerInstance = { 14 | error(message: any, trace?: string, context?: string): void; 15 | log(message: any, context?: string): void; 16 | warn(message: any, context?: string): void; 17 | debug(message: any, context?: string): void; 18 | verbose(message: any, context?: string): void; 19 | }; 20 | 21 | export interface DbConnectionOptions { 22 | host: string; 23 | user: string; 24 | password?: string; 25 | database: string; 26 | replicaSet?: string; 27 | authSource?: string; 28 | } 29 | 30 | export abstract class IDatabaseInstance { 31 | /** 32 | * Open connection 33 | * @param options 34 | */ 35 | abstract open(options?: any): Promise; 36 | /** 37 | * Get collection instance 38 | * @param name 39 | */ 40 | abstract collection(name: string): Promise>; 41 | /** 42 | * Close all connections 43 | */ 44 | abstract close(): void; 45 | } 46 | -------------------------------------------------------------------------------- /src/conf/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "mongodb": { 3 | "host": "localhost:27017", 4 | "user": "", 5 | "password": "", 6 | "database": "mails", 7 | "replicaSet": "" 8 | }, 9 | "redis": { 10 | "host": "localhost", 11 | "port": "6379", 12 | "db": 1 13 | }, 14 | "jobs": { 15 | "mail-recover": { 16 | "enable": false, 17 | "bufferTime": 1800, 18 | "type": "cron", 19 | "cron": "*/30 * * * *" 20 | } 21 | }, 22 | "mails": { 23 | "sendgrid": { 24 | "authKey": "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", 25 | "from": "trungdt@absoft.vn", 26 | "maxRetries": 3, 27 | "circuit": { 28 | "timeout": 6000, 29 | "errorThresholdPercentage": 50, 30 | "resetTimeout": 30000 31 | } 32 | }, 33 | "mailgun": { 34 | "domain": "sandbox8aa1bf5250504af59ef5fe1a9582f0f2.mailgun.org", 35 | "apiKey": "0342938a78974f52446a4af5c4924dd1-73ae490d-52e521f2", 36 | "from": "postmaster@sandbox8aa1bf5250504af59ef5fe1a9582f0f2.mailgun.org", 37 | "maxRetries": 3, 38 | "circuit": { 39 | "timeout": 6000, 40 | "errorThresholdPercentage": 50, 41 | "resetTimeout": 30000 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/dto/index.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { EMailStatus } from '../commons'; 3 | export * from './mail.controller.dto'; 4 | export * from './queue.dto'; 5 | 6 | /** 7 | * Insert mail info dto 8 | */ 9 | export interface InsertMailInfoDto { 10 | title: string; 11 | content: string; 12 | to: string[]; 13 | cc?: string[]; 14 | bcc?: string[]; 15 | } 16 | 17 | /** 18 | * An email dto 19 | */ 20 | export interface MailDto { 21 | _id?: ObjectId; 22 | to: string[]; 23 | cc: string[]; 24 | bcc: string[]; 25 | title: string; 26 | content: string; 27 | status: any[]; 28 | 29 | /** 30 | * Actual send on date, when user created this email 31 | */ 32 | sentOn?: Date; 33 | 34 | /** 35 | * Deliveried date 36 | */ 37 | deliveriedDate?: Date; 38 | } 39 | 40 | /** 41 | * An DTO to describe a mail when attempt to be sent 42 | */ 43 | export interface AttemptMailSendingDto { 44 | to: string[]; 45 | cc: string[]; 46 | bcc: string[]; 47 | title: string; 48 | content: string; 49 | } 50 | 51 | export interface MailStatusDto { 52 | [k: string]: any; 53 | /** 54 | * How many times has been retry 55 | */ 56 | retries?: number; 57 | /** 58 | * MTA name of previous try, only available in type Attempt 59 | */ 60 | mta?: string; 61 | type: EMailStatus; 62 | } 63 | -------------------------------------------------------------------------------- /src/providers/error.filter.provider.ts: -------------------------------------------------------------------------------- 1 | import { Catch, HttpException, ArgumentsHost, HttpStatus, ExceptionFilter, Inject } from '@nestjs/common'; 2 | import { ClassProvider } from '@nestjs/common/interfaces'; 3 | import { pick } from 'lodash'; 4 | import { APP_FILTER } from '@nestjs/core'; 5 | import { PROVIDERS, ILoggerInstance } from '../commons'; 6 | 7 | @Catch() 8 | class ErrorFilter implements ExceptionFilter { 9 | constructor( 10 | @Inject(PROVIDERS.ROOT_LOGGER) 11 | private readonly logger: ILoggerInstance 12 | ) { } 13 | 14 | catch(exception: Error, host: ArgumentsHost) { 15 | const ctx = host.switchToHttp(); 16 | const response = ctx.getResponse(); 17 | const request = ctx.getRequest(); 18 | 19 | const status = exception instanceof HttpException 20 | ? exception.getStatus() 21 | : HttpStatus.INTERNAL_SERVER_ERROR; 22 | 23 | this.logger.error(exception); 24 | 25 | response.status(status).json({ 26 | statusCode: status, 27 | message: (exception.message as any).message || exception.message, 28 | timestamp: new Date().toISOString(), 29 | uri: request.url, 30 | method: request.method, 31 | ...pick(exception.message || {}, ['data']) 32 | }); 33 | } 34 | } 35 | 36 | export const providerErrorFilter: ClassProvider = { 37 | provide: APP_FILTER, 38 | useClass: ErrorFilter, 39 | }; 40 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 3 | import { AppModule } from './modules/app.module'; 4 | import * as _ from 'lodash'; 5 | import { inspect } from 'util'; 6 | import * as responseTime from 'response-time'; 7 | import * as compression from 'compression'; 8 | const helmet = require('helmet'); 9 | 10 | function handleErrors() { 11 | process.on('uncaughtException', (e) => { 12 | console.error(`Exiting due to unhandled exception: ${inspect(e)}`); 13 | }); 14 | 15 | process.on('unhandledRejection', (reason, promise) => { 16 | console.error(`Exiting due to unhandled rejection: ${reason} ${inspect(promise)}`); 17 | }); 18 | } 19 | 20 | async function bootstrap() { 21 | const app = await NestFactory.create(AppModule); 22 | app.enableCors(); 23 | app.use(helmet()); 24 | app.use(responseTime({ 25 | digits: 0, 26 | suffix: false 27 | })); 28 | app.use(compression({ level: 9, memLevel: 9 })); 29 | handleErrors(); 30 | 31 | const options = new DocumentBuilder() 32 | .setTitle('nest-mail-service') 33 | .setDescription('nest-mail-service api') 34 | .build(); 35 | const document = SwaggerModule.createDocument(app, options); 36 | SwaggerModule.setup('docs', app, document); 37 | 38 | await app.listen(process.env.NODE_PORT || 9000); 39 | } 40 | 41 | bootstrap() 42 | .catch((e) => { 43 | throw e; 44 | }); 45 | -------------------------------------------------------------------------------- /src/controllers/mail.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Body, Inject } from '@nestjs/common'; 2 | import { ApiUseTags, ApiCreatedResponse, ApiBadRequestResponse, ApiOperation, ApiInternalServerErrorResponse } from '@nestjs/swagger'; 3 | import { IMailService, IQueueProducer } from '../services'; 4 | import { SendMailRequestDto, SendMailResponseDto } from '../dto'; 5 | import { QUEUES, ILoggerInstance, PROVIDERS } from '../commons'; 6 | 7 | @ApiUseTags('email') 8 | @Controller('/api/emails') 9 | export default class MailController { 10 | constructor( 11 | @Inject(QUEUES.MAIN) 12 | private readonly mainQueue: IQueueProducer, 13 | @Inject(PROVIDERS.ROOT_LOGGER) 14 | private readonly logger: ILoggerInstance, 15 | private readonly mailService: IMailService, 16 | ) { } 17 | 18 | @Post('') 19 | @ApiOperation({ title: 'Send email' }) 20 | @ApiCreatedResponse({ 21 | description: 'Email sending request has been created successfully', 22 | type: SendMailResponseDto 23 | }) 24 | @ApiBadRequestResponse({}) 25 | @ApiInternalServerErrorResponse({}) 26 | async create( 27 | @Body() 28 | inp: SendMailRequestDto, 29 | ) { 30 | const newMailInfo = await this.mailService.insert({ ...inp }); 31 | const newMailId = newMailInfo._id.toHexString(); 32 | try { 33 | await this.mainQueue.send(newMailId); 34 | } 35 | catch (e) { 36 | this.logger.error('Can not dispatch new mail sending to main queue', e.stack); 37 | } 38 | return { 39 | data: { id: newMailId } 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "plugins": [ 3 | [ 4 | "@semantic-release/commit-analyzer", 5 | { 6 | "preset": "angular", 7 | "releaseRules": [ 8 | { 9 | "type": "docs", 10 | "release": "patch" 11 | }, 12 | { 13 | "type": "refactor", 14 | "release": "patch" 15 | }, 16 | { 17 | "type": "style", 18 | "release": "patch" 19 | }, 20 | { 21 | "type": "chore", 22 | "release": "patch" 23 | } 24 | ] 25 | } 26 | ], 27 | [ 28 | "@semantic-release/release-notes-generator", 29 | { 30 | "config": './release.changelog.angular.js', 31 | "writerOpts": { 32 | "commitsSort": [ 33 | "type", 34 | "subject", 35 | "scope" 36 | ] 37 | } 38 | } 39 | ], 40 | "@semantic-release/changelog", 41 | [ 42 | "@semantic-release/npm", 43 | { 44 | "npmPublish": false 45 | } 46 | ], 47 | [ 48 | "@semantic-release/git", 49 | { 50 | "assets": [ 51 | "CHANGELOG.md", 52 | "package.json" 53 | ], 54 | "message": "chore(release): release <%= nextRelease.version %> - <%= new Date().toLocaleDateString('en-US', {year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' }) %>\n\n<%= nextRelease.notes %>" 55 | } 56 | ], 57 | "@semantic-release/github" 58 | ] 59 | }; 60 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | import * as _ from 'lodash'; 5 | import { AppModule } from '../src/modules/app.module'; 6 | import { NoopLogger } from '../src/commons/test-helper'; 7 | import { PROVIDERS } from '../src/commons'; 8 | 9 | /** 10 | * Integration test for Jobs 11 | */ 12 | describe('/src/app.ts', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const module = await Test.createTestingModule({ 17 | imports: [ 18 | AppModule 19 | ] 20 | }) 21 | .overrideProvider(PROVIDERS.ROOT_LOGGER) 22 | .useValue(NoopLogger) 23 | .compile(); 24 | 25 | app = module.createNestApplication(); 26 | await app.init(); 27 | }); 28 | 29 | afterAll(async () => { 30 | await app.close(); 31 | }); 32 | 33 | describe('Common', () => { 34 | describe('GET /health', () => { 35 | it('should return HTTP 200', () => { 36 | return request(app.getHttpServer()) 37 | .get('/health') 38 | .expect(200) 39 | .then((res) => { 40 | expect(res.body).toMatchObject({ 41 | name: 'nest-mail-service', 42 | version: expect.anything() 43 | }); 44 | }); 45 | }); 46 | }); 47 | 48 | describe('HEAD /health', () => { 49 | it('should return HTTP 200', () => { 50 | return request(app.getHttpServer()) 51 | .head('/health') 52 | .expect(200); 53 | }); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/providers/redis.provider.ts: -------------------------------------------------------------------------------- 1 | import { FactoryProvider } from '@nestjs/common/interfaces'; 2 | import * as Redis from 'ioredis'; 3 | import * as Bluebird from 'bluebird'; 4 | import { memoize } from 'lodash'; 5 | import { PROVIDERS } from '../commons/const'; 6 | import { IConfiguration, ILoggerInstance } from '../commons/interfaces'; 7 | (Redis as any).Promise = Promise; 8 | 9 | /** 10 | * Create new redis connection, wait until connection established 11 | */ 12 | export const newRedisConnection: (config: IConfiguration, logger: ILoggerInstance, name?: string) => Bluebird = memoize((config: IConfiguration, logger: ILoggerInstance, connectionName?: string) => { 13 | return new Bluebird((resolve) => { 14 | const { host, port, db, keyPrefix } = config.get('redis'); 15 | const redis = new Redis({ 16 | connectionName, 17 | host, 18 | port, 19 | db, 20 | keyPrefix, 21 | enableReadyCheck: true, 22 | enableOfflineQueue: false 23 | }); 24 | redis.on('error', async (error) => { 25 | logger.error(`Redis connection error ${JSON.stringify(error)}`); 26 | // try to reconnect 27 | await redis.connect(); 28 | }); 29 | redis.on('ready', () => resolve(redis)); 30 | }).timeout(6000, 'Redis unavailable'); 31 | }, (...args: any[]) => ((args.length === 3) ? args[2] : '') + '-redis'); 32 | 33 | export const providerRedis: FactoryProvider = { 34 | provide: PROVIDERS.REDIS, 35 | inject: [IConfiguration, PROVIDERS.ROOT_LOGGER], 36 | useFactory: (config: IConfiguration, logger: ILoggerInstance) => newRedisConnection(config, logger) 37 | }; 38 | -------------------------------------------------------------------------------- /src/middlewares/graceful.shutdown.ts: -------------------------------------------------------------------------------- 1 | import { NestMiddleware, Injectable, Inject, HttpServer } from '@nestjs/common'; 2 | import { HttpAdapterHost } from '@nestjs/core'; 3 | import { ILoggerInstance } from '../commons'; 4 | import { PROVIDERS } from '../commons/const'; 5 | 6 | const FORCE_TIMEOUT = 20000; // 20s 7 | 8 | @Injectable() 9 | export class MwGracefulShutdown implements NestMiddleware { 10 | private shuttingDown: boolean = false; 11 | private httpServer: HttpServer; 12 | 13 | constructor( 14 | @Inject(PROVIDERS.ROOT_LOGGER) 15 | private readonly logger: ILoggerInstance, 16 | httpAdapterHost: HttpAdapterHost 17 | ) { 18 | this.httpServer = httpAdapterHost.httpAdapter.getHttpServer(); 19 | process.on('SIGTERM', this.gracefulExit.bind(this)); 20 | } 21 | 22 | private gracefulExit(): void { 23 | if (!process.env.NODE_ENV) { 24 | return process.exit(1); 25 | } 26 | if (this.shuttingDown) { 27 | return; 28 | } 29 | this.shuttingDown = true; 30 | this.logger.warn('Received kill signal (SIGTERM), shutting down'); 31 | 32 | setTimeout(() => { 33 | this.logger.error('Could not close connections in time, forcefully shutting down'); 34 | process.exit(1); 35 | }, FORCE_TIMEOUT).unref(); 36 | 37 | Promise.resolve() // later can wait to close all connection 38 | .then(() => { 39 | this.httpServer.close(); 40 | setTimeout(() => { 41 | process.exit(); 42 | }, 500); 43 | }); 44 | return null; 45 | } 46 | 47 | use(_req: any, res: any, next: () => void) { 48 | if (!this.shuttingDown) { 49 | return next(); 50 | } 51 | res.set('Connection', 'close'); 52 | res.status(503).send('Server is in the process of restarting.'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/services/workers/mta.strategy.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@nestjs/common'; 2 | import { ClassProvider, OnModuleInit } from '@nestjs/common/interfaces'; 3 | import { IMTAStategyService } from './mta.strategy.service.interface'; 4 | import { IOC_KEY, ILoggerInstance, PROVIDERS, IConfiguration, EMailStatus } from '../../commons'; 5 | import { MailStatusDto } from '../../dto'; 6 | import { IMailTransferAgent, MailGunMTA, SendGridMTA } from '../mta'; 7 | 8 | /** 9 | * Smart MTA Stategy 10 | */ 11 | @Injectable() 12 | export class MTAStategyService implements IMTAStategyService, OnModuleInit { 13 | static get [IOC_KEY](): ClassProvider { 14 | return { 15 | provide: IMTAStategyService, 16 | useClass: MTAStategyService 17 | }; 18 | } 19 | 20 | private availableMTA: IMailTransferAgent[] = []; 21 | 22 | constructor( 23 | @Inject(PROVIDERS.ROOT_LOGGER) 24 | private readonly logger: ILoggerInstance, 25 | private readonly configService: IConfiguration 26 | ) { } 27 | 28 | async onModuleInit() { 29 | this.availableMTA.push(new SendGridMTA(this.logger, this.configService)); 30 | this.availableMTA.push(new MailGunMTA(this.logger, this.configService)); 31 | await Promise.all(this.availableMTA.map(mta => mta.init())); 32 | } 33 | 34 | async getMTA(lastStatus: MailStatusDto): Promise { 35 | // prefer to retry with the previous mta 36 | if (lastStatus.type === EMailStatus.Attempt) { 37 | const previousMTAInstance = this.availableMTA.find(t => t.name === lastStatus.mta && t.isAvailable); 38 | if (previousMTAInstance 39 | && (lastStatus.retries || 0) < previousMTAInstance.getMaxRetries() // can retry? 40 | ) { 41 | return previousMTAInstance; 42 | } 43 | } 44 | 45 | return this.availableMTA.find(t => t.isAvailable 46 | // either first try (Init state) or not use previous one 47 | && (!lastStatus.mta || lastStatus.mta !== t.name) 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/dto/mail.controller.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiModelProperty } from '@nestjs/swagger'; 2 | import { IsString, MinLength, IsOptional, IsEmail, IsArray } from 'class-validator'; 3 | 4 | export class SendMailRequestDto { 5 | @ApiModelProperty({ 6 | required: true, 7 | description: 'Mail title', 8 | minLength: 1 9 | }) 10 | @IsString() 11 | @MinLength(1) 12 | readonly title: string; 13 | 14 | @ApiModelProperty({ 15 | required: true, 16 | description: 'Mail content', 17 | minLength: 1 18 | }) 19 | @IsString() 20 | @MinLength(1) 21 | readonly content: string; 22 | 23 | @ApiModelProperty({ 24 | required: true, 25 | description: 'Receiver addresses', 26 | example: ['test@gmail.com'], 27 | isArray: true, 28 | minLength: 1, 29 | type: String 30 | }) 31 | @IsArray() 32 | @IsString({ each: true }) 33 | @IsEmail({}, { each: true }) 34 | readonly to: string[]; 35 | 36 | @ApiModelProperty({ 37 | required: false, 38 | description: 'CC addresses', 39 | example: ['test@gmail.com'], 40 | isArray: true, 41 | minLength: 1, 42 | type: String 43 | }) 44 | @IsOptional() 45 | @IsArray() 46 | @IsString({ each: true }) 47 | @IsEmail({}, { each: true }) 48 | readonly cc?: string[]; 49 | 50 | @ApiModelProperty({ 51 | required: false, 52 | description: 'BCC addresses', 53 | example: ['test@gmail.com'], 54 | isArray: true, 55 | minLength: 1, 56 | type: String 57 | }) 58 | @IsOptional() 59 | @IsArray() 60 | @IsString({ each: true }) 61 | @IsEmail({}, { each: true }) 62 | readonly bcc?: string[]; 63 | } 64 | 65 | class SendMailResultDto { 66 | @ApiModelProperty({ 67 | required: true, 68 | description: 'Email id, in UUID format', 69 | example: '5d4601128e98533b66875b71' 70 | }) 71 | @IsString() 72 | _id: string; 73 | } 74 | 75 | export class SendMailResponseDto { 76 | @ApiModelProperty({ 77 | required: true, 78 | type: SendMailResultDto 79 | }) 80 | data: SendMailResultDto; 81 | } 82 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.1.1](https://github.com/immanuel192/nest-mail-service/compare/v1.1.0...v1.1.1) (2019-08-07) 2 | 3 | # [1.1.0](https://github.com/immanuel192/nest-mail-service/compare/v1.0.0...v1.1.0) (2019-08-07) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * correct docker file that no need to remove local.josn anymore ([5fc6e41](https://github.com/immanuel192/nest-mail-service/commit/5fc6e41)) 9 | * enable production mail-recover job ([bbfe116](https://github.com/immanuel192/nest-mail-service/commit/bbfe116)) 10 | 11 | 12 | ### Code Refactoring 13 | 14 | * refactor enum of email sending status ([7a5a041](https://github.com/immanuel192/nest-mail-service/commit/7a5a041)) 15 | * update queue produer and consumer ([a7ea93e](https://github.com/immanuel192/nest-mail-service/commit/a7ea93e)) 16 | 17 | 18 | ### Features 19 | 20 | * add mail MTA, and stategy ([3f24ddf](https://github.com/immanuel192/nest-mail-service/commit/3f24ddf)) 21 | * add queue consumer base and mail sending worker ([6a1a8b4](https://github.com/immanuel192/nest-mail-service/commit/6a1a8b4)) 22 | 23 | # 1.0.0 (2019-08-05) 24 | 25 | 26 | ### Bug Fixes 27 | 28 | * fix build deployment ([0a793c1](https://github.com/immanuel192/nest-mail-service/commit/0a793c1)) 29 | 30 | 31 | ### Features 32 | 33 | * add endpoint POST /api/emails ([9fed93d](https://github.com/immanuel192/nest-mail-service/commit/9fed93d)) 34 | * add job abstract for cron job support ([6d213f0](https://github.com/immanuel192/nest-mail-service/commit/6d213f0)) 35 | * add mail.cover job ([b464deb](https://github.com/immanuel192/nest-mail-service/commit/b464deb)) 36 | * add mongo db connection ([8a07775](https://github.com/immanuel192/nest-mail-service/commit/8a07775)) 37 | * add mongodb and redis providers ([d18d9a5](https://github.com/immanuel192/nest-mail-service/commit/d18d9a5)) 38 | * add queue support ([496567c](https://github.com/immanuel192/nest-mail-service/commit/496567c)) 39 | * update POST /api/emails endpoint to enqueue when creating new email ([63620d3](https://github.com/immanuel192/nest-mail-service/commit/63620d3)) 40 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch app", 9 | "type": "node", 10 | "request": "launch", 11 | "args": [ 12 | "src/app.ts" 13 | ], 14 | "runtimeArgs": [ 15 | "-r", 16 | "ts-node/register/transpile-only", 17 | ], 18 | "smartStep": false, 19 | "cwd": "${workspaceRoot}", 20 | "protocol": "auto", 21 | "internalConsoleOptions": "openOnSessionStart", 22 | "env": { 23 | "NODE_ENV": "mylocal", 24 | "NODE_PORT": "9000" 25 | }, 26 | "sourceMaps": true, 27 | "console": "internalConsole", 28 | "outputCapture": "std" 29 | }, 30 | { 31 | "name": "Debug unit test", 32 | "type": "node", 33 | "request": "launch", 34 | "program": "${workspaceRoot}/node_modules/.bin/jest", 35 | "stopOnEntry": false, 36 | "env": { 37 | "NODE_ENV": "test", 38 | "PORT": "9000" 39 | }, 40 | "args": [ 41 | "${relativeFile}" 42 | ], 43 | "cwd": "${workspaceRoot}", 44 | "preLaunchTask": null, 45 | "runtimeExecutable": null, 46 | "runtimeArgs": [ 47 | "--nolazy" 48 | ] 49 | }, 50 | { 51 | "name": "Debug integration test", 52 | "type": "node", 53 | "request": "launch", 54 | "program": "${workspaceRoot}/node_modules/.bin/jest", 55 | "stopOnEntry": false, 56 | "env": { 57 | "NODE_ENV": "test", 58 | "PORT": "9000" 59 | }, 60 | "args": [ 61 | "--config", 62 | "./jest-cover.json", 63 | "${relativeFile}", 64 | "--forceExit" 65 | ], 66 | "cwd": "${workspaceRoot}", 67 | "preLaunchTask": null, 68 | "runtimeExecutable": null, 69 | "runtimeArgs": [ 70 | "--nolazy" 71 | ] 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /src/services/jobs/mail.recover.job.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@nestjs/common'; 2 | import { get } from 'lodash'; 3 | import * as Bluebird from 'bluebird'; 4 | import { JobAbstract } from './jobabstract'; 5 | import { JOB_IDS, QUEUES } from '../../commons'; 6 | import { IMailService } from '../mail.service.interface'; 7 | import { MailDto } from '../../dto'; 8 | import { IQueueProducer } from '../queue/queue.base.interface'; 9 | 10 | /** 11 | * Enqueue emails to queue in case anything failed 12 | */ 13 | @Injectable() 14 | export class MailRecoverJob extends JobAbstract { 15 | protected jobName = JOB_IDS.MAIL_RECOVERY; 16 | protected bufferTime: number = 1800; 17 | 18 | constructor( 19 | private readonly mailService: IMailService, 20 | @Inject(QUEUES.MAIN) 21 | private readonly mainQueue: IQueueProducer 22 | ) { 23 | super(); 24 | } 25 | 26 | async onModuleInit() { 27 | await super.onModuleInit(); 28 | this.bufferTime = get(this.config as any, 'bufferTime', this.bufferTime); 29 | } 30 | 31 | async execute() { 32 | this.logger.debug(`${this.jobName}: Starting job`); 33 | let pendingMails: MailDto[] = []; 34 | let processedCount: number = 0; 35 | let firstId: string = null; 36 | const fetchQuery = { 37 | fromId: null as any, 38 | limit: 100, 39 | bufferTime: this.bufferTime 40 | }; 41 | do { 42 | pendingMails = await this.mailService.fetchPendingMails({ ...fetchQuery }); 43 | if (pendingMails.length === 0) { 44 | break; 45 | } 46 | processedCount += pendingMails.length; 47 | firstId = firstId || pendingMails[0]._id.toHexString(); 48 | 49 | fetchQuery.fromId = pendingMails[pendingMails.length - 1]._id; 50 | this.logger.debug(`Fetched ${pendingMails.length} pending mails`); 51 | 52 | // push to queue with limited concurrency 53 | await Bluebird.map(pendingMails, mail => this.mainQueue.send(mail._id.toHexString()), { concurrency: 5 }); 54 | } while (pendingMails.length > 0); 55 | 56 | this.logger.debug(`${this.jobName}: Finished. Processed ${processedCount} pending mails, started from ${firstId}`); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/services/mta/mailgun.mta.ts: -------------------------------------------------------------------------------- 1 | import { SuperAgentRequest } from 'superagent'; 2 | import { MTABase } from './mta.base'; 3 | import { AttemptMailSendingDto } from '../../dto'; 4 | import { ILoggerInstance, IConfiguration } from '../../commons'; 5 | 6 | export class MailGunMTA extends MTABase { 7 | name = 'mailgun'; 8 | 9 | protected configName: string = 'mailgun'; 10 | 11 | protected defaultConfig: { 12 | domain: string; 13 | apiKey: string; 14 | from: string; 15 | }; 16 | 17 | protected method = 'POST'; 18 | 19 | constructor( 20 | logger: ILoggerInstance, 21 | configService: IConfiguration 22 | ) { 23 | super(logger, configService); 24 | } 25 | 26 | async init() { 27 | const config = this.configService.get('mails'); 28 | this.defaultConfig = config[this.configName]; 29 | if (!this.defaultConfig || 30 | !(this.defaultConfig.apiKey && this.defaultConfig.domain && this.defaultConfig.from) 31 | ) { 32 | throw new Error(`Invalid configuration of mailgun. Actual config: ${JSON.stringify(this.defaultConfig)}`); 33 | } 34 | 35 | super.init(); 36 | } 37 | 38 | protected async prepareAuth(request: SuperAgentRequest) { 39 | request.auth('api', this.defaultConfig.apiKey); 40 | } 41 | 42 | protected prepareUrl() { 43 | return `https://api.mailgun.net/v3/${this.defaultConfig.domain}/messages`; 44 | } 45 | 46 | protected async prepareRequestConfig(request: SuperAgentRequest) { 47 | request.timeout(this.timeout); 48 | request.set('Content-Type', 'application/x-www-form-urlencoded'); 49 | } 50 | 51 | protected async prepareRequestContent(request: SuperAgentRequest, mail: AttemptMailSendingDto) { 52 | /** 53 | * 54 | -F from='Excited User ' \ 55 | -F to=YOU@YOUR_DOMAIN_NAME \ 56 | -F to=bar@example.com \ 57 | -F subject='Hello' \ 58 | -F text='Testing some Mailgun awesomeness!' 59 | */ 60 | const data: any = { 61 | from: this.defaultConfig.from, 62 | to: mail.to, 63 | subject: mail.title, 64 | text: mail.content 65 | }; 66 | if (mail.cc && mail.cc.length > 0) { 67 | data.cc = mail.cc; 68 | } 69 | if (mail.bcc && mail.cc.length > 0) { 70 | data.bcc = mail.bcc; 71 | } 72 | request.field(data); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/services/queue/queue.base.ts: -------------------------------------------------------------------------------- 1 | import * as RSMQ from 'rsmq'; 2 | import { OnModuleInit } from '@nestjs/common'; 3 | import { IConfiguration, ILoggerInstance, QUEUE_NAMESPACE } from '../../commons'; 4 | import { QueueMessageDto } from '../../dto'; 5 | import { IQueueProducer, IQueueConsumer } from './queue.base.interface'; 6 | 7 | /** 8 | * Base queue 9 | */ 10 | export abstract class QueueBase implements IQueueProducer, IQueueConsumer, OnModuleInit { 11 | protected abstract configService: IConfiguration; 12 | protected abstract logger: ILoggerInstance; 13 | protected connection: RSMQ; 14 | 15 | /** 16 | * Whether we will support realtime or not 17 | */ 18 | protected realtime: boolean = false; 19 | 20 | constructor( 21 | private readonly queueName: string 22 | ) { } 23 | 24 | async onModuleInit() { 25 | this.logger.debug(`Init queue ${this.queueName}`); 26 | const redisConfig = this.configService.get('redis'); 27 | this.connection = new RSMQ({ 28 | host: redisConfig.host, 29 | port: redisConfig.port, 30 | ns: QUEUE_NAMESPACE, 31 | realtime: this.realtime, 32 | db: redisConfig.db 33 | } as any); 34 | const queues = await this.connection.listQueuesAsync(); 35 | if (!queues.includes(this.queueName)) { 36 | const result = await this.connection.createQueueAsync({ 37 | qname: this.queueName, 38 | vt: 20 39 | }); 40 | if (result !== 1) { 41 | throw new Error(`Creating queue ${this.queueName} unsuccessful`); 42 | } 43 | this.logger.debug(`Created queue ${this.queueName}`); 44 | } 45 | } 46 | 47 | send(message: string) { 48 | return this.connection.sendMessageAsync({ 49 | message, 50 | qname: this.queueName 51 | }); 52 | } 53 | 54 | receive(): Promise { 55 | return this.connection.receiveMessageAsync({ qname: this.queueName }) 56 | .then((message: QueueMessageDto) => (message.id ? message : null)); 57 | } 58 | 59 | delete(id: number): Promise { 60 | return this.connection.deleteMessageAsync({ id: id.toString(10), qname: this.queueName }).then(t => t === 1); 61 | } 62 | 63 | updateVisibility(id: number, vt: number): Promise { 64 | return this.connection.changeMessageVisibilityAsync({ 65 | vt, 66 | id: id.toString(10), 67 | qname: this.queueName 68 | }).then(t => t === 1); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/services/mta/sendgrid.mta.ts: -------------------------------------------------------------------------------- 1 | import { SuperAgentRequest } from 'superagent'; 2 | import { MTABase } from './mta.base'; 3 | import { AttemptMailSendingDto } from '../../dto'; 4 | import { ILoggerInstance, IConfiguration } from '../../commons'; 5 | 6 | export class SendGridMTA extends MTABase { 7 | name = 'sendgrid'; 8 | 9 | protected configName: string = 'sendgrid'; 10 | 11 | protected defaultConfig: { 12 | authKey: string; 13 | from: string 14 | }; 15 | 16 | protected method = 'POST'; 17 | 18 | constructor( 19 | logger: ILoggerInstance, 20 | configService: IConfiguration 21 | ) { 22 | super(logger, configService); 23 | } 24 | 25 | async init() { 26 | const config = this.configService.get('mails'); 27 | this.defaultConfig = config[this.configName]; 28 | if ( 29 | !this.defaultConfig || !(this.defaultConfig.authKey && this.defaultConfig.from) 30 | ) { 31 | throw new Error(`Invalid configuration of ${this.name}. Actual config: ${JSON.stringify(this.defaultConfig)}`); 32 | } 33 | 34 | super.init(); 35 | } 36 | 37 | protected async prepareAuth(request: SuperAgentRequest) { 38 | request.auth(this.defaultConfig.authKey, { type: 'bearer' }); 39 | } 40 | 41 | protected prepareUrl() { 42 | return 'https://api.sendgrid.com/v3/mail/send'; 43 | } 44 | 45 | protected async prepareRequestConfig(request: SuperAgentRequest) { 46 | request.timeout(this.timeout); 47 | request.set('Content-Type', 'application/json'); 48 | request.accept('application/json'); 49 | } 50 | 51 | protected async prepareRequestContent(request: SuperAgentRequest, mail: AttemptMailSendingDto) { 52 | const data: any = { 53 | personalizations: [ 54 | { 55 | to: [ 56 | ...mail.to.map(email => ({ email })) 57 | ] 58 | } 59 | ], 60 | from: { 61 | email: this.defaultConfig.from 62 | }, 63 | subject: mail.title, 64 | content: [ 65 | { 66 | type: 'text/plain', 67 | value: mail.content 68 | } 69 | ] 70 | }; 71 | if (mail.cc && mail.cc.length > 0) { 72 | data.personalizations[0].cc = [ 73 | ...mail.cc.map(email => ({ email })) 74 | ]; 75 | } 76 | if (mail.bcc && mail.bcc.length > 0) { 77 | data.personalizations[0].bcc = [ 78 | ...mail.bcc.map(email => ({ email })) 79 | ]; 80 | } 81 | request.send(data); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/providers/database.provider.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@nestjs/common'; 2 | import { ClassProvider } from '@nestjs/common/interfaces'; 3 | import * as mongodb from 'mongodb'; 4 | import * as _ from 'lodash'; 5 | import * as url from 'url'; 6 | import { IConfiguration, IDatabaseInstance, ILoggerInstance } from '../commons/interfaces'; 7 | import { IOC_KEY, PROVIDERS } from '../commons'; 8 | 9 | const configName = 'mongodb'; 10 | 11 | @Injectable() 12 | export class Database implements IDatabaseInstance { 13 | protected mongoClient: mongodb.MongoClient = null; 14 | protected database: mongodb.Db = null; 15 | protected connectionString: string = ''; 16 | protected dbName: string = ''; 17 | protected authSource: string; 18 | 19 | static get [IOC_KEY](): ClassProvider { 20 | return { 21 | provide: IDatabaseInstance, 22 | useClass: Database 23 | }; 24 | } 25 | 26 | constructor( 27 | configService: IConfiguration, 28 | @Inject(PROVIDERS.ROOT_LOGGER) 29 | private readonly logger: ILoggerInstance 30 | ) { 31 | const config = configService.get(configName); 32 | this.connectionString = url.format({ 33 | protocol: 'mongodb', 34 | slashes: true, 35 | auth: config.user ? ( 36 | config.user + (config.password ? ':' + config.password : '') 37 | ) : undefined, 38 | host: config.host, 39 | pathname: config.database, 40 | query: config.replicaSet ? { replicaSet: config.replicaSet } : undefined 41 | }); 42 | this.dbName = config.database; 43 | this.authSource = config.authSource; 44 | } 45 | 46 | async open(options?: any): Promise { 47 | const o: mongodb.MongoClientOptions = _.defaultsDeep({}, options || {}, { 48 | authSource: this.authSource, 49 | reconnectTries: Number.MAX_VALUE, 50 | promiseLibrary: Promise 51 | }); 52 | this.logger.debug('Establishing connection to mongodb'); 53 | const client = await mongodb.MongoClient.connect(this.connectionString, o); 54 | this.database = client.db(this.dbName); 55 | this.mongoClient = client; 56 | return this.database; 57 | } 58 | 59 | async collection(name: string) { 60 | const db = this.database || await this.open(); 61 | this.logger.debug(`Retrieving collect ${name}`); 62 | return db.collection(name); 63 | } 64 | 65 | close() { 66 | if (this.mongoClient) { 67 | this.mongoClient.close(); 68 | } 69 | this.database = null; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/services/jobs/mail.recover.job.spec.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { MailRecoverJob } from './mail.recover.job'; 3 | import { loggerMock, when } from '../../commons/test-helper'; 4 | 5 | class FakeMailRecoverJob extends MailRecoverJob { 6 | setByName(name: string, value: any) { 7 | (this as any)[name] = value; 8 | } 9 | 10 | getJobName() { 11 | return this.jobName; 12 | } 13 | } 14 | 15 | const mailService = { 16 | fetchPendingMails: jest.fn() 17 | }; 18 | const mainQueue = { 19 | send: jest.fn() 20 | }; 21 | const logger = loggerMock(); 22 | 23 | describe('/src/services/jobs/mail.recover.job.ts', () => { 24 | let instance: FakeMailRecoverJob; 25 | const defaultConfig = { 26 | bufferTime: 10 27 | }; 28 | 29 | beforeAll(() => { 30 | instance = new FakeMailRecoverJob(mailService as any, mainQueue as any); 31 | instance.setByName('logger', logger); 32 | instance.setByName('config', defaultConfig); 33 | instance.setByName('bufferTime', defaultConfig.bufferTime); 34 | 35 | }); 36 | afterAll(() => { 37 | jest.restoreAllMocks(); 38 | }); 39 | afterEach(() => { 40 | jest.resetAllMocks(); 41 | }); 42 | 43 | it('should execute correctly', async () => { 44 | const id = new ObjectId('5d47931e4ac9107f560cd446'); 45 | const id2 = new ObjectId('5d47a7844ac9107f560cd448'); 46 | 47 | when(mailService.fetchPendingMails).calledWith({ 48 | fromId: null, 49 | limit: 100, 50 | bufferTime: defaultConfig.bufferTime 51 | }).mockResolvedValue([{ _id: id }, { _id: id2 }]); 52 | 53 | when(mailService.fetchPendingMails).calledWith({ 54 | fromId: id2, 55 | limit: 100, 56 | bufferTime: defaultConfig.bufferTime 57 | }).mockResolvedValue([]); 58 | 59 | await instance.execute(); 60 | 61 | expect(mailService.fetchPendingMails.mock.calls[0][0]).toMatchObject({ fromId: null, limit: 100, bufferTime: 10 }); 62 | expect(mailService.fetchPendingMails.mock.calls[1][0]).toMatchObject({ fromId: id2, limit: 100, bufferTime: 10 }); 63 | 64 | expect(mainQueue.send).toBeCalledWith(id.toHexString()); 65 | expect(mainQueue.send).toBeCalledWith(id2.toHexString()); 66 | 67 | expect(logger.debug).toBeCalledWith(`${instance.getJobName()}: Starting job`); 68 | expect(logger.debug).toBeCalledWith('Fetched 2 pending mails'); 69 | expect(logger.debug).toBeCalledWith(`${instance.getJobName()}: Finished. Processed 2 pending mails, started from ${id.toHexString()}`); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/commons/test-helper.ts: -------------------------------------------------------------------------------- 1 | export { when } from 'jest-when'; 2 | import { isArray } from 'lodash'; 3 | 4 | const ISO_8601_FULL = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(([+-]\d\d:\d\d)|Z)?$/i; 5 | export const expectDateISO8601Format = expect.stringMatching(ISO_8601_FULL); 6 | 7 | /** 8 | * Generate random string 9 | */ 10 | export const randomString = (): string => Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); 11 | 12 | /** 13 | * Generate random number in range 14 | * @param min 15 | * @param max 16 | */ 17 | export const randomNum = (isInt: boolean = true, min: number = 1, max: number = 99999) => { 18 | const t = Math.random() * (max - min) + min; 19 | return isInt ? Math.trunc(t) : t; 20 | }; 21 | 22 | /** 23 | * Spy on object 24 | * @param target 25 | * @param funcs 26 | */ 27 | function spyOn(target: T, funcs: K): jest.SpyInstance; 28 | function spyOn(target: T, funcs: K[]): { [k in K]: jest.SpyInstance }; 29 | function spyOn(target: T, funcs: K | K[]): any { 30 | const ret = (isArray(funcs) ? funcs : [funcs]).reduce((c, v) => { 31 | c[v] = jest.spyOn(target, v); 32 | return c; 33 | }, {} as any); 34 | if (!isArray(funcs)) { 35 | return ret[funcs]; 36 | } 37 | return ret; 38 | } 39 | 40 | export { spyOn }; 41 | 42 | /** 43 | * Noop Logger, for integration test only 44 | */ 45 | export const NoopLogger = { 46 | error(_message: any, _trace?: string, _context?: string) { }, 47 | log(_message: any, _context?: string) { }, 48 | warn(_message: any, _context?: string) { }, 49 | debug(_message: any, _context?: string) { }, 50 | verbose(_message: any, _context?: string) { }, 51 | }; 52 | 53 | export const loggerMock = () => { 54 | return { 55 | error: jest.fn(), 56 | log: jest.fn(), 57 | warn: jest.fn(), 58 | debug: jest.fn(), 59 | verbose: jest.fn(), 60 | }; 61 | }; 62 | 63 | export const configMock = () => { 64 | return { 65 | get: jest.fn() 66 | }; 67 | }; 68 | 69 | export const collectionMock = () => { 70 | return { 71 | insertOne: jest.fn(), 72 | findOne: jest.fn(), 73 | find: jest.fn(), 74 | toArray: jest.fn(), 75 | updateOne: jest.fn() 76 | }; 77 | }; 78 | 79 | export const queueMock = () => { 80 | return { 81 | receive: jest.fn(), 82 | delete: jest.fn(), 83 | updateVisibility: jest.fn(), 84 | send: jest.fn() 85 | }; 86 | }; 87 | -------------------------------------------------------------------------------- /src/controllers/mail.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { when } from 'jest-when'; 2 | import { ObjectId } from 'mongodb'; 3 | import MailController from './mail.controller'; 4 | import { SendMailRequestDto } from '../dto'; 5 | import { queueMock, loggerMock } from '../commons/test-helper'; 6 | 7 | const mockMailService = { 8 | insert: jest.fn() 9 | }; 10 | 11 | const mainQueue = queueMock(); 12 | const logger = loggerMock(); 13 | 14 | describe('/src/controllers/mail.controller.ts', () => { 15 | let instance: MailController; 16 | 17 | beforeAll(() => { 18 | instance = new MailController( 19 | mainQueue, 20 | logger, 21 | mockMailService as any 22 | ); 23 | }); 24 | 25 | afterAll(() => { 26 | jest.restoreAllMocks(); 27 | }); 28 | 29 | afterEach(() => { 30 | jest.resetAllMocks(); 31 | }); 32 | 33 | describe('create', () => { 34 | const expectObjId = new ObjectId('5d4601128e98533b66875b71'); 35 | 36 | it('should call mail service to insert new mail and response correctly', async () => { 37 | const inp: SendMailRequestDto = { title: 'my ttitle', content: 'test', to: ['myemail@gmail.com'] }; 38 | 39 | when(mockMailService.insert).calledWith({ ...inp }).mockResolvedValue({ _id: expectObjId }); 40 | 41 | const result = await instance.create(inp); 42 | expect(result.data.id).toEqual(expectObjId.toHexString()); 43 | expect(mainQueue.send).toHaveBeenCalledTimes(1); 44 | expect(mainQueue.send).toBeCalledWith(expectObjId.toHexString()); 45 | }); 46 | 47 | it('should log as error but not reject in case can not enqueue', async () => { 48 | const inp: SendMailRequestDto = { title: 'my ttitle', content: 'test', to: ['myemail@gmail.com'] }; 49 | const myError = new Error('test'); 50 | 51 | when(mockMailService.insert).calledWith({ ...inp }).mockResolvedValue({ _id: expectObjId }); 52 | mainQueue.send.mockRejectedValue(myError); 53 | 54 | const result = await instance.create(inp); 55 | expect(result.data.id).toEqual(expectObjId.toHexString()); 56 | 57 | expect(logger.error).toBeCalledWith('Can not dispatch new mail sending to main queue', myError.stack || null); 58 | }); 59 | 60 | it('should throw exception if can not create new document', () => { 61 | const myError = new Error('test'); 62 | mockMailService.insert.mockRejectedValue(myError); 63 | 64 | return instance.create({} as any) 65 | .then(() => Promise.reject(new Error('mail controller create does not handle error correctly'))) 66 | .catch((e) => { 67 | expect(e).toMatchObject(myError); 68 | expect(logger.error).not.toBeCalled(); 69 | expect(mainQueue.send).not.toBeCalled(); 70 | }); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/repositories/index.ts: -------------------------------------------------------------------------------- 1 | import { Cursor, MongoCallback, FindOneOptions, InsertWriteOpResult, CollectionInsertManyOptions, InsertOneWriteOpResult, CollectionInsertOneOptions, ReplaceOneOptions, UpdateWriteOpResult, CommonOptions } from 'mongodb'; 2 | import { FactoryProvider } from '@nestjs/common/interfaces'; 3 | import { IOC_KEY, IDatabaseInstance } from '../commons'; 4 | import { MailModel } from '../models/mail.model'; 5 | 6 | /** 7 | * Abstract collection class to help us easier register collection into IoC 8 | */ 9 | abstract class Collection{ 10 | abstract find(query?: Object): Cursor; 11 | abstract find(query: Object, options?: FindOneOptions): Cursor; 12 | abstract findOne(filter: Object, callback: MongoCallback): void; 13 | abstract findOne(filter: Object, options?: FindOneOptions): Promise; 14 | abstract findOne(filter: Object, options: FindOneOptions, callback: MongoCallback): void; 15 | 16 | abstract insertMany(docs: Object[], callback: MongoCallback): void; 17 | abstract insertMany(docs: Object[], options?: CollectionInsertManyOptions): Promise; 18 | abstract insertMany(docs: Object[], options: CollectionInsertManyOptions, callback: MongoCallback): void; 19 | /** http://mongodb.github.io/node-mongodb-native/3.0/api/Collection.html#insertOne */ 20 | abstract insertOne(docs: Object, callback: MongoCallback): void; 21 | abstract insertOne(docs: Object, options?: CollectionInsertOneOptions): Promise; 22 | abstract insertOne(docs: Object, options: CollectionInsertOneOptions, callback: MongoCallback): void; 23 | 24 | abstract updateMany(filter: Object, update: Object, callback: MongoCallback): void; 25 | abstract updateMany(filter: Object, update: Object, options?: CommonOptions & { upsert?: boolean }): Promise; 26 | abstract updateMany(filter: Object, update: Object, options: CommonOptions & { upsert?: boolean }, callback: MongoCallback): void; 27 | /** http://mongodb.github.io/node-mongodb-native/3.0/api/Collection.html#updateOne */ 28 | abstract updateOne(filter: Object, update: Object, callback: MongoCallback): void; 29 | abstract updateOne(filter: Object, update: Object, options?: ReplaceOneOptions): Promise; 30 | abstract updateOne(filter: Object, update: Object, options: ReplaceOneOptions, callback: MongoCallback): void; 31 | } 32 | 33 | export abstract class IMailCollection extends Collection { 34 | static get [IOC_KEY](): FactoryProvider { 35 | return { 36 | provide: IMailCollection, 37 | inject: [IDatabaseInstance], 38 | useFactory: (db: IDatabaseInstance) => db.collection('mails') 39 | }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/services/workers/mail.sending.worker.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@nestjs/common'; 2 | import { QueueConsumerBase } from '../queue/queue.consumer.base'; 3 | import { QueueMessageDto, AttemptMailSendingDto } from '../../dto'; 4 | import { EMailProcessingStatus, QUEUES, EMailStatus } from '../../commons'; 5 | import { IQueueConsumer } from '../queue/queue.base.interface'; 6 | import { IMTAStategyService } from './mta.strategy.service.interface'; 7 | import { IMailService } from '../mail.service.interface'; 8 | 9 | @Injectable() 10 | export class MailSendingWorker extends QueueConsumerBase { 11 | constructor( 12 | @Inject(QUEUES.MAIN) 13 | mainQueueConsumer: IQueueConsumer, 14 | private readonly mailService: IMailService, 15 | private readonly mtaStategy: IMTAStategyService 16 | ) { 17 | super(mainQueueConsumer, QUEUES.MAIN); 18 | } 19 | 20 | async onMesage(message: QueueMessageDto) { 21 | const mail = await this.mailService.getMailById(message.message); 22 | if (!mail) { 23 | return EMailProcessingStatus.Outdated; 24 | } 25 | 26 | let status = mail.status && mail.status.length > 0 && mail.status[0]; 27 | if (!status || [EMailStatus.Fail, EMailStatus.Success].includes(status.type)) { 28 | return EMailProcessingStatus.Outdated; 29 | } 30 | 31 | // pick one mail transfer agent 32 | const mtaInstance = await this.mtaStategy.getMTA(status); 33 | let needNewStatus = !mtaInstance || status.type === EMailStatus.Init || mtaInstance.name !== status.mta; 34 | let finalProcessingStatus = EMailProcessingStatus.Success; 35 | if (!mtaInstance) { 36 | // totally dont have any mta instances? die lah 37 | needNewStatus = true; 38 | status = { 39 | onDate: new Date(), 40 | type: EMailStatus.Fail 41 | }; 42 | finalProcessingStatus = EMailProcessingStatus.Outdated; 43 | } 44 | else { 45 | if (needNewStatus) { 46 | status = { 47 | retries: 0, 48 | mta: mtaInstance.name, 49 | type: EMailStatus.Attempt 50 | }; 51 | } 52 | // attempt to send 53 | const mailSendingInfo: AttemptMailSendingDto = { 54 | to: mail.to, 55 | cc: mail.cc, 56 | bcc: mail.bcc, 57 | title: mail.title, 58 | content: mail.content 59 | }; 60 | const sendStatus = await mtaInstance.send(mailSendingInfo); 61 | 62 | switch (sendStatus) { 63 | case EMailProcessingStatus.Success: 64 | needNewStatus = true; 65 | status = { 66 | onDate: new Date(), 67 | type: EMailStatus.Success 68 | }; 69 | break; 70 | default: 71 | status.retries++; 72 | finalProcessingStatus = EMailProcessingStatus.Retry; 73 | break; 74 | } 75 | } 76 | 77 | if (needNewStatus) { 78 | await this.mailService.addMailStatus(mail._id, status); 79 | } 80 | else { 81 | await this.mailService.updateMailStatus(mail._id, status); 82 | } 83 | 84 | return finalProcessingStatus; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/services/jobs/jobabstract.ts: -------------------------------------------------------------------------------- 1 | import { OnModuleInit, Inject } from '@nestjs/common'; 2 | import { merge, get } from 'lodash'; 3 | import { Scheduler } from 'nest-schedule/dist/scheduler'; 4 | import { IJobConfig, EJobType } from './jobabstract.interface'; 5 | import { ILoggerInstance, IConfiguration, PROVIDERS } from '../../commons'; 6 | 7 | export abstract class JobAbstract implements OnModuleInit { 8 | /** 9 | * Job name 10 | */ 11 | protected abstract jobName: string; 12 | @Inject(PROVIDERS.ROOT_LOGGER) 13 | protected readonly logger: ILoggerInstance; 14 | /** 15 | * Job Config 16 | */ 17 | protected config: IJobConfig = { 18 | type: EJobType.timeout, 19 | maxRetry: 5, 20 | retryInterval: 2000 21 | }; 22 | protected isRunning: boolean = false; 23 | 24 | @Inject(IConfiguration) protected readonly configService: IConfiguration; 25 | 26 | /** 27 | * Job main entry point to execute your business logic. Return true to stop the job 28 | */ 29 | abstract execute(): void | Promise | boolean | Promise; 30 | 31 | cancelJob() { 32 | Scheduler.cancelJob(this.jobName); 33 | } 34 | 35 | /** 36 | * Mutex Lock acquirer. By default always return true 37 | */ 38 | tryLock(_key?: string | number): boolean | (() => void) | Promise { 39 | return true; 40 | } 41 | 42 | onModuleInit() { 43 | const userConfig = get(this.configService.get('jobs'), this.jobName, {}) as IJobConfig; 44 | if (!userConfig.enable) { 45 | this.logger.debug(`Ignore job ${this.jobName} due to not enable`); 46 | } 47 | this.logger.debug(`Registering job ${this.jobName}`); 48 | 49 | if (userConfig.startTime) { 50 | userConfig.startTime = new Date(userConfig.startTime); 51 | userConfig.endTime = new Date(userConfig.endTime); 52 | } 53 | this.config = merge({ 54 | startTime: null, 55 | endTime: null 56 | } as any, this.config, userConfig); 57 | 58 | const executor = async () => { 59 | try { 60 | if (this.isRunning) { 61 | this.logger.debug(`Job ${this.jobName} is running. Skipping`); 62 | return false; 63 | } 64 | this.isRunning = true; 65 | const result = await this.execute(); 66 | return result || false; 67 | } 68 | catch (err) { 69 | this.logger.error({ 70 | message: err.message, 71 | stack: err.stack, 72 | jobId: this.jobName 73 | }); 74 | return true; 75 | } 76 | finally { 77 | this.isRunning = false; 78 | } 79 | }; 80 | 81 | switch (this.config.type) { 82 | case EJobType.cron: 83 | Scheduler.scheduleCronJob(this.jobName, this.config.cron, executor, this.config, this.tryLock.bind(this)); 84 | break; 85 | case EJobType.interval: 86 | Scheduler.scheduleIntervalJob(this.jobName, this.config.interval, executor, this.config, this.tryLock.bind(this)); 87 | break; 88 | case EJobType.timeout: 89 | Scheduler.scheduleTimeoutJob(this.jobName, this.config.timeout, executor, this.config, this.tryLock.bind(this)); 90 | break; 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/services/mail.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ClassProvider } from '@nestjs/common/interfaces'; 3 | import { ObjectId } from 'mongodb'; 4 | import { IOC_KEY, EMailStatus } from '../commons'; 5 | import { IMailService } from './mail.service.interface'; 6 | import { IMailCollection } from '../repositories'; 7 | import { InsertMailInfoDto, MailDto, MailStatusDto } from '../dto'; 8 | import { MailModel } from '../models/mail.model'; 9 | 10 | /** 11 | * Internal email service 12 | */ 13 | @Injectable() 14 | export class MailService implements IMailService { 15 | static get [IOC_KEY](): ClassProvider { 16 | return { 17 | provide: IMailService, 18 | useClass: MailService 19 | }; 20 | } 21 | 22 | constructor( 23 | private readonly repoMail: IMailCollection 24 | ) { } 25 | 26 | getMailById(id: string): Promise { 27 | return this.repoMail.findOne({ _id: new ObjectId(id) }); 28 | } 29 | 30 | async insert(mail: InsertMailInfoDto): Promise { 31 | const newMailObj: MailModel = { 32 | to: mail.to, 33 | cc: mail.cc, 34 | bcc: mail.bcc, 35 | title: mail.title, 36 | content: mail.content, 37 | status: [ 38 | { 39 | type: EMailStatus.Init 40 | } 41 | ], 42 | sentOn: new Date() 43 | }; 44 | const insertResult = await this.repoMail.insertOne(newMailObj); 45 | return this.repoMail.findOne({ _id: insertResult.insertedId }); 46 | } 47 | 48 | fetchPendingMails( 49 | inp: { fromId?: ObjectId, limit?: number, bufferTime?: number } = {}) 50 | : Promise { 51 | const query: any = { 52 | 'status.0.type': { 53 | $nin: [EMailStatus.Success, EMailStatus.Fail] 54 | } 55 | }; 56 | 57 | const options: any = { 58 | sort: { 59 | id: 1 60 | }, 61 | projection: { 62 | _id: 1 63 | }, 64 | limit: (inp.limit > 0 ? inp.limit : 0) || 100 65 | }; 66 | 67 | if (inp.fromId) { 68 | query._id = { 69 | $gt: inp.fromId 70 | }; 71 | } 72 | 73 | if (inp.bufferTime > 0) { 74 | query.sentOn = { 75 | $lt: new Date(Date.now() - inp.bufferTime * 1000) 76 | }; 77 | } 78 | 79 | return this.repoMail 80 | .find(query, options) 81 | .toArray(); 82 | } 83 | 84 | async updateMailStatus(id: string | ObjectId, status: MailStatusDto): Promise { 85 | const mailId = (id instanceof ObjectId) ? id : new ObjectId(id); 86 | const result = await this.repoMail.updateOne( 87 | { 88 | _id: mailId 89 | }, 90 | { 91 | $set: 92 | { 93 | 'status.0': status 94 | } 95 | }); 96 | return result.result.ok === 1; 97 | } 98 | 99 | async addMailStatus(id: string | ObjectId, status: MailStatusDto): Promise { 100 | const mailId = (id instanceof ObjectId) ? id : new ObjectId(id); 101 | const result = await this.repoMail.updateOne( 102 | { 103 | _id: mailId 104 | }, 105 | { 106 | $push: 107 | { 108 | status: { 109 | $each: [status], 110 | $position: 0 111 | } 112 | } 113 | }); 114 | return result.result.ok === 1; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/services/workers/mta.strategy.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { MTAStategyService } from './mta.strategy.service'; 2 | import { loggerMock, configMock } from '../../commons/test-helper'; 3 | import { EMailStatus } from '../../commons'; 4 | 5 | class FakeMTAStategy extends MTAStategyService { 6 | getByName(name: string) { 7 | return (this as any)[name]; 8 | } 9 | 10 | setByName(name: string, value: any) { 11 | (this as any)[name] = value; 12 | } 13 | } 14 | 15 | const logger = loggerMock(); 16 | const config = configMock(); 17 | describe('/src/services/workers/mta.strategy.service.ts', () => { 18 | let instance: FakeMTAStategy; 19 | 20 | beforeAll(() => { 21 | instance = new FakeMTAStategy(logger, config); 22 | }); 23 | 24 | afterEach(() => { 25 | jest.clearAllMocks(); 26 | }); 27 | 28 | afterAll(() => { 29 | jest.restoreAllMocks(); 30 | }); 31 | 32 | describe('getMTA', () => { 33 | const availableMTA: any[] = []; 34 | beforeAll(() => { 35 | instance.setByName('availableMTA', availableMTA); 36 | }); 37 | 38 | it('should prefer to load available MTA that match with previous use', async () => { 39 | availableMTA.length = 0; 40 | const mta = { name: 'mymta', isAvailable: true, getMaxRetries() { return 2; } }; 41 | availableMTA.push(mta); 42 | 43 | const actualMta = await instance.getMTA({ type: EMailStatus.Attempt, mta: 'mymta' }); 44 | expect(actualMta).toStrictEqual(mta); 45 | }); 46 | 47 | it('should load previous mta to retry', async () => { 48 | availableMTA.length = 0; 49 | const mta = { name: 'mymta', isAvailable: true, getMaxRetries() { return 2; } }; 50 | availableMTA.push(mta); 51 | 52 | const actualMta = await instance.getMTA({ type: EMailStatus.Attempt, retries: 1, mta: 'mymta' }); 53 | expect(actualMta).toStrictEqual(mta); 54 | }); 55 | 56 | it('should choose different mta if exceed retry', async () => { 57 | availableMTA.length = 0; 58 | const mta = { name: 'mymta', isAvailable: true, getMaxRetries() { return 2; } }; 59 | const mta2 = { name: 'mymta2', isAvailable: true, getMaxRetries() { return 2; } }; 60 | availableMTA.push(mta); 61 | availableMTA.push(mta2); 62 | 63 | const actualMta = await instance.getMTA({ type: EMailStatus.Attempt, retries: 2, mta: 'mymta' }); 64 | expect(actualMta).toStrictEqual(mta2); 65 | }); 66 | 67 | it('should return undefined if can not find any MTA', async () => { 68 | availableMTA.length = 0; 69 | const mta = { name: 'mymta', isAvailable: true, getMaxRetries() { return 2; } }; 70 | const mta2 = { name: 'mymta2', isAvailable: false, getMaxRetries() { return 2; } }; 71 | availableMTA.push(mta); 72 | availableMTA.push(mta2); 73 | 74 | const actualMta = await instance.getMTA({ type: EMailStatus.Attempt, retries: 2, mta: 'mymta' }); 75 | expect(actualMta).toBeFalsy(); 76 | }); 77 | 78 | it('should return any MTA that match for Init', async () => { 79 | availableMTA.length = 0; 80 | const mta = { name: 'mymta', isAvailable: true, getMaxRetries() { return 2; } }; 81 | const mta2 = { name: 'mymta2', isAvailable: false, getMaxRetries() { return 2; } }; 82 | availableMTA.push(mta); 83 | availableMTA.push(mta2); 84 | 85 | const actualMta = await instance.getMTA({ type: EMailStatus.Init }); 86 | expect(actualMta).toStrictEqual(mta); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/services/mta/mta.base.ts: -------------------------------------------------------------------------------- 1 | import { IMailTransferAgent } from './mta.interface'; 2 | import * as superagent from 'superagent'; 3 | import { get, merge } from 'lodash'; 4 | import { EMailProcessingStatus, ILoggerInstance, IConfiguration } from '../../commons'; 5 | import { AttemptMailSendingDto } from '../../dto'; 6 | import circuitBreaker, { CircuitBreaker } from 'opossum'; 7 | 8 | /** 9 | * Base Mail Transfer Agent 10 | */ 11 | export abstract class MTABase extends IMailTransferAgent { 12 | isAvailable: boolean = true; 13 | 14 | protected timeout: number = 5000; 15 | /** 16 | * Default mail sending config 17 | * 18 | * @abstract 19 | * @type {*} 20 | * @memberof IMailTransferAgent 21 | */ 22 | protected readonly defaultConfig: any = {}; 23 | 24 | /** 25 | * Config name, in config file 26 | * 27 | * @abstract 28 | * @type {string} 29 | * @memberof IMailTransferAgent 30 | */ 31 | protected abstract readonly configName: string; 32 | 33 | /** 34 | * Request method 35 | * 36 | * @protected 37 | * @abstract 38 | * @type {('GET' | 'POST')} 39 | * @memberof MTABase 40 | */ 41 | protected abstract readonly method: string; 42 | 43 | private circuit: CircuitBreaker; 44 | protected config: any; 45 | 46 | constructor( 47 | protected readonly logger: ILoggerInstance, 48 | protected readonly configService: IConfiguration 49 | ) { 50 | super(); 51 | } 52 | 53 | async init() { 54 | const defaultOptions = { 55 | timeout: 5000, // If our function takes longer than 3 seconds, trigger a failure 56 | errorThresholdPercentage: 50, // When 50% of requests fail, trip the circuit 57 | resetTimeout: 30000 // After 30 seconds, try again. 58 | }; 59 | 60 | this.config = get(this.configService.get('mails'), this.configName); 61 | const options = merge({}, defaultOptions, get(this.config, 'circuit', {})); 62 | 63 | this.circuit = circuitBreaker(this.attemptSendMail.bind(this), options); 64 | // when open 65 | this.circuit.on('open', () => { 66 | this.logger.debug(`Walao eh! ${this.name} circuit opened`); 67 | this.isAvailable = false; 68 | }); 69 | // when close 70 | this.circuit.on('close', () => { 71 | this.logger.debug(`Hallo! ${this.name} circuit closed`); 72 | this.isAvailable = true; 73 | }); 74 | } 75 | 76 | getMaxRetries() { 77 | return this.config.maxRetries || 3; 78 | } 79 | 80 | async send(mail: AttemptMailSendingDto) { 81 | return this.circuit.fire(mail) 82 | .catch(() => EMailProcessingStatus.Retry); // Retry here means failed) 83 | } 84 | 85 | /** 86 | * Prepare authentication 87 | */ 88 | protected abstract prepareAuth(request: superagent.SuperAgentRequest): Promise; 89 | 90 | /** 91 | * Prepare url to making request 92 | */ 93 | protected abstract prepareUrl(): string; 94 | 95 | /** 96 | * Prepare request content 97 | */ 98 | protected abstract prepareRequestContent( 99 | request: superagent.SuperAgentRequest, 100 | mail: AttemptMailSendingDto 101 | ): Promise; 102 | 103 | /** 104 | * Last step before sending out request, you can set timeout or whatever things you like 105 | * @param request 106 | */ 107 | protected abstract prepareRequestConfig(request: superagent.SuperAgentRequest): Promise; 108 | 109 | private async attemptSendMail(mail: AttemptMailSendingDto) { 110 | const request = superagent(this.method, this.prepareUrl()); 111 | await Promise.all([ 112 | this.prepareAuth(request), 113 | this.prepareRequestContent(request, mail), 114 | this.prepareRequestConfig(request) 115 | ]); 116 | 117 | return request 118 | .then((response) => { 119 | /** 120 | * @todo To handle possible cases such as 429, 413, 503,... 121 | */ 122 | if (response.ok) { 123 | return EMailProcessingStatus.Success; 124 | } 125 | return EMailProcessingStatus.Retry; 126 | }) 127 | .catch((e) => { 128 | this.logger.error(`${this.name} - Error while sending email - ${e.message} - ${JSON.stringify(get(e, 'response.body', {}))}`, e.stack); 129 | return Promise.reject(e); 130 | }); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-mail-service", 3 | "version": "1.1.1", 4 | "description": "", 5 | "main": "dist/app.js", 6 | "dependencies": { 7 | "@nestjs/common": "6.5.3", 8 | "@nestjs/core": "6.5.3", 9 | "@nestjs/platform-express": "6.5.3", 10 | "@nestjs/swagger": "3.1.0", 11 | "bluebird": "3.5.5", 12 | "class-transformer": "0.2.3", 13 | "class-validator": "0.9.1", 14 | "compression": "1.7.4", 15 | "config": "3.1.0", 16 | "helmet": "3.18.0", 17 | "ioredis": "4.14.0", 18 | "ioredis-lock": "4.0.0", 19 | "lodash": "4.17.15", 20 | "mongodb": "2.2.30", 21 | "morgan": "1.9.1", 22 | "nest-schedule": "0.6.3", 23 | "nodemon": "1.19.1", 24 | "opossum": "3.0.0", 25 | "reflect-metadata": "0.1.13", 26 | "response-time": "2.3.2", 27 | "rsmq": "0.11.0", 28 | "rxjs": "6.5.2", 29 | "superagent": "5.1.0", 30 | "swagger-ui-express": "4.0.7", 31 | "tslib": "1.10.0" 32 | }, 33 | "devDependencies": { 34 | "@commitlint/cli": "8.1.0", 35 | "@commitlint/config-conventional": "8.0.0", 36 | "@nestjs/testing": "6.5.3", 37 | "@semantic-release/changelog": "3.0.4", 38 | "@semantic-release/git": "7.0.16", 39 | "@types/compression": "0.0.36", 40 | "@types/jest": "23.3.1", 41 | "@types/jest-when": "2.4.1", 42 | "@types/lodash": "4.14.136", 43 | "@types/mongodb": "3.0.5", 44 | "@types/morgan": "1.7.35", 45 | "@types/response-time": "2.3.3", 46 | "@types/supertest": "2.0.7", 47 | "@types/ioredis": "3.2.7", 48 | "@types/bluebird": "3.5.20", 49 | "@types/opossum": "1.10.1", 50 | "conventional-changelog-angular": "5.0.3", 51 | "coveralls": "3.0.4", 52 | "husky": "1.3.1", 53 | "jest": "24.7.1", 54 | "jest-when": "2.6.0", 55 | "lint-staged": "8.1.5", 56 | "semantic-release": "15.13.19", 57 | "ts-jest": "24.0.1", 58 | "ts-node": "8.0.2", 59 | "tslint": "5.16.0", 60 | "supertest": "4.0.2", 61 | "tslint-config-airbnb": "5.11.1", 62 | "tsutils": "3.14.0", 63 | "typescript": "3.5.2" 64 | }, 65 | "scripts": { 66 | "lint": "tslint --exclude '**/*.d.ts' src/**/*.ts", 67 | "lint:verify:build": "tsc -p tsconfig-prod.json --noEmit", 68 | "dist:tsc": "tsc -p tsconfig-prod.json && cp -r src/conf dist", 69 | "dist:prepare": "rm -rf node_modules && npm install --production", 70 | "dist:build": "npm run dist:tsc && npm run dist:prepare", 71 | "dist": "npm run dist:build", 72 | "clean": "rm -rf node_modules && rm -rf dist && rm -f package-lock.json", 73 | "cover": "NODE_ENV=test jest --config ./jest-cover.json --coverage --bail --forceExit", 74 | "cover:travis": "NODE_ENV=travis jest --config ./jest-cover.json --coverage --bail --forceExit", 75 | "test": "npm run lint && NODE_ENV=test jest --forceExit", 76 | "test:e2e": "NODE_ENV=test jest --config ./jest-e2e.json --forceExit --bail", 77 | "test:watch": "NODE_ENV=test jest --watch", 78 | "start": "nodemon dist/app.js", 79 | "start:local": "NODE_ENV=mylocal ts-node -T src/app.ts", 80 | "start:local:watch": "NODE_ENV=mylocal nodemon --watch src -e ts --exec \"ts-node\" -T src/app.ts", 81 | "release": "semantic-release --no-ci" 82 | }, 83 | "repository": { 84 | "type": "git", 85 | "url": "git+https://github.com/immanuel192/nest-mail-service.git" 86 | }, 87 | "keywords": [], 88 | "author": "", 89 | "license": "ISC", 90 | "bugs": { 91 | "url": "https://github.com/immanuel192/nest-mail-service/issues" 92 | }, 93 | "homepage": "https://github.com/immanuel192/nest-mail-service#readme", 94 | "jest": { 95 | "roots": [ 96 | "/src" 97 | ], 98 | "transform": { 99 | "^.+\\.ts$": "ts-jest" 100 | }, 101 | "testRegex": ".spec.ts$", 102 | "testPathIgnorePatterns": [ 103 | "/node_modules/" 104 | ], 105 | "coveragePathIgnorePatterns": [ 106 | "/node_modules/" 107 | ], 108 | "collectCoverageFrom": [ 109 | "/src/**/*.ts", 110 | "!/src/**/*.d.ts", 111 | "!**/node_modules/**" 112 | ], 113 | "testEnvironment": "node" 114 | }, 115 | "commitlint": { 116 | "extends": [ 117 | "@commitlint/config-conventional" 118 | ], 119 | "rules": { 120 | "header-max-length": [ 121 | 2, 122 | "always", 123 | 100 124 | ] 125 | } 126 | }, 127 | "husky": { 128 | "hooks": { 129 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 130 | "pre-commit": "lint-staged" 131 | } 132 | }, 133 | "lint-staged": { 134 | "src/**/*.ts": [ 135 | "tslint --exclude '**/*.d.ts' src/**/*.ts --fix", 136 | "git add" 137 | ] 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/services/mta/mta.base.spec.ts: -------------------------------------------------------------------------------- 1 | import * as circuit from 'opossum'; 2 | import { MTABase } from './mta.base'; 3 | import { AttemptMailSendingDto } from '../../dto'; 4 | import { loggerMock, configMock, when } from '../../commons/test-helper'; 5 | import { EMailProcessingStatus } from '../../commons'; 6 | 7 | class FakeMTA extends MTABase { 8 | isAvailable: boolean = true; 9 | config: any = {}; 10 | configName: string = 'myConfigName'; 11 | method: string = 'mymethod'; 12 | name: string = 'mta name'; 13 | 14 | getByName(name: string) { 15 | return (this as any)[name]; 16 | } 17 | 18 | setByName(name: string, value: any) { 19 | (this as any)[name] = value; 20 | } 21 | 22 | public prepareAuth(_request: any): Promise { 23 | throw new Error('Method not implemented.'); 24 | } 25 | 26 | public prepareUrl(): string { 27 | return 'myurl'; 28 | } 29 | 30 | public prepareRequestContent(_request: any, _mail: AttemptMailSendingDto): Promise { 31 | throw new Error('Method not implemented.'); 32 | } 33 | 34 | public prepareRequestConfig(_request: any): Promise { 35 | throw new Error('Method not implemented.'); 36 | } 37 | } 38 | 39 | const logger = loggerMock(); 40 | const config = configMock(); 41 | let circuitMock: jest.SpyInstance; 42 | 43 | describe('/src/services/mta/mta.base.ts', () => { 44 | let instance: FakeMTA; 45 | 46 | beforeAll(() => { 47 | circuitMock = jest.spyOn(circuit, 'default'); 48 | instance = new FakeMTA(logger, config); 49 | }); 50 | 51 | afterEach(() => { 52 | jest.clearAllMocks(); 53 | }); 54 | 55 | afterAll(() => { 56 | jest.restoreAllMocks(); 57 | }); 58 | 59 | describe('init', () => { 60 | const fakeCircuitObj = { 61 | on: jest.fn() 62 | }; 63 | 64 | it('should use default config to init circuit', async () => { 65 | when(config.get).calledWith('mails').mockReturnValue({ [instance.configName]: {} }); 66 | circuitMock.mockReturnValue(fakeCircuitObj); 67 | 68 | await instance.init(); 69 | 70 | expect(circuitMock.mock.calls[0][1]).toMatchObject({ 71 | timeout: 5000, 72 | errorThresholdPercentage: 50, 73 | resetTimeout: 30000 74 | }); 75 | }); 76 | 77 | it('should allow override config to init circuit', async () => { 78 | when(config.get).calledWith('mails').mockReturnValue({ 79 | [instance.configName]: { 80 | circuit: { 81 | resetTimeout: 1 82 | } 83 | } 84 | }); 85 | circuitMock.mockReturnValue(fakeCircuitObj); 86 | 87 | await instance.init(); 88 | 89 | expect(circuitMock.mock.calls[0][1]).toMatchObject({ 90 | timeout: 5000, 91 | errorThresholdPercentage: 50, 92 | resetTimeout: 1 93 | }); 94 | }); 95 | 96 | it('should listen to circuit status', async () => { 97 | when(config.get).calledWith('mails').mockReturnValue({ [instance.configName]: {} }); 98 | circuitMock.mockReturnValue(fakeCircuitObj); 99 | 100 | await instance.init(); 101 | 102 | // open 103 | expect(fakeCircuitObj.on.mock.calls[0][0]).toEqual('open'); 104 | instance.isAvailable = true; 105 | fakeCircuitObj.on.mock.calls[0][1](); 106 | expect(instance.isAvailable).toEqual(false); 107 | expect(logger.debug).toBeCalledWith(`Walao eh! ${instance.name} circuit opened`); 108 | 109 | // close 110 | expect(fakeCircuitObj.on.mock.calls[1][0]).toEqual('close'); 111 | instance.isAvailable = false; 112 | fakeCircuitObj.on.mock.calls[1][1](); 113 | expect(instance.isAvailable).toEqual(true); 114 | expect(logger.debug).toBeCalledWith(`Hallo! ${instance.name} circuit closed`); 115 | }); 116 | }); 117 | 118 | describe('getMaxRetries', () => { 119 | it('should return config max retries', () => { 120 | instance.config.maxRetries = 10; 121 | expect(instance.getMaxRetries()).toEqual(10); 122 | }); 123 | 124 | it('should return default maxretries', () => { 125 | instance.config = {}; 126 | expect(instance.getMaxRetries()).toEqual(3); 127 | }); 128 | }); 129 | 130 | describe('send', () => { 131 | it('should call circuit to fire event', async () => { 132 | const fire = jest.fn(); 133 | const inp = { x: 1, y: 2 }; 134 | const expectValue = { a: 1, b: 2 }; 135 | instance.setByName('circuit', { fire }); 136 | when(fire).calledWith(inp).mockResolvedValue(expectValue); 137 | 138 | const res = await instance.send(inp as any); 139 | expect(res).toMatchObject(expectValue); 140 | }); 141 | 142 | it('should return Retry status if circuit failed to load', async () => { 143 | const fire = jest.fn(); 144 | const inp = { x: 1, y: 2 }; 145 | instance.setByName('circuit', { fire }); 146 | when(fire).calledWith(inp).mockRejectedValue(EMailProcessingStatus.Retry); 147 | 148 | const res = await instance.send(inp as any); 149 | expect(res).toEqual(EMailProcessingStatus.Retry); 150 | }); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nest-mail-service 2 | [![Build Status](https://travis-ci.org/immanuel192/nest-mail-service.svg?branch=master)](https://travis-ci.org/immanuel192/nest-mail-service) 3 | [![Coverage Status](https://coveralls.io/repos/github/immanuel192/nest-mail-service/badge.svg?branch=master)](https://coveralls.io/github/immanuel192/nest-mail-service?branch=master) 4 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://travis-ci.org/immanuel192/nest-mail-service) 5 | 6 | ## System Diagram 7 | ### Tech stack 8 | NestJS/Typescript, MongoDB, Redis, Queue 9 | 10 | ![Diagram](./docs/diagram.png) 11 | 12 | How system works: 13 | - Client will POST /api/email to send email. 14 | - The endpoint will: 15 | - Perform validation to check the request payload. 16 | - New email document will be created in mongodb database 17 | - Dispatch a message to `main` queue. 18 | - Immediately response to client HTTP 201 Created with email id 19 | - If can not create new document, immediately response HTTP 500 20 | - If can create new document but can not dispatch message to `main` queue, api still response HTTP 201 21 | - `Email Sender Worker` will: 22 | - Long polling from the `main` queue. I use [`rsmq`](https://www.npmjs.com/package/rsmq) to implement `main` queue, but it can easily replaced with SQS. 23 | - Set the message visibility to prevent other worker instance pull this message again. 24 | - Attempt to send email 25 | - If success, delete message from the queue 26 | - If fail, update message status together with the visibility to let it available again in the queue for next time retry 27 | - In case if can not retry at all after a configured number of retries, it will dispatch a message to `DeadLetter` queue for analysis and mark the email document as `Failed` 28 | - `Worker` will: 29 | - Run every 30 mins 30 | - Try to scan any email documents that need to be enqueue again. This will be backup solution in case API did not dispatch message successfully. 31 | - Dispatch message to `main` queue 32 | 33 | 34 | ![How Worker Fetch Enqueue Emails](./docs/how_worker_fetch_pending_emails.png) 35 | 36 | 37 | - We also have internal components to support exponential backoff and circuit breaker mechanism when selecting email provider 38 | 39 | ### App scaling 40 | - All the endpoints are very light load. Can scale easily. 41 | - To clear the queue faster, we can increase the number of `Email Sender Worker` instances. 42 | 43 | ### App components 44 | ![Components](./docs/modules.png) 45 | - Currently `App Module` and `Worker Module` are all loaded in `app.ts` but we can easily separate it into worker and service to run separately 46 | 47 | ### Interesting features 48 | - Smart Mail Transfer Agent Stategy 49 | - Circuit Breaker for Selecting Mail Transfer Agent 50 | 51 | ### Things that can be improved 52 | - For the `Worker`, acquire mutex lock before executed to prevent other `Worker` instance to run while it running 53 | - Data sharding to improve write performance. 54 | - Switch to use Lambda 55 | - Apply mutex lock to guarantee that only one message that will be processed at a time without affect performance 56 | 57 | ## Versioning 58 | We use `semantic-release` to generate release notes. This make use of [conventional commit structure](https://www.conventionalcommits.org/en/v1.0.0-beta.4/) for both the notes and release numbers. 59 | 60 | 61 | ### Setup your working environment: 62 | #### Quick start 63 | - Update `mails` config section in `default.json` config to use your own keys 64 | - docker-compose up -d 65 | 66 | #### Available tasks 67 | ```sh 68 | npm install 69 | docker-compose up -d # to start the app by docker, including mongodb container. This will build and use `production` config 70 | npm run start:local # to start app in your local machine, that will connect to docker mongodb container 71 | npm run start:local:watch # to start app and watch for any changes 72 | npm run test # to run unit test 73 | npm run test:e2e # to run integration test 74 | npm run test:watch # to test app and keep watching files change 75 | npm run cover # to get code coverage 76 | ``` 77 | 78 | - To start develop new feature, you can create your own config `mylocal.json` file as below. Remember to run `docker-compose up -d` first to init mongo and redis container 79 | ```json 80 | { 81 | "mongodb": { 82 | "host": "localhost:27020", 83 | "user": "", 84 | "password": "", 85 | "database": "mails", 86 | "replicaSet": "" 87 | }, 88 | "redis": { 89 | "host": "localhost", 90 | "port": "6380", 91 | "db": 2 92 | } 93 | } 94 | 95 | ``` 96 | 97 | ### Using docker 98 | - Run 99 | ```sh 100 | docker-compose up -d 101 | ``` 102 | 103 | ## API Documentation 104 | There is swagger integrated to help you easier navigate through all exposed restful api. Please follow: 105 | ```sh 106 | npm run start 107 | open http://localhost:9001/docs/ 108 | ``` 109 | 110 | ### Release Production: 111 | - Create Github and NPM [tokens](https://github.com/immanuel192/semantic-release-sample) (only need to do one time) 112 | - Export these tokens into your `~/.bash_profile` and source it: 113 | ```sh 114 | export GH_TOKEN={your github token} 115 | export NPM_TOKEN={your npm token} # should be optional 116 | ``` 117 | - Run `npm run release` 118 | -------------------------------------------------------------------------------- /src/services/queue/queue.consumer.base.spec.ts: -------------------------------------------------------------------------------- 1 | import * as redisProvider from '../../providers/redis.provider'; 2 | import { QueueConsumerBase } from './queue.consumer.base'; 3 | import { EMailProcessingStatus, QUEUE_NAMESPACE, QUEUE_RETRY_CHECK } from '../../commons'; 4 | import { loggerMock, configMock, when } from '../../commons/test-helper'; 5 | 6 | class FakeWorker extends QueueConsumerBase { 7 | onMesage(): Promise { 8 | return Promise.resolve(null); 9 | } 10 | 11 | setByName(name: string, value: any) { 12 | (this as any)[name] = value; 13 | } 14 | 15 | getByName(name: string) { 16 | return (this as any)[name]; 17 | } 18 | } 19 | 20 | const queueMock = { 21 | receive: jest.fn(), 22 | delete: jest.fn(), 23 | updateVisibility: jest.fn() 24 | }; 25 | const QUEUE_NAME = 'test-queue'; 26 | const logger = loggerMock(); 27 | const config = configMock(); 28 | 29 | describe('/src/services/queue/queue.consumer.base.ts', () => { 30 | let instance: FakeWorker; 31 | let spyRedisNewConnection: jest.SpyInstance; 32 | 33 | beforeAll(() => { 34 | spyRedisNewConnection = jest.spyOn(redisProvider, 'newRedisConnection'); 35 | instance = new FakeWorker(queueMock, QUEUE_NAME); 36 | instance.setByName('logger', logger); 37 | instance.setByName('configService', config); 38 | }); 39 | 40 | beforeEach(() => { 41 | jest.resetAllMocks(); 42 | }); 43 | 44 | afterAll(() => { 45 | jest.restoreAllMocks(); 46 | instance.onModuleDestroy(); 47 | }); 48 | 49 | describe('onModuleInit', () => { 50 | const fakeRedis = { 51 | subscribe: jest.fn(), 52 | on: jest.fn() 53 | }; 54 | 55 | it('should init subcribe to redis and internal buffer', async () => { 56 | const channelName = `${QUEUE_NAMESPACE}:rt:${QUEUE_NAME}`; 57 | when(spyRedisNewConnection).calledWith(config, logger, QUEUE_NAME).mockReturnValue(fakeRedis); 58 | await instance.onModuleInit(); 59 | 60 | expect(fakeRedis.subscribe).toBeCalledWith(channelName); 61 | expect(fakeRedis.on).toBeCalledWith('message', expect.anything()); 62 | }); 63 | }); 64 | 65 | describe('attempProcessMessage', () => { 66 | let mockOnMessage: jest.SpyInstance; 67 | 68 | beforeAll(() => { 69 | mockOnMessage = jest.spyOn(instance, 'onMesage'); 70 | }); 71 | 72 | beforeEach(() => { 73 | mockOnMessage.mockReset(); 74 | }); 75 | 76 | afterAll(() => { 77 | mockOnMessage.mockRestore(); 78 | }); 79 | 80 | it('should not do anything if can not fetch message from queue', async () => { 81 | queueMock.receive.mockResolvedValue(null); 82 | 83 | await (instance as any).attempProcessMessage(); 84 | expect(mockOnMessage).not.toBeCalled(); 85 | }); 86 | 87 | it('should process input message that prefetch from queue', async () => { 88 | const message = { id: 123, message: 'my message' }; 89 | 90 | await (instance as any).attempProcessMessage(message); 91 | expect(queueMock.receive).not.toBeCalled(); 92 | expect(logger.debug).toBeCalledWith(`Executing worker for message ${message.message}`); 93 | expect(mockOnMessage).toBeCalledWith(message); 94 | 95 | // should date lastProcessOn 96 | expect(Date.now() - instance.getByName('lastProcessOn')).toBeLessThan(100); 97 | }); 98 | 99 | it('should fetch data from from queue if any', async () => { 100 | const message = { id: 123, message: 'my message' }; 101 | queueMock.receive.mockResolvedValue(message); 102 | 103 | await (instance as any).attempProcessMessage(); 104 | expect(logger.debug).toBeCalledWith(`Executing worker for message ${message.message}`); 105 | expect(mockOnMessage).toBeCalledWith(message); 106 | 107 | // should date lastProcessOn 108 | expect(Date.now() - instance.getByName('lastProcessOn')).toBeLessThan(100); 109 | }); 110 | 111 | it('should delete message from queue when successfull', async () => { 112 | const message = { id: 123, message: 'my message' }; 113 | mockOnMessage.mockResolvedValue(EMailProcessingStatus.Success); 114 | 115 | await (instance as any).attempProcessMessage(message); 116 | expect(logger.debug).toBeCalledWith(`Worker processed doc ${message.message} with status ${EMailProcessingStatus.Success}`); 117 | expect(queueMock.delete).toBeCalledWith(message.id); 118 | }); 119 | 120 | it('should retry message from queue if any', async () => { 121 | const message = { id: 123, message: 'my message' }; 122 | mockOnMessage.mockResolvedValue(EMailProcessingStatus.Retry); 123 | 124 | await (instance as any).attempProcessMessage(message); 125 | expect(queueMock.delete).not.toBeCalled(); 126 | expect(queueMock.updateVisibility).toBeCalledWith(message.id, QUEUE_RETRY_CHECK); 127 | }); 128 | 129 | it('should log if any error', async () => { 130 | const message = { id: 123, message: 'my message' }; 131 | const myError = new Error('my error'); 132 | mockOnMessage.mockRejectedValue(myError); 133 | 134 | await (instance as any).attempProcessMessage(message); 135 | expect(queueMock.delete).not.toBeCalled(); 136 | expect(queueMock.updateVisibility).not.toBeCalled(); 137 | expect(logger.error).toBeCalledWith(`Unsuccessful processing message ${message.message} with error ${myError.message}`, myError.stack); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /src/services/queue/queue.consumer.base.ts: -------------------------------------------------------------------------------- 1 | import * as Bluebird from 'bluebird'; 2 | import { OnModuleInit, Inject, OnModuleDestroy } from '@nestjs/common'; 3 | import { Redis } from 'ioredis'; 4 | import { Subject, timer, Observable } from 'rxjs'; 5 | import { bufferTime } from 'rxjs/operators'; 6 | import { IQueueConsumer } from './queue.base.interface'; 7 | import { IConfiguration, ILoggerInstance, PROVIDERS, QUEUE_NAMESPACE, EMailProcessingStatus, QUEUE_RETRY_CHECK, QUEUE_MAXIDLE, QUEUE_MAXFETCH } from '../../commons'; 8 | import { newRedisConnection } from '../../providers/redis.provider'; 9 | import { QueueMessageDto } from '../../dto'; 10 | 11 | /** 12 | * Consumer queue base 13 | */ 14 | export abstract class QueueConsumerBase implements OnModuleInit, OnModuleDestroy { 15 | @Inject(PROVIDERS.ROOT_LOGGER) 16 | protected readonly logger: ILoggerInstance; 17 | @Inject(IConfiguration) 18 | protected readonly configService: IConfiguration; 19 | 20 | /** 21 | * Subcribe redis client, for realtime listen to the PUBLISH command 22 | * 23 | * @protected 24 | * @type {Redis} 25 | * @memberof QueueConsumerBase 26 | */ 27 | protected subcribeRedis: Redis; 28 | /** 29 | * Redis channel name 30 | * 31 | * @protected 32 | * @type {string} 33 | * @memberof QueueConsumerBase 34 | */ 35 | protected channelName: string; 36 | /** 37 | * Internal observable, to continuously 38 | * 39 | * @protected 40 | * @type {Subject} 41 | * @memberof QueueConsumerBase 42 | */ 43 | private rxObservableBuffer: Subject; 44 | private rxObservableTicker: Observable; 45 | /** 46 | * Last time (in number) we process a message. To make sure that we dont miss any message in the queue 47 | * 48 | * @private 49 | * @type {number} 50 | * @memberof QueueConsumerBase 51 | */ 52 | private lastProcessOn: number = Date.now(); 53 | 54 | constructor( 55 | protected readonly queue: IQueueConsumer, 56 | private readonly queueName: string 57 | ) { } 58 | 59 | /** 60 | * Try to process the message 61 | * 62 | * Possible return cases: 63 | * - `EMailInQueueProcessingStatus.Success`: Message will be deleted 64 | * - `EMailInQueueProcessingStatus.Retry`: Message will be putted back to the queue for next retry 65 | * - `EMailInQueueProcessingStatus.Outdated`: No need to do anything, message will be deleted. Usually because the item has been processed while message still in the queue 66 | * @param message 67 | */ 68 | abstract onMesage(message: QueueMessageDto): Promise; 69 | 70 | async onModuleInit() { 71 | this.subcribeRedis = await newRedisConnection(this.configService, this.logger, this.queueName); 72 | await this.subscribeRedis(); 73 | await this.initRx(); 74 | } 75 | 76 | onModuleDestroy() { 77 | this.subcribeRedis.quit(); 78 | this.rxObservableBuffer.complete(); 79 | } 80 | 81 | private initRx() { 82 | /** 83 | * I use observable in Rxjs to try prevent backpressure 84 | */ 85 | this.rxObservableBuffer = new Subject(); 86 | this.rxObservableBuffer 87 | .pipe(bufferTime(50)) 88 | .subscribe(args => Bluebird.map([...args], m => this.attempProcessMessage(m), { concurrency: 5 })); 89 | 90 | // In case that we miss anything, actively to pull data from the queue 91 | this.rxObservableTicker = timer(2000, 5000); 92 | this.rxObservableTicker.subscribe(() => { 93 | if (Date.now() - this.lastProcessOn > QUEUE_MAXIDLE) { 94 | return this.tryFetchOnIdle(); 95 | } 96 | return null; 97 | }); 98 | } 99 | 100 | private subscribeRedis() { 101 | this.channelName = `${QUEUE_NAMESPACE}:rt:${this.queueName}`; 102 | this.subcribeRedis.subscribe(this.channelName); 103 | this.subcribeRedis.on('message', (channel: string) => { 104 | if (channel === this.channelName) { 105 | // trigger our internal event to fetch message from queue 106 | this.rxObservableBuffer.next(); 107 | } 108 | }); 109 | } 110 | 111 | /** 112 | * Try to fetch at most QUEUE_MAXFETCH messages from the queue 113 | */ 114 | private async tryFetchOnIdle() { 115 | for (let i = 0; i < QUEUE_MAXFETCH; i++) { 116 | const message = await this.queue.receive(); 117 | if (!message) { 118 | break; 119 | } 120 | this.rxObservableBuffer.next(message); 121 | } 122 | } 123 | 124 | /** 125 | * Attempt to either fetch one message from queue OR process the prefetched one 126 | * @param prefetchedMessage Prefetched message from the queue 127 | */ 128 | private async attempProcessMessage(prefetchedMessage?: QueueMessageDto) { 129 | const message = (prefetchedMessage && prefetchedMessage.id) ? prefetchedMessage : (await this.queue.receive()); 130 | if (message) { 131 | try { 132 | this.logger.debug(`Executing worker for message ${message.message}`); 133 | const executeResult = await this.onMesage(message); 134 | this.logger.debug(`Worker processed doc ${message.message} with status ${executeResult}`); 135 | if (executeResult === EMailProcessingStatus.Outdated || executeResult === EMailProcessingStatus.Success) { 136 | await this.queue.delete(message.id); 137 | } 138 | 139 | if (executeResult === EMailProcessingStatus.Retry) { 140 | await this.queue.updateVisibility(message.id, QUEUE_RETRY_CHECK); 141 | } 142 | } 143 | catch (e) { 144 | this.logger.error(`Unsuccessful processing message ${message.message} with error ${e.message}`, e.stack); 145 | } 146 | finally { 147 | this.lastProcessOn = Date.now(); 148 | } 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/services/queue/queue.base.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | jest.mock('rsmq', () => { 3 | return jest.fn().mockImplementation(() => rsmqMock); 4 | }); 5 | import { QueueBase } from './queue.base'; 6 | import { configMock, loggerMock, when, randomString } from '../../commons/test-helper'; 7 | 8 | const config = configMock(); 9 | const logger = loggerMock(); 10 | const rsmqMock = { 11 | listQueuesAsync: jest.fn(), 12 | createQueueAsync: jest.fn(), 13 | sendMessageAsync: jest.fn(), 14 | receiveMessageAsync: jest.fn(), 15 | deleteMessageAsync: jest.fn(), 16 | changeMessageVisibilityAsync: jest.fn() 17 | }; 18 | const QUEUE_NAME = 'test-queue'; 19 | const sampleRedisConfig = { 20 | host: 'myhost', 21 | port: 1234, 22 | db: 5 23 | }; 24 | 25 | class FakeQueue extends QueueBase { 26 | public configService: any; 27 | public logger: any; 28 | constructor() { 29 | super(QUEUE_NAME); 30 | this.configService = config; 31 | this.logger = logger; 32 | } 33 | } 34 | 35 | describe('/src/services/queue/queue.base.ts', () => { 36 | let instance: FakeQueue; 37 | 38 | beforeAll(() => { 39 | instance = new FakeQueue(); 40 | }); 41 | 42 | beforeEach(() => { 43 | when(config.get).calledWith('redis').mockReturnValue(sampleRedisConfig); 44 | }); 45 | 46 | afterEach(() => { 47 | jest.clearAllMocks(); 48 | }); 49 | 50 | afterAll(() => { 51 | jest.restoreAllMocks(); 52 | }); 53 | 54 | describe('onModuleInit', () => { 55 | it('should create queue if not exist', async () => { 56 | rsmqMock.listQueuesAsync.mockResolvedValue(['test', randomString()]); 57 | when(rsmqMock.createQueueAsync).calledWith({ qname: QUEUE_NAME, vt: 20 }).mockResolvedValue(1); 58 | 59 | await (instance as any).onModuleInit(); 60 | expect(logger.debug).toBeCalledWith(`Init queue ${QUEUE_NAME}`); 61 | expect(logger.debug).toBeCalledWith(`Created queue ${QUEUE_NAME}`); 62 | }); 63 | 64 | it('should not create queue if queue already existed', async () => { 65 | rsmqMock.listQueuesAsync.mockResolvedValue(['test', randomString(), QUEUE_NAME]); 66 | 67 | await (instance as any).onModuleInit(); 68 | expect(rsmqMock.createQueueAsync).not.toBeCalled(); 69 | }); 70 | 71 | it('should throw exception when can not create queue', () => { 72 | rsmqMock.listQueuesAsync.mockResolvedValue(['test', randomString()]); 73 | rsmqMock.createQueueAsync.mockResolvedValue(0); 74 | 75 | return (instance as any).onModuleInit() 76 | .catch((e: Error) => { 77 | expect(e).toMatchObject({ 78 | message: `Creating queue ${QUEUE_NAME} unsuccessful` 79 | }); 80 | }); 81 | }); 82 | }); 83 | 84 | describe('send', () => { 85 | it('should enqueue message to queue', async () => { 86 | const expectValue = { a: 1, b: 2 }; 87 | const message = randomString(); 88 | rsmqMock.sendMessageAsync.mockResolvedValue(expectValue); 89 | 90 | const result = await instance.send(message); 91 | expect(result).toMatchObject(expectValue); 92 | expect(rsmqMock.sendMessageAsync).toBeCalledWith({ 93 | message, qname: QUEUE_NAME 94 | }); 95 | }); 96 | }); 97 | 98 | describe('receive', () => { 99 | it('should fetch message from queue', async () => { 100 | const expectValue = { id: 123 }; 101 | rsmqMock.receiveMessageAsync.mockResolvedValue(expectValue); 102 | 103 | const result = await instance.receive(); 104 | expect(result).toMatchObject(expectValue); 105 | expect(rsmqMock.receiveMessageAsync).toBeCalledWith({ 106 | qname: QUEUE_NAME 107 | }); 108 | }); 109 | 110 | it('should return null if no message', async () => { 111 | const expectValue = {}; 112 | rsmqMock.receiveMessageAsync.mockResolvedValue(expectValue); 113 | 114 | const result = await instance.receive(); 115 | expect(result).toStrictEqual(null); 116 | expect(rsmqMock.receiveMessageAsync).toBeCalledWith({ 117 | qname: QUEUE_NAME 118 | }); 119 | }); 120 | }); 121 | 122 | describe('delete', () => { 123 | it('should delete message from queue and return true', async () => { 124 | const id = 123; 125 | rsmqMock.deleteMessageAsync.mockResolvedValue(1); 126 | 127 | const result = await instance.delete(id); 128 | expect(result).toStrictEqual(true); 129 | expect(rsmqMock.deleteMessageAsync).toBeCalledWith({ 130 | qname: QUEUE_NAME, 131 | id: id.toString() 132 | }); 133 | }); 134 | 135 | it('should return false if can not delete', async () => { 136 | const id = 123; 137 | rsmqMock.deleteMessageAsync.mockResolvedValue(0); 138 | 139 | const result = await instance.delete(id); 140 | expect(result).toStrictEqual(false); 141 | expect(rsmqMock.deleteMessageAsync).toBeCalledWith({ 142 | qname: QUEUE_NAME, 143 | id: id.toString() 144 | }); 145 | }); 146 | }); 147 | 148 | describe('updateVisibility', () => { 149 | it('should update message visibility return true', async () => { 150 | const id = 123; const vt = 100; 151 | rsmqMock.changeMessageVisibilityAsync.mockResolvedValue(1); 152 | 153 | const result = await instance.updateVisibility(id, vt); 154 | expect(result).toStrictEqual(true); 155 | expect(rsmqMock.changeMessageVisibilityAsync).toBeCalledWith({ 156 | vt, 157 | qname: QUEUE_NAME, 158 | id: id.toString() 159 | }); 160 | }); 161 | 162 | it('should return false if can not update message visibility', async () => { 163 | const id = 123; const vt = 100; 164 | rsmqMock.changeMessageVisibilityAsync.mockResolvedValue(0); 165 | 166 | const result = await instance.updateVisibility(id, vt); 167 | expect(result).toStrictEqual(false); 168 | expect(rsmqMock.changeMessageVisibilityAsync).toBeCalledWith({ 169 | vt, 170 | qname: QUEUE_NAME, 171 | id: id.toString() 172 | }); 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /src/services/workers/mail.sending.worker.spec.ts: -------------------------------------------------------------------------------- 1 | import { MailSendingWorker } from './mail.sending.worker'; 2 | import { when } from 'jest-when'; 3 | import { EMailProcessingStatus, EMailStatus } from '../../commons'; 4 | 5 | const queue = {}; 6 | const mailService = { 7 | getMailById: jest.fn(), 8 | addMailStatus: jest.fn(), 9 | updateMailStatus: jest.fn() 10 | }; 11 | const mtaStategy = { 12 | getMTA: jest.fn() 13 | }; 14 | 15 | describe('/src/services/workers/mail.sending.worker.ts', () => { 16 | let instance: MailSendingWorker; 17 | 18 | beforeAll(() => { 19 | instance = new MailSendingWorker(queue as any, mailService as any, mtaStategy as any); 20 | }); 21 | 22 | afterEach(() => { 23 | jest.clearAllMocks(); 24 | }); 25 | 26 | afterAll(() => { 27 | jest.restoreAllMocks(); 28 | }); 29 | 30 | describe('onMessage', () => { 31 | const message = { id: '123', message: '456' }; 32 | it('should return Outdated if can not find message in mongodb', async () => { 33 | when(mailService.getMailById).calledWith(message.message).mockResolvedValue(null); 34 | 35 | const res = await instance.onMesage(message as any); 36 | expect(res).toEqual(EMailProcessingStatus.Outdated); 37 | }); 38 | 39 | it('should return Outdated when mail does not have status', async () => { 40 | when(mailService.getMailById).calledWith(message.message).mockResolvedValue({}); 41 | 42 | const res = await instance.onMesage(message as any); 43 | expect(res).toEqual(EMailProcessingStatus.Outdated); 44 | }); 45 | 46 | it('should return Outdated when mail does not have invalid status', async () => { 47 | when(mailService.getMailById).calledWith(message.message).mockResolvedValue({ status: [] }); 48 | 49 | const res = await instance.onMesage(message as any); 50 | expect(res).toEqual(EMailProcessingStatus.Outdated); 51 | }); 52 | 53 | it('should return Outdated when status is Fail', async () => { 54 | when(mailService.getMailById).calledWith(message.message).mockResolvedValue({ status: [{ type: EMailStatus.Fail }] }); 55 | 56 | const res = await instance.onMesage(message as any); 57 | expect(res).toEqual(EMailProcessingStatus.Outdated); 58 | }); 59 | 60 | it('should return Outdated when status is Success', async () => { 61 | when(mailService.getMailById).calledWith(message.message).mockResolvedValue({ status: [{ type: EMailStatus.Success }] }); 62 | 63 | const res = await instance.onMesage(message as any); 64 | expect(res).toEqual(EMailProcessingStatus.Outdated); 65 | }); 66 | 67 | it('should Fail if can not find any MTA', async () => { 68 | const _id = '_id'; 69 | when(mailService.getMailById).calledWith(message.message).mockResolvedValue({ 70 | _id, 71 | status: [{ type: EMailStatus.Attempt }] 72 | }); 73 | when(mtaStategy.getMTA).calledWith({ type: EMailStatus.Attempt }).mockResolvedValue(null); 74 | 75 | const res = await instance.onMesage(message as any); 76 | expect(res).toEqual(EMailProcessingStatus.Outdated); 77 | expect(mailService.addMailStatus).toBeCalledWith(_id, expect.objectContaining({ 78 | onDate: expect.any(Date), 79 | type: EMailStatus.Fail 80 | })); 81 | }); 82 | 83 | it('should send mail for Init status', async () => { 84 | const _id = '_id'; 85 | when(mailService.getMailById).calledWith(message.message).mockResolvedValue({ 86 | _id, 87 | status: [{ type: EMailStatus.Init }] 88 | }); 89 | const mock = jest.fn(); 90 | when(mtaStategy.getMTA).calledWith({ type: EMailStatus.Init }).mockResolvedValue({ send: mock, name: 'mymta' }); 91 | mock.mockResolvedValue(EMailProcessingStatus.Success); 92 | 93 | const res = await instance.onMesage(message as any); 94 | expect(res).toEqual(EMailProcessingStatus.Success); 95 | expect(mailService.addMailStatus).toBeCalledWith(_id, expect.objectContaining({ 96 | onDate: expect.any(Date), 97 | type: EMailStatus.Success 98 | })); 99 | }); 100 | 101 | it('should retry when mail Init fail', async () => { 102 | const _id = '_id'; 103 | when(mailService.getMailById).calledWith(message.message).mockResolvedValue({ 104 | _id, 105 | status: [{ type: EMailStatus.Init }] 106 | }); 107 | const mock = jest.fn(); 108 | when(mtaStategy.getMTA).calledWith({ type: EMailStatus.Init }).mockResolvedValue({ send: mock, name: 'mymta' }); 109 | mock.mockResolvedValue(EMailProcessingStatus.Retry); 110 | 111 | const res = await instance.onMesage(message as any); 112 | expect(res).toEqual(EMailProcessingStatus.Retry); 113 | expect(mailService.addMailStatus).toBeCalledWith(_id, expect.objectContaining({ 114 | retries: 1, 115 | mta: 'mymta', 116 | type: EMailStatus.Attempt 117 | })); 118 | }); 119 | 120 | it('should retry for Attempt mail', async () => { 121 | const _id = '_id'; 122 | when(mailService.getMailById).calledWith(message.message).mockResolvedValue({ 123 | _id, 124 | status: [{ type: EMailStatus.Attempt, retries: 1, mta: 'mymta' }] 125 | }); 126 | const mock = jest.fn(); 127 | when(mtaStategy.getMTA).calledWith({ 128 | type: EMailStatus.Attempt, 129 | retries: 1, 130 | mta: 'mymta' 131 | }).mockResolvedValue({ send: mock, name: 'mymta' }); 132 | mock.mockResolvedValue(EMailProcessingStatus.Retry); 133 | 134 | const res = await instance.onMesage(message as any); 135 | expect(res).toEqual(EMailProcessingStatus.Retry); 136 | expect(mailService.updateMailStatus).toBeCalledWith(_id, expect.objectContaining({ 137 | retries: 2, 138 | mta: 'mymta', 139 | type: EMailStatus.Attempt 140 | })); 141 | }); 142 | 143 | it('should retry for Attempt with different MTA if exceed configured number', async () => { 144 | const _id = '_id'; 145 | when(mailService.getMailById).calledWith(message.message).mockResolvedValue({ 146 | _id, 147 | status: [{ 148 | type: EMailStatus.Attempt, 149 | // Previously used mymta2 but the stategy will give different MTA in this scenario 150 | retries: 1, mta: 'mymta2' 151 | }] 152 | }); 153 | const mock = jest.fn(); 154 | when(mtaStategy.getMTA).calledWith({ 155 | type: EMailStatus.Attempt, 156 | retries: 1, 157 | mta: 'mymta2' 158 | }).mockResolvedValue({ send: mock, name: 'mymta' }); 159 | mock.mockResolvedValue(EMailProcessingStatus.Retry); 160 | 161 | const res = await instance.onMesage(message as any); 162 | expect(res).toEqual(EMailProcessingStatus.Retry); 163 | expect(mailService.addMailStatus).toBeCalledWith(_id, expect.objectContaining({ 164 | retries: 1, 165 | mta: 'mymta', 166 | type: EMailStatus.Attempt 167 | })); 168 | }); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /src/services/mta/mailgun.mta.spec.ts: -------------------------------------------------------------------------------- 1 | import { MailGunMTA } from './mailgun.mta'; 2 | import { loggerMock, configMock, when } from '../../commons/test-helper'; 3 | 4 | class FakeMailgunMTA extends MailGunMTA { 5 | getByName(name: string) { 6 | return (this as any)[name]; 7 | } 8 | 9 | setByName(name: string, value: any) { 10 | (this as any)[name] = value; 11 | } 12 | } 13 | 14 | const logger = loggerMock(); 15 | const config = configMock(); 16 | 17 | describe('/src/services/mta/mailgun.mta.ts', () => { 18 | let instance: FakeMailgunMTA; 19 | 20 | beforeAll(() => { 21 | instance = new FakeMailgunMTA(logger, config); 22 | }); 23 | 24 | afterEach(() => { 25 | jest.clearAllMocks(); 26 | }); 27 | 28 | afterAll(() => { 29 | jest.restoreAllMocks(); 30 | }); 31 | 32 | it('should register correct name', () => { 33 | expect(instance.name).toEqual('mailgun'); 34 | }); 35 | 36 | it('should register correct config name', () => { 37 | expect(instance.getByName('configName')).toEqual('mailgun'); 38 | }); 39 | 40 | it('should register correct method', () => { 41 | expect(instance.getByName('method')).toEqual('POST'); 42 | }); 43 | 44 | describe('init', () => { 45 | it('should throw error if no config', () => { 46 | when(config.get).calledWith('mails').mockReturnValue({}); 47 | return instance.init() 48 | .catch((e) => { 49 | expect(e).toMatchObject({ 50 | message: `Invalid configuration of mailgun. Actual config: ${JSON.stringify(instance.getByName('defaultConfig'))}` 51 | }); 52 | }); 53 | }); 54 | 55 | it('should throw error if dont have apiKey', () => { 56 | when(config.get).calledWith('mails').mockReturnValue({ 57 | mailgun: { 58 | domain: 'domain', 59 | from: 'from', 60 | } 61 | }); 62 | return instance.init() 63 | .catch((e) => { 64 | expect(e).toMatchObject({ 65 | message: `Invalid configuration of mailgun. Actual config: ${JSON.stringify(instance.getByName('defaultConfig'))}` 66 | }); 67 | }); 68 | }); 69 | 70 | it('should throw error if dont have domain', () => { 71 | when(config.get).calledWith('mails').mockReturnValue({ 72 | mailgun: { 73 | apiKey: 'domain', 74 | from: 'from', 75 | } 76 | }); 77 | return instance.init() 78 | .catch((e) => { 79 | expect(e).toMatchObject({ 80 | message: `Invalid configuration of mailgun. Actual config: ${JSON.stringify(instance.getByName('defaultConfig'))}` 81 | }); 82 | }); 83 | }); 84 | 85 | it('should throw error if dont have from', () => { 86 | when(config.get).calledWith('mails').mockReturnValue({ 87 | mailgun: { 88 | domain: 'domain', 89 | apiKey: 'from', 90 | } 91 | }); 92 | return instance.init() 93 | .catch((e) => { 94 | expect(e).toMatchObject({ 95 | message: `Invalid configuration of mailgun. Actual config: ${JSON.stringify(instance.getByName('defaultConfig'))}` 96 | }); 97 | }); 98 | }); 99 | 100 | it('should call super to init', () => { 101 | const expectConfig = { 102 | domain: 'domain', 103 | apiKey: 'from', 104 | from: 'from' 105 | }; 106 | when(config.get).calledWith('mails').mockReturnValue({ 107 | mailgun: expectConfig 108 | }); 109 | return instance.init() 110 | .then(() => { 111 | expect(instance.getByName('config')).toMatchObject(expectConfig); 112 | }); 113 | }); 114 | }); 115 | 116 | describe('prepareAuth', () => { 117 | it('should set correct auth', async () => { 118 | const myKey = 'mykey'; 119 | instance.setByName('defaultConfig', { apiKey: myKey }); 120 | const mock = jest.fn(); 121 | await (instance.getByName('prepareAuth').bind(instance)({ auth: mock })); 122 | 123 | expect(mock).toHaveBeenCalledTimes(1); 124 | expect(mock).lastCalledWith('api', myKey); 125 | }); 126 | }); 127 | 128 | describe('prepareUrl', () => { 129 | it('should return correct url', () => { 130 | const myDomain = 'mydomain'; 131 | instance.setByName('defaultConfig', { domain: myDomain }); 132 | const url = instance.getByName('prepareUrl').bind(instance)(); 133 | 134 | expect(url).toEqual(`https://api.mailgun.net/v3/${myDomain}/messages`); 135 | }); 136 | }); 137 | 138 | describe('prepareRequestConfig', () => { 139 | it('should prepare correct config', async () => { 140 | const mock = jest.fn(); 141 | instance.setByName('timeout', -200000); 142 | await (instance.getByName('prepareRequestConfig').bind(instance)({ timeout: mock, set: mock })); 143 | 144 | expect(mock.mock.calls[0]).toEqual([-200000]); 145 | expect(mock).toBeCalledWith('Content-Type', 'application/x-www-form-urlencoded'); 146 | }); 147 | }); 148 | 149 | describe('prepareRequestContent', () => { 150 | it('should prepare request when having only to', async () => { 151 | instance.setByName('defaultConfig', { from: 'from@me.com' }); 152 | const mock = jest.fn(); 153 | 154 | await (instance.getByName('prepareRequestContent').bind(instance)({ field: mock }, 155 | { 156 | to: 'trung@test.com', 157 | title: 'test', 158 | content: 'test 2 test' 159 | } 160 | )); 161 | 162 | expect(mock).lastCalledWith({ 163 | from: 'from@me.com', 164 | to: 'trung@test.com', 165 | subject: 'test', 166 | text: 'test 2 test' 167 | }); 168 | }); 169 | 170 | it('should prepare request when having cc', async () => { 171 | instance.setByName('defaultConfig', { from: 'from@me.com' }); 172 | const mock = jest.fn(); 173 | 174 | await (instance.getByName('prepareRequestContent').bind(instance)({ field: mock }, 175 | { 176 | to: 'trung@test.com', 177 | cc: ['test@cc.com'], 178 | title: 'test', 179 | content: 'test 2 test' 180 | } 181 | )); 182 | 183 | expect(mock).lastCalledWith({ 184 | from: 'from@me.com', 185 | to: 'trung@test.com', 186 | cc: ['test@cc.com'], 187 | subject: 'test', 188 | text: 'test 2 test' 189 | }); 190 | }); 191 | 192 | it('should prepare request when having bcc', async () => { 193 | instance.setByName('defaultConfig', { from: 'from@me.com' }); 194 | const mock = jest.fn(); 195 | 196 | await (instance.getByName('prepareRequestContent').bind(instance)({ field: mock }, 197 | { 198 | to: 'trung@test.com', 199 | cc: ['test@cc.com'], 200 | bcc: ['test@bcc.com'], 201 | title: 'test', 202 | content: 'test 2 test' 203 | } 204 | )); 205 | 206 | expect(mock).lastCalledWith({ 207 | from: 'from@me.com', 208 | to: 'trung@test.com', 209 | cc: ['test@cc.com'], 210 | bcc: ['test@bcc.com'], 211 | subject: 'test', 212 | text: 'test 2 test' 213 | }); 214 | }); 215 | }); 216 | }); 217 | -------------------------------------------------------------------------------- /src/providers/database.provider.spec.ts: -------------------------------------------------------------------------------- 1 | import * as mongodb from 'mongodb'; 2 | import { Database } from './database.provider'; 3 | import { IOC_KEY, IDatabaseInstance } from '../commons'; 4 | import { loggerMock, configMock, when } from '../commons/test-helper'; 5 | 6 | const logger = loggerMock(); 7 | const config = configMock(); 8 | const configDefaultKey = 'mongodb'; 9 | 10 | class FakeDatabase extends Database { 11 | getByName(name: string) { 12 | return (this as any)[name]; 13 | } 14 | 15 | setByName(name: string, value: any) { 16 | (this as any)[name] = value; 17 | } 18 | } 19 | 20 | describe('/src/providers/database.provider.ts', () => { 21 | let instance: FakeDatabase; 22 | 23 | beforeAll(() => { 24 | when(config.get).calledWith(configDefaultKey).mockReturnValue({ 25 | host: 'localhost:27020', 26 | user: 'myUser', 27 | password: 'myPassword', 28 | database: 'mails', 29 | replicaSet: 'myReplicaSet' 30 | }); 31 | instance = new FakeDatabase(config, logger); 32 | 33 | // verify the constructor 34 | expect(instance.getByName('connectionString')).toEqual('mongodb://myUser:myPassword@localhost:27020/mails?replicaSet=myReplicaSet'); 35 | expect(instance.getByName('dbName')).toEqual('mails'); 36 | }); 37 | 38 | afterAll(() => { 39 | jest.restoreAllMocks(); 40 | }); 41 | 42 | afterEach(() => { 43 | jest.resetAllMocks(); 44 | }); 45 | 46 | describe('ioc', () => { 47 | it('should register as IDatabaseInstance', () => { 48 | expect(Database[IOC_KEY]).toMatchObject({ 49 | provide: IDatabaseInstance, 50 | useClass: Database 51 | }); 52 | }); 53 | }); 54 | 55 | describe('open', () => { 56 | let connectSpyHandler: jest.SpyInstance; 57 | 58 | beforeAll(() => { 59 | connectSpyHandler = jest.spyOn(mongodb.MongoClient, 'connect'); 60 | }); 61 | 62 | afterAll(() => { 63 | connectSpyHandler.mockRestore(); 64 | }); 65 | 66 | it('should open new connection with default options', async () => { 67 | const connectionString = instance.getByName('connectionString'); 68 | const dbName = instance.getByName('dbName'); 69 | const expectDbInstance = { a: 1, b: 2 }; 70 | 71 | const fakeClient = { 72 | db: jest.fn() 73 | }; 74 | when(fakeClient.db).calledWith(dbName).mockReturnValue(expectDbInstance); 75 | connectSpyHandler.mockReturnValue(fakeClient); 76 | 77 | await instance.open(); 78 | expect(connectSpyHandler).toBeCalledWith(connectionString, { 79 | authSource: instance.getByName('authSource'), 80 | reconnectTries: Number.MAX_VALUE, 81 | promiseLibrary: Promise 82 | }); 83 | expect(instance.getByName('database')).toStrictEqual(expectDbInstance); 84 | expect(instance.getByName('mongoClient')).toStrictEqual(fakeClient); 85 | 86 | expect(logger.debug).toBeCalledWith('Establishing connection to mongodb'); 87 | }); 88 | 89 | it('should open new connection with custom options', async () => { 90 | const connectionString = instance.getByName('connectionString'); 91 | const dbName = instance.getByName('dbName'); 92 | const expectDbInstance = { a: 1, b: 2 }; 93 | 94 | const fakeClient = { 95 | db: jest.fn() 96 | }; 97 | when(fakeClient.db).calledWith(dbName).mockReturnValue(expectDbInstance); 98 | connectSpyHandler.mockReturnValue(fakeClient); 99 | 100 | await instance.open({ 101 | authSource: 'test', 102 | promiseLibrary: null 103 | }); 104 | expect(connectSpyHandler).toBeCalledWith(connectionString, { 105 | authSource: 'test', 106 | reconnectTries: Number.MAX_VALUE, 107 | promiseLibrary: null 108 | }); 109 | expect(instance.getByName('database')).toStrictEqual(expectDbInstance); 110 | expect(instance.getByName('mongoClient')).toStrictEqual(fakeClient); 111 | }); 112 | 113 | it('should support no auth user config', () => { 114 | config.get.mockReset(); 115 | when(config.get).calledWith(configDefaultKey).mockReturnValue({ 116 | host: 'localhost:27020', 117 | user: '', 118 | password: '', 119 | database: 'mails', 120 | replicaSet: 'myReplicaSet' 121 | }); 122 | instance = new FakeDatabase(config, logger); 123 | expect(instance.getByName('connectionString')).toEqual('mongodb://localhost:27020/mails?replicaSet=myReplicaSet'); 124 | }); 125 | 126 | it('should support auth with no password', () => { 127 | config.get.mockReset(); 128 | when(config.get).calledWith(configDefaultKey).mockReturnValue({ 129 | host: 'localhost:27020', 130 | user: 'trung', 131 | password: '', 132 | database: 'mails', 133 | replicaSet: 'myReplicaSet' 134 | }); 135 | instance = new FakeDatabase(config, logger); 136 | expect(instance.getByName('connectionString')).toEqual('mongodb://trung@localhost:27020/mails?replicaSet=myReplicaSet'); 137 | }); 138 | }); 139 | 140 | describe('collection', () => { 141 | const fakeDbObject = { 142 | collection: jest.fn() 143 | }; 144 | 145 | it('should open connection if have not established connection yet', async () => { 146 | const expectCollectionObject = { a: 1, b: 2 }; 147 | instance.setByName('database', null); 148 | const spyOpenHandler = jest.spyOn(instance, 'open'); 149 | spyOpenHandler.mockResolvedValue(fakeDbObject); 150 | fakeDbObject.collection.mockReset(); 151 | when(fakeDbObject.collection).calledWith('myCollection').mockResolvedValue(expectCollectionObject); 152 | 153 | const collection = await instance.collection('myCollection'); 154 | 155 | expect(collection).toMatchObject(expectCollectionObject); 156 | expect(logger.debug).toBeCalledWith('Retrieving collect myCollection'); 157 | expect(spyOpenHandler).toHaveBeenCalledTimes(1); 158 | 159 | spyOpenHandler.mockRestore(); 160 | }); 161 | 162 | it('should not open connection if already established', async () => { 163 | instance.setByName('database', fakeDbObject); 164 | const spyOpenHandler = jest.spyOn(instance, 'open'); 165 | fakeDbObject.collection.mockReset(); 166 | 167 | await instance.collection('myCollection'); 168 | 169 | expect(spyOpenHandler).not.toBeCalled(); 170 | 171 | spyOpenHandler.mockRestore(); 172 | }); 173 | }); 174 | 175 | describe('close', () => { 176 | it('should close the connection if alreay established', () => { 177 | const fakeClient = { 178 | close: jest.fn() 179 | }; 180 | instance.setByName('mongoClient', fakeClient); 181 | instance.setByName('database', 'fake value, should be cleared after close'); 182 | instance.close(); 183 | 184 | expect(instance.getByName('database')).toStrictEqual(null); 185 | expect(fakeClient.close).toHaveBeenCalledTimes(1); 186 | }); 187 | 188 | it('should clear the db info but not close connection', () => { 189 | instance.setByName('mongoClient', null); 190 | instance.setByName('database', 'fake value, should be cleared after close'); 191 | instance.close(); 192 | 193 | expect(instance.getByName('database')).toStrictEqual(null); 194 | }); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /src/services/mta/sendgrid.mta.spec.ts: -------------------------------------------------------------------------------- 1 | import { SendGridMTA } from './sendgrid.mta'; 2 | import { loggerMock, configMock, when } from '../../commons/test-helper'; 3 | 4 | class FakeSendgridMTA extends SendGridMTA { 5 | getByName(name: string) { 6 | return (this as any)[name]; 7 | } 8 | 9 | setByName(name: string, value: any) { 10 | (this as any)[name] = value; 11 | } 12 | } 13 | 14 | const logger = loggerMock(); 15 | const config = configMock(); 16 | 17 | describe('/src/services/mta/sendgrid.mta.ts', () => { 18 | let instance: FakeSendgridMTA; 19 | 20 | beforeAll(() => { 21 | instance = new FakeSendgridMTA(logger, config); 22 | }); 23 | 24 | afterEach(() => { 25 | jest.clearAllMocks(); 26 | }); 27 | 28 | afterAll(() => { 29 | jest.restoreAllMocks(); 30 | }); 31 | 32 | it('should register correct name', () => { 33 | expect(instance.name).toEqual('sendgrid'); 34 | }); 35 | 36 | it('should register correct config name', () => { 37 | expect(instance.getByName('configName')).toEqual('sendgrid'); 38 | }); 39 | 40 | it('should register correct method', () => { 41 | expect(instance.getByName('method')).toEqual('POST'); 42 | }); 43 | 44 | describe('init', () => { 45 | it('should throw error if no config', () => { 46 | when(config.get).calledWith('mails').mockReturnValue({}); 47 | return instance.init() 48 | .catch((e) => { 49 | expect(e).toMatchObject({ 50 | message: `Invalid configuration of sendgrid. Actual config: ${JSON.stringify(instance.getByName('defaultConfig'))}` 51 | }); 52 | }); 53 | }); 54 | 55 | it('should throw error if dont have authKey', () => { 56 | when(config.get).calledWith('mails').mockReturnValue({ 57 | sendgrid: { 58 | from: 'from', 59 | } 60 | }); 61 | return instance.init() 62 | .catch((e) => { 63 | expect(e).toMatchObject({ 64 | message: `Invalid configuration of sendgrid. Actual config: ${JSON.stringify(instance.getByName('defaultConfig'))}` 65 | }); 66 | }); 67 | }); 68 | 69 | it('should throw error if dont have from', () => { 70 | when(config.get).calledWith('mails').mockReturnValue({ 71 | sendgrid: { 72 | authKey: 'domain' 73 | } 74 | }); 75 | return instance.init() 76 | .catch((e) => { 77 | expect(e).toMatchObject({ 78 | message: `Invalid configuration of sendgrid. Actual config: ${JSON.stringify(instance.getByName('defaultConfig'))}` 79 | }); 80 | }); 81 | }); 82 | 83 | it('should call super to init', () => { 84 | const expectConfig = { 85 | authKey: 'from', 86 | from: 'from' 87 | }; 88 | when(config.get).calledWith('mails').mockReturnValue({ 89 | sendgrid: expectConfig 90 | }); 91 | return instance.init() 92 | .then(() => { 93 | expect(instance.getByName('config')).toMatchObject(expectConfig); 94 | }); 95 | }); 96 | }); 97 | 98 | describe('prepareAuth', () => { 99 | it('should set correct auth', async () => { 100 | const myKey = 'mykey'; 101 | instance.setByName('defaultConfig', { authKey: myKey }); 102 | const mock = jest.fn(); 103 | await (instance.getByName('prepareAuth').bind(instance)({ auth: mock })); 104 | 105 | expect(mock).toHaveBeenCalledTimes(1); 106 | expect(mock).lastCalledWith(myKey, { type: 'bearer' }); 107 | }); 108 | }); 109 | 110 | describe('prepareUrl', () => { 111 | it('should return correct url', () => { 112 | const url = instance.getByName('prepareUrl').bind(instance)(); 113 | 114 | expect(url).toEqual('https://api.sendgrid.com/v3/mail/send'); 115 | }); 116 | }); 117 | 118 | describe('prepareRequestConfig', () => { 119 | it('should prepare correct config', async () => { 120 | const mock = jest.fn(); 121 | instance.setByName('timeout', -200000); 122 | await (instance.getByName('prepareRequestConfig').bind(instance)({ timeout: mock, set: mock, accept: mock })); 123 | 124 | expect(mock.mock.calls[0]).toEqual([-200000]); 125 | expect(mock).toBeCalledWith('Content-Type', 'application/json'); 126 | expect(mock).toBeCalledWith('application/json'); 127 | }); 128 | }); 129 | 130 | describe('prepareRequestContent', () => { 131 | it('should prepare request when having only to', async () => { 132 | instance.setByName('defaultConfig', { from: 'from@me.com' }); 133 | const mock = jest.fn(); 134 | 135 | await (instance.getByName('prepareRequestContent').bind(instance)({ send: mock }, 136 | { 137 | to: ['trung@test.com'], 138 | title: 'test', 139 | content: 'test 2 test' 140 | } 141 | )); 142 | 143 | expect(mock).lastCalledWith({ 144 | personalizations: [ 145 | { 146 | to: [ 147 | { email: 'trung@test.com' } 148 | ] 149 | } 150 | ], 151 | from: { 152 | email: 'from@me.com' 153 | }, 154 | subject: 'test', 155 | content: [ 156 | { 157 | type: 'text/plain', 158 | value: 'test 2 test' 159 | } 160 | ] 161 | }); 162 | }); 163 | 164 | it('should prepare request when having cc', async () => { 165 | instance.setByName('defaultConfig', { from: 'from@me.com' }); 166 | const mock = jest.fn(); 167 | 168 | await (instance.getByName('prepareRequestContent').bind(instance)({ send: mock }, 169 | { 170 | to: ['trung@test.com'], 171 | cc: ['cc@test.com'], 172 | title: 'test', 173 | content: 'test 2 test' 174 | } 175 | )); 176 | 177 | expect(mock).lastCalledWith({ 178 | personalizations: [ 179 | { 180 | to: [ 181 | { email: 'trung@test.com' } 182 | ], 183 | cc: [ 184 | { email: 'cc@test.com' } 185 | ] 186 | } 187 | ], 188 | from: { 189 | email: 'from@me.com' 190 | }, 191 | subject: 'test', 192 | content: [ 193 | { 194 | type: 'text/plain', 195 | value: 'test 2 test' 196 | } 197 | ] 198 | }); 199 | }); 200 | 201 | it('should prepare request when having bcc', async () => { 202 | instance.setByName('defaultConfig', { from: 'from@me.com' }); 203 | const mock = jest.fn(); 204 | 205 | await (instance.getByName('prepareRequestContent').bind(instance)({ send: mock }, 206 | { 207 | to: ['trung@test.com'], 208 | cc: ['cc@test.com'], 209 | bcc: ['bcc@test.com'], 210 | title: 'test', 211 | content: 'test 2 test' 212 | } 213 | )); 214 | 215 | expect(mock).lastCalledWith({ 216 | personalizations: [ 217 | { 218 | to: [ 219 | { email: 'trung@test.com' } 220 | ], 221 | cc: [ 222 | { email: 'cc@test.com' } 223 | ], 224 | bcc: [ 225 | { email: 'bcc@test.com' } 226 | ] 227 | } 228 | ], 229 | from: { 230 | email: 'from@me.com' 231 | }, 232 | subject: 'test', 233 | content: [ 234 | { 235 | type: 'text/plain', 236 | value: 'test 2 test' 237 | } 238 | ] 239 | }); 240 | }); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /src/services/mail.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { MailService } from './mail.service'; 3 | import { IMailService } from './mail.service.interface'; 4 | import { collectionMock, randomString, when } from '../commons/test-helper'; 5 | import { IOC_KEY, EMailStatus } from '../commons'; 6 | import { InsertMailInfoDto, MailStatusDto } from '../dto'; 7 | 8 | const mailCol = collectionMock(); 9 | 10 | describe('/src/services/mail.service.ts', () => { 11 | let instance: IMailService; 12 | beforeAll(() => { 13 | instance = new MailService(mailCol as any); 14 | }); 15 | 16 | beforeEach(() => { 17 | jest.resetAllMocks(); 18 | }); 19 | 20 | afterAll(() => { 21 | jest.restoreAllMocks(); 22 | }); 23 | 24 | describe('IoC', () => { 25 | it('should have class information as expected', () => { 26 | expect(MailService[IOC_KEY]).not.toBeUndefined(); 27 | expect(MailService[IOC_KEY]).toMatchObject({ 28 | provide: IMailService, 29 | useClass: MailService 30 | }); 31 | 32 | expect(instance).not.toBeUndefined(); 33 | }); 34 | }); 35 | 36 | describe('insert', () => { 37 | const inp: InsertMailInfoDto = { 38 | to: [randomString()], 39 | cc: [randomString()], 40 | bcc: [randomString()], 41 | title: randomString(), 42 | content: randomString() 43 | }; 44 | 45 | it('should insert new mail and return full new mail document', async () => { 46 | const actualInsertedDoc = { insertedId: randomString() }; 47 | const actualNewMailDoc = { a: 1, b: 2, c: 4 }; 48 | when(mailCol.insertOne).calledWith({ 49 | to: inp.to, 50 | cc: inp.cc, 51 | bcc: inp.bcc, 52 | title: inp.title, 53 | content: inp.content, 54 | status: [ 55 | { 56 | type: EMailStatus.Init 57 | } 58 | ], 59 | sentOn: expect.any(Date) 60 | }).mockResolvedValue(actualInsertedDoc); 61 | when(mailCol.findOne).calledWith({ _id: actualInsertedDoc.insertedId }).mockResolvedValue(actualNewMailDoc); 62 | 63 | const result = await instance.insert(inp); 64 | expect(result).toMatchObject(actualNewMailDoc); 65 | }); 66 | 67 | it('should reject if any exception when inserting new document', () => { 68 | const expectError = new Error('i love this error'); 69 | mailCol.insertOne.mockRejectedValue(expectError); 70 | 71 | return instance.insert(inp) 72 | .then(() => Promise.reject(new Error('insert does not handle error correctly'))) 73 | .catch((e) => { 74 | expect(e).toMatchObject(expectError); 75 | expect(mailCol.findOne).not.toBeCalled(); 76 | }); 77 | }); 78 | }); 79 | 80 | describe('fetchPendingMails', () => { 81 | const baseQuery = { 82 | 'status.0.type': { 83 | $nin: [EMailStatus.Success, EMailStatus.Fail] 84 | } 85 | }; 86 | const baseOptions = { 87 | sort: { 88 | id: 1 89 | }, 90 | projection: { 91 | _id: 1 92 | }, 93 | limit: 100 94 | }; 95 | 96 | it('should fetch data without the input conditions', async () => { 97 | const expectValue = [{ a: 1, b: 2 }]; 98 | mailCol.find.mockReset(); 99 | when(mailCol.find).calledWith({ ...baseQuery }, { ...baseOptions }).mockReturnValue(mailCol); 100 | mailCol.toArray.mockResolvedValue(expectValue); 101 | 102 | const result = await instance.fetchPendingMails(); 103 | expect(result).toEqual(expectValue); 104 | }); 105 | 106 | it('should fetch data with custom limit', async () => { 107 | const expectValue = [{ a: 1, b: 2 }]; 108 | mailCol.find.mockReset(); 109 | when(mailCol.find).calledWith({ ...baseQuery }, { ...baseOptions, limit: 20 }).mockReturnValue(mailCol); 110 | mailCol.toArray.mockResolvedValue(expectValue); 111 | 112 | const result = await instance.fetchPendingMails({ limit: 20 }); 113 | expect(result).toEqual(expectValue); 114 | }); 115 | 116 | it('should not fetch data with custom limit if limit < 0', async () => { 117 | const expectValue = [{ a: 1, b: 2 }]; 118 | mailCol.find.mockReset(); 119 | when(mailCol.find).calledWith({ ...baseQuery }, { ...baseOptions, limit: 100 }).mockReturnValue(mailCol); 120 | mailCol.toArray.mockResolvedValue(expectValue); 121 | 122 | const result = await instance.fetchPendingMails({ limit: -1 }); 123 | expect(result).toEqual(expectValue); 124 | }); 125 | 126 | it('should fetch data with fromId', async () => { 127 | const expectValue = [{ a: 1, b: 2 }]; 128 | const id = new ObjectId('5d47931e4ac9107f560cd446'); 129 | mailCol.find.mockReset(); 130 | when(mailCol.find).calledWith({ 131 | ...baseQuery, 132 | _id: { 133 | $gt: id 134 | } 135 | }, { ...baseOptions }).mockReturnValue(mailCol); 136 | mailCol.toArray.mockResolvedValue(expectValue); 137 | 138 | const result = await instance.fetchPendingMails({ fromId: id }); 139 | expect(result).toEqual(expectValue); 140 | }); 141 | 142 | it('should fetch data with custom bufferTime', async () => { 143 | const expectValue = [{ a: 1, b: 2 }]; 144 | const bufferTime = 10; 145 | mailCol.find.mockReset(); 146 | when(mailCol.find).calledWith({ 147 | ...baseQuery, 148 | sentOn: { 149 | $lt: expect.any(Date) 150 | } 151 | }, { ...baseOptions }).mockReturnValue(mailCol); 152 | mailCol.toArray.mockResolvedValue(expectValue); 153 | 154 | const result = await instance.fetchPendingMails({ bufferTime }); 155 | expect(result).toEqual(expectValue); 156 | 157 | const compareTime = new Date(Date.now() - bufferTime * 1000); 158 | const fistCallArgs = mailCol.find.mock.calls[0][0].sentOn['$lt']; 159 | expect(Math.abs(compareTime.getTime() - fistCallArgs.getTime())).toBeLessThanOrEqual(1000); // should be within 1s in diff 160 | }); 161 | }); 162 | 163 | describe('updateMailStatus', () => { 164 | it('should update mail status if provide objectid string', async () => { 165 | const id = '5d4a0bbb36a94547a743dcd5'; 166 | const status: MailStatusDto = { 167 | type: EMailStatus.Success 168 | }; 169 | mailCol.updateOne.mockResolvedValue({ result: { ok: 1 } }); 170 | 171 | const res = await instance.updateMailStatus(id, status); 172 | expect(res).toEqual(true); 173 | expect(mailCol.updateOne).toHaveBeenCalledTimes(1); 174 | expect(mailCol.updateOne).toBeCalledWith({ _id: new ObjectId(id) }, 175 | { 176 | $set: 177 | { 178 | 'status.0': status 179 | } 180 | }); 181 | }); 182 | 183 | it('should update mail status if provide objectid', async () => { 184 | const id = new ObjectId('5d4a0bbb36a94547a743dcd5'); 185 | const status: MailStatusDto = { 186 | type: EMailStatus.Success 187 | }; 188 | mailCol.updateOne.mockResolvedValue({ result: { ok: 1 } }); 189 | 190 | const res = await instance.updateMailStatus(id, status); 191 | expect(res).toEqual(true); 192 | expect(mailCol.updateOne).toHaveBeenCalledTimes(1); 193 | expect(mailCol.updateOne).toBeCalledWith({ _id: new ObjectId(id) }, 194 | { 195 | $set: 196 | { 197 | 'status.0': status 198 | } 199 | }); 200 | }); 201 | }); 202 | 203 | describe('addMailStatus', () => { 204 | it('should add new mail status if provide objectid string', async () => { 205 | const id = '5d4a0bbb36a94547a743dcd5'; 206 | const status: MailStatusDto = { 207 | type: EMailStatus.Success 208 | }; 209 | mailCol.updateOne.mockResolvedValue({ result: { ok: 1 } }); 210 | 211 | const res = await instance.addMailStatus(id, status); 212 | expect(res).toEqual(true); 213 | expect(mailCol.updateOne).toHaveBeenCalledTimes(1); 214 | expect(mailCol.updateOne).toBeCalledWith({ _id: new ObjectId(id) }, 215 | { 216 | $push: 217 | { 218 | status: { 219 | $each: [status], 220 | $position: 0 221 | } 222 | } 223 | }); 224 | }); 225 | 226 | it('should add new mail status if provide objectid', async () => { 227 | const id = new ObjectId('5d4a0bbb36a94547a743dcd5'); 228 | const status: MailStatusDto = { 229 | type: EMailStatus.Success 230 | }; 231 | mailCol.updateOne.mockResolvedValue({ result: { ok: 1 } }); 232 | 233 | const res = await instance.addMailStatus(id, status); 234 | expect(res).toEqual(true); 235 | expect(mailCol.updateOne).toHaveBeenCalledTimes(1); 236 | expect(mailCol.updateOne).toBeCalledWith({ _id: new ObjectId(id) }, 237 | { 238 | $push: 239 | { 240 | status: { 241 | $each: [status], 242 | $position: 0 243 | } 244 | } 245 | }); 246 | }); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /src/services/jobs/jobabstract.spec.ts: -------------------------------------------------------------------------------- 1 | import { when } from 'jest-when'; 2 | import { Scheduler } from 'nest-schedule/dist/scheduler'; 3 | import { JobAbstract } from './jobabstract'; 4 | import { EJobType } from './jobabstract.interface'; 5 | import { ILoggerInstance, IConfiguration } from '../../commons'; 6 | import { configMock, spyOn, loggerMock } from '../../commons/test-helper'; 7 | 8 | class TestJob extends JobAbstract { 9 | protected jobName: string = 'test-job'; 10 | protected logger: ILoggerInstance; 11 | protected configService: IConfiguration; 12 | 13 | setLogger(logger: any) { 14 | this.logger = logger; 15 | } 16 | 17 | setRunningState(state: boolean) { 18 | this.isRunning = state; 19 | } 20 | 21 | getRunningState() { 22 | return this.isRunning; 23 | } 24 | 25 | getConfig() { 26 | return this.config; 27 | } 28 | 29 | resetConfig() { 30 | this.config = { 31 | type: EJobType.timeout, 32 | maxRetry: 5, 33 | retryInterval: 2000 34 | }; 35 | } 36 | 37 | getJobName() { 38 | return this.jobName; 39 | } 40 | 41 | setConfigService(configService: any) { 42 | this.configService = configService; 43 | } 44 | 45 | execute(): boolean | void | Promise | Promise { 46 | } 47 | } 48 | 49 | describe('/src/jobs/jobabstract.ts', () => { 50 | let instance: TestJob; 51 | const logger = loggerMock(); 52 | 53 | beforeAll(() => { 54 | instance = new TestJob(); 55 | instance.setLogger(logger); 56 | }); 57 | beforeEach(() => { 58 | instance.resetConfig(); 59 | }); 60 | afterAll(() => { 61 | jest.restoreAllMocks(); 62 | }); 63 | afterEach(() => { 64 | jest.resetAllMocks(); 65 | }); 66 | 67 | describe('tryLock', () => { 68 | it('should return true when try lock', async () => { 69 | expect(await instance.tryLock()).toEqual(true); 70 | }); 71 | }); 72 | 73 | describe('onModuleInit', () => { 74 | const configService = configMock(); 75 | let handlers: { 76 | scheduleCronJob: jest.SpyInstance, 77 | scheduleIntervalJob: jest.SpyInstance, 78 | scheduleTimeoutJob: jest.SpyInstance 79 | } = null; 80 | beforeAll(() => { 81 | instance.setConfigService(configService); 82 | handlers = spyOn(Scheduler, [ 83 | 'scheduleCronJob', 84 | 'scheduleIntervalJob', 85 | 'scheduleTimeoutJob' 86 | ]); 87 | }); 88 | 89 | it('should build config correctly', async () => { 90 | const userConfig = { 91 | myConfig: 123, 92 | enable: true 93 | }; 94 | when(configService.get).calledWith('jobs').mockReturnValue({ 95 | [instance.getJobName()]: userConfig 96 | }); 97 | handlers.scheduleTimeoutJob.mockImplementation(() => { }); 98 | await instance.onModuleInit(); 99 | 100 | expect(instance.getConfig()).toMatchObject({ 101 | startTime: null, 102 | endTime: null, 103 | type: EJobType.timeout, 104 | maxRetry: 5, 105 | retryInterval: 2000, 106 | ...userConfig 107 | }); 108 | 109 | expect(Scheduler.scheduleTimeoutJob).toBeCalled(); 110 | }); 111 | 112 | it('should convert to native DateTime for startTime and endTime', async () => { 113 | const userConfig = { 114 | startTime: 'Mon Jun 03 2019', 115 | endTime: '2019-06-03T04:00:14.803Z', 116 | enable: true 117 | }; 118 | 119 | when(configService.get).calledWith('jobs').mockReturnValue({ 120 | [instance.getJobName()]: userConfig 121 | }); 122 | handlers.scheduleTimeoutJob.mockImplementation(() => { }); 123 | await instance.onModuleInit(); 124 | 125 | expect(instance.getConfig()).toMatchObject({ 126 | startTime: new Date(userConfig.startTime), 127 | endTime: new Date(userConfig.endTime), 128 | type: EJobType.timeout, 129 | maxRetry: 5, 130 | retryInterval: 2000 131 | }); 132 | }); 133 | 134 | it('should register timeout job correctly', async () => { 135 | when(configService.get).calledWith('jobs').mockReturnValue({ 136 | [instance.getJobName()]: { enable: true } 137 | }); 138 | handlers.scheduleTimeoutJob.mockImplementation(() => { }); 139 | await instance.onModuleInit(); 140 | 141 | const config = instance.getConfig(); 142 | expect(handlers.scheduleTimeoutJob).toHaveBeenCalledTimes(1); 143 | expect(handlers.scheduleTimeoutJob).toBeCalledWith(instance.getJobName(), config.timeout, expect.anything(), config, expect.anything()); 144 | expect(handlers.scheduleCronJob).not.toBeCalled(); 145 | expect(handlers.scheduleIntervalJob).not.toBeCalled(); 146 | }); 147 | 148 | it('should register interval job correctly', async () => { 149 | when(configService.get).calledWith('jobs').mockReturnValue({ 150 | [instance.getJobName()]: { 151 | type: EJobType.interval, 152 | interval: 500, 153 | enable: true 154 | } 155 | }); 156 | handlers.scheduleIntervalJob.mockImplementation(() => { }); 157 | await instance.onModuleInit(); 158 | 159 | const config = instance.getConfig(); 160 | expect(handlers.scheduleIntervalJob).toHaveBeenCalledTimes(1); 161 | expect(handlers.scheduleIntervalJob).toBeCalledWith(instance.getJobName(), config.interval, expect.anything(), config, expect.anything()); 162 | expect(handlers.scheduleCronJob).not.toBeCalled(); 163 | expect(handlers.scheduleTimeoutJob).not.toBeCalled(); 164 | }); 165 | 166 | it('should register cron job correctly', async () => { 167 | when(configService.get).calledWith('jobs').mockReturnValue({ 168 | [instance.getJobName()]: { 169 | type: EJobType.cron, 170 | cron: '*/1 * * * *', 171 | enable: true 172 | } 173 | }); 174 | handlers.scheduleCronJob.mockImplementation(() => { }); 175 | await instance.onModuleInit(); 176 | 177 | const config = instance.getConfig(); 178 | expect(handlers.scheduleCronJob).toHaveBeenCalledTimes(1); 179 | expect(handlers.scheduleCronJob).toBeCalledWith(instance.getJobName(), config.cron, expect.anything(), config, expect.anything()); 180 | expect(handlers.scheduleIntervalJob).not.toBeCalled(); 181 | expect(handlers.scheduleTimeoutJob).not.toBeCalled(); 182 | }); 183 | 184 | it('should pass tryLock to scheduler', async () => { 185 | when(configService.get).calledWith('jobs').mockReturnValue({ 186 | [instance.getJobName()]: { 187 | enable: true 188 | } 189 | }); 190 | handlers.scheduleTimeoutJob.mockImplementation(() => { }); 191 | const tryLockHandler = jest.spyOn(instance, 'tryLock'); 192 | await instance.onModuleInit(); 193 | 194 | const tryLockArgument = handlers.scheduleTimeoutJob.mock.calls[0][4]; 195 | tryLockArgument(); 196 | expect(tryLockHandler).toBeCalled(); 197 | tryLockHandler.mockRestore(); 198 | }); 199 | 200 | describe('job executor mechanism', () => { 201 | let executorHandler: jest.SpyInstance; 202 | 203 | beforeAll(() => { 204 | executorHandler = jest.spyOn(instance, 'execute'); 205 | }); 206 | 207 | beforeEach(() => { 208 | when(configService.get).calledWith('jobs').mockReturnValue({ 209 | [instance.getJobName()]: { enable: true } 210 | }); 211 | handlers.scheduleTimeoutJob.mockImplementation(() => { }); 212 | }); 213 | 214 | it('should not trigger job if it is running', async () => { 215 | instance.setRunningState(true); 216 | await instance.onModuleInit(); 217 | 218 | const executor = handlers.scheduleTimeoutJob.mock.calls[0][2]; 219 | await executor(); 220 | expect(executorHandler).not.toBeCalled(); 221 | expect(logger.debug).toBeCalledWith(`Job ${instance.getJobName()} is running. Skipping`); 222 | }); 223 | 224 | it('should trigger job if it is not running', async () => { 225 | instance.setRunningState(false); 226 | executorHandler.mockImplementation(() => 'ok ok'); 227 | await instance.onModuleInit(); 228 | 229 | const executor = handlers.scheduleTimeoutJob.mock.calls[0][2]; 230 | const ret = await executor(); 231 | expect(executorHandler).toHaveBeenCalledTimes(1); 232 | expect(ret).toEqual('ok ok'); 233 | }); 234 | 235 | it('should change the state isRunning to true before execute job', async () => { 236 | instance.setRunningState(false); 237 | executorHandler.mockImplementation(() => { 238 | expect(instance.getRunningState()).toEqual(true); 239 | return 'ok ok'; 240 | }); 241 | await instance.onModuleInit(); 242 | 243 | const executor = handlers.scheduleTimeoutJob.mock.calls[0][2]; 244 | const ret = await executor(); 245 | expect(instance.getRunningState()).toEqual(false); // switch back to false 246 | expect(executorHandler).toHaveBeenCalledTimes(1); 247 | expect(ret).toEqual('ok ok'); 248 | }); 249 | 250 | it('should force return false if job does not return anything', async () => { 251 | instance.setRunningState(false); 252 | await instance.onModuleInit(); 253 | 254 | const executor = handlers.scheduleTimeoutJob.mock.calls[0][2]; 255 | const ret = await executor(); 256 | expect(executorHandler).toHaveBeenCalledTimes(1); 257 | expect(ret).toEqual(false); 258 | }); 259 | 260 | it('should try catch when execute job', async () => { 261 | const error = new Error('my error'); 262 | 263 | instance.setRunningState(false); 264 | executorHandler.mockReset(); 265 | executorHandler.mockRejectedValue(error); 266 | 267 | await instance.onModuleInit(); 268 | 269 | const executor = handlers.scheduleTimeoutJob.mock.calls[0][2]; 270 | const ret = await executor(); 271 | expect(executorHandler).toHaveBeenCalledTimes(1); 272 | expect(ret).toEqual(true); 273 | expect(instance.getRunningState()).toEqual(false); // force running to false 274 | expect(logger.error).toBeCalledWith({ 275 | message: error.message, 276 | stack: error.stack, 277 | jobId: instance.getJobName() 278 | }); 279 | }); 280 | }); 281 | }); 282 | 283 | describe('cancelJob', () => { 284 | it('should cancel the job', () => { 285 | const handler = jest.spyOn(Scheduler, 'cancelJob'); 286 | handler.mockImplementation(() => { }); 287 | instance.cancelJob(); 288 | expect(handler).toBeCalledWith(instance.getJobName()); 289 | handler.mockRestore(); 290 | }); 291 | }); 292 | }); 293 | -------------------------------------------------------------------------------- /test/mail.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | import * as _ from 'lodash'; 5 | import { NoopLogger, randomString } from '../src/commons/test-helper'; 6 | import { PROVIDERS, EMailStatus } from '../src/commons'; 7 | import { MailModule } from '../src/modules/mail.module'; 8 | import { GlobalModule } from '../src/modules/global.module'; 9 | import { IMailService } from '../src/services'; 10 | 11 | describe('/test/mail.e2e-spec.ts', () => { 12 | let app: INestApplication; 13 | 14 | beforeAll(async () => { 15 | const module = await Test.createTestingModule({ 16 | imports: [ 17 | GlobalModule.forRoot(), 18 | MailModule 19 | ] 20 | }) 21 | .overrideProvider(PROVIDERS.ROOT_LOGGER) 22 | .useValue(NoopLogger) 23 | .compile(); 24 | 25 | app = module.createNestApplication(); 26 | await app.init(); 27 | }); 28 | 29 | afterAll(async () => { 30 | await app.close(); 31 | }); 32 | 33 | describe('Email endpoints', () => { 34 | describe('POST /api/emails', () => { 35 | describe('validation', () => { 36 | it('when request without title then return 400', () => { 37 | return request(app.getHttpServer()) 38 | .post('/api/emails') 39 | .send({}) 40 | .set('accept', 'json') 41 | .expect(400) 42 | .then((ret) => { 43 | expect(ret.body.message).toEqual(expect.arrayContaining([ 44 | expect.objectContaining({ 45 | property: 'title', 46 | constraints: expect.objectContaining({ 47 | isString: 'title must be a string' 48 | }) 49 | }) 50 | ])); 51 | }); 52 | }); 53 | 54 | it('when request with empty title then return 400', () => { 55 | return request(app.getHttpServer()) 56 | .post('/api/emails') 57 | .send({ 58 | title: '' 59 | }) 60 | .set('accept', 'json') 61 | .expect(400) 62 | .then((ret) => { 63 | expect(ret.body.message).toEqual(expect.arrayContaining([ 64 | expect.objectContaining({ 65 | property: 'title', 66 | constraints: expect.objectContaining({ 67 | minLength: 'title must be longer than or equal to 1 characters' 68 | }) 69 | }) 70 | ])); 71 | }); 72 | }); 73 | 74 | it('when request with empty content then return 400', () => { 75 | return request(app.getHttpServer()) 76 | .post('/api/emails') 77 | .send({ 78 | content: '' 79 | }) 80 | .set('accept', 'json') 81 | .expect(400) 82 | .then((ret) => { 83 | expect(ret.body.message).toEqual(expect.arrayContaining([ 84 | expect.objectContaining({ 85 | property: 'content', 86 | constraints: expect.objectContaining({ 87 | minLength: 'content must be longer than or equal to 1 characters' 88 | }) 89 | }) 90 | ])); 91 | }); 92 | }); 93 | 94 | it('when request without content then return 400', () => { 95 | return request(app.getHttpServer()) 96 | .post('/api/emails') 97 | .send({}) 98 | .set('accept', 'json') 99 | .expect(400) 100 | .then((ret) => { 101 | expect(ret.body.message).toEqual(expect.arrayContaining([ 102 | expect.objectContaining({ 103 | property: 'content', 104 | constraints: expect.objectContaining({ 105 | isString: 'content must be a string' 106 | }) 107 | }) 108 | ])); 109 | }); 110 | }); 111 | 112 | describe('field to', () => { 113 | it('when request with empty to then return 400', () => { 114 | return request(app.getHttpServer()) 115 | .post('/api/emails') 116 | .send({ 117 | to: '' 118 | }) 119 | .set('accept', 'json') 120 | .expect(400) 121 | .then((ret) => { 122 | expect(ret.body.message).toEqual(expect.arrayContaining([ 123 | expect.objectContaining({ 124 | property: 'to', 125 | constraints: expect.objectContaining({ 126 | isArray: 'to must be an array' 127 | }) 128 | }) 129 | ])); 130 | }); 131 | }); 132 | 133 | it('when request without to then return 400', () => { 134 | return request(app.getHttpServer()) 135 | .post('/api/emails') 136 | .send({}) 137 | .set('accept', 'json') 138 | .expect(400) 139 | .then((ret) => { 140 | expect(ret.body.message).toEqual(expect.arrayContaining([ 141 | expect.objectContaining({ 142 | property: 'to', 143 | constraints: expect.objectContaining({ 144 | isArray: 'to must be an array' 145 | }) 146 | }) 147 | ])); 148 | }); 149 | }); 150 | 151 | it('when request with to is not an array then return 400', () => { 152 | return request(app.getHttpServer()) 153 | .post('/api/emails') 154 | .send({ 155 | to: 'test@gmail.com' 156 | }) 157 | .set('accept', 'json') 158 | .expect(400) 159 | .then((ret) => { 160 | expect(ret.body.message).toEqual(expect.arrayContaining([ 161 | expect.objectContaining({ 162 | property: 'to', 163 | constraints: expect.objectContaining({ 164 | isArray: 'to must be an array' 165 | }) 166 | }) 167 | ])); 168 | }); 169 | }); 170 | 171 | it('when request with to has at least one invalid email address then return 400', () => { 172 | return request(app.getHttpServer()) 173 | .post('/api/emails') 174 | .send({ 175 | to: [ 176 | 'test@gmail.com', 177 | 'invalid' 178 | ] 179 | }) 180 | .set('accept', 'json') 181 | .expect(400) 182 | .then((ret) => { 183 | expect(ret.body.message).toEqual(expect.arrayContaining([ 184 | expect.objectContaining({ 185 | property: 'to', 186 | constraints: expect.objectContaining({ 187 | isEmail: 'each value in to must be an email' 188 | }) 189 | }) 190 | ])); 191 | }); 192 | }); 193 | }); 194 | 195 | describe('field cc', () => { 196 | it('when request with empty cc then return 400', () => { 197 | return request(app.getHttpServer()) 198 | .post('/api/emails') 199 | .send({ 200 | cc: '' 201 | }) 202 | .set('accept', 'json') 203 | .expect(400) 204 | .then((ret) => { 205 | expect(ret.body.message).toEqual(expect.arrayContaining([ 206 | expect.objectContaining({ 207 | property: 'cc', 208 | constraints: expect.objectContaining({ 209 | isArray: 'cc must be an array' 210 | }) 211 | }) 212 | ])); 213 | }); 214 | }); 215 | 216 | it('when request without cc then should not throw exception', () => { 217 | return request(app.getHttpServer()) 218 | .post('/api/emails') 219 | .send({}) 220 | .set('accept', 'json') 221 | .expect(400) 222 | .then((ret) => { 223 | expect(ret.body.message).not.toEqual(expect.arrayContaining([ 224 | expect.objectContaining({ 225 | property: 'cc', 226 | constraints: expect.objectContaining({ 227 | isArray: 'cc must be an array' 228 | }) 229 | }) 230 | ])); 231 | }); 232 | }); 233 | 234 | it('when request with cc is not an array then return 400', () => { 235 | return request(app.getHttpServer()) 236 | .post('/api/emails') 237 | .send({ 238 | cc: 'test@gmail.com' 239 | }) 240 | .set('accept', 'json') 241 | .expect(400) 242 | .then((ret) => { 243 | expect(ret.body.message).toEqual(expect.arrayContaining([ 244 | expect.objectContaining({ 245 | property: 'cc', 246 | constraints: expect.objectContaining({ 247 | isArray: 'cc must be an array' 248 | }) 249 | }) 250 | ])); 251 | }); 252 | }); 253 | 254 | it('when request with cc has at least one invalid email address then return 400', () => { 255 | return request(app.getHttpServer()) 256 | .post('/api/emails') 257 | .send({ 258 | cc: [ 259 | 'test@gmail.com', 260 | 'invalid' 261 | ] 262 | }) 263 | .set('accept', 'json') 264 | .expect(400) 265 | .then((ret) => { 266 | expect(ret.body.message).toEqual(expect.arrayContaining([ 267 | expect.objectContaining({ 268 | property: 'cc', 269 | constraints: expect.objectContaining({ 270 | isEmail: 'each value in cc must be an email' 271 | }) 272 | }) 273 | ])); 274 | }); 275 | }); 276 | }); 277 | 278 | describe('field bcc', () => { 279 | it('when request with empty bcc then return 400', () => { 280 | return request(app.getHttpServer()) 281 | .post('/api/emails') 282 | .send({ 283 | bcc: '' 284 | }) 285 | .set('accept', 'json') 286 | .expect(400) 287 | .then((ret) => { 288 | expect(ret.body.message).toEqual(expect.arrayContaining([ 289 | expect.objectContaining({ 290 | property: 'bcc', 291 | constraints: expect.objectContaining({ 292 | isArray: 'bcc must be an array' 293 | }) 294 | }) 295 | ])); 296 | }); 297 | }); 298 | 299 | it('when request without bcc then should not throw exception', () => { 300 | return request(app.getHttpServer()) 301 | .post('/api/emails') 302 | .send({}) 303 | .set('accept', 'json') 304 | .expect(400) 305 | .then((ret) => { 306 | expect(ret.body.message).not.toEqual(expect.arrayContaining([ 307 | expect.objectContaining({ 308 | property: 'bcc', 309 | constraints: expect.objectContaining({ 310 | isArray: 'bcc must be an array' 311 | }) 312 | }) 313 | ])); 314 | }); 315 | }); 316 | 317 | it('when request with cc is not an array then return 400', () => { 318 | return request(app.getHttpServer()) 319 | .post('/api/emails') 320 | .send({ 321 | bcc: 'test@gmail.com' 322 | }) 323 | .set('accept', 'json') 324 | .expect(400) 325 | .then((ret) => { 326 | expect(ret.body.message).toEqual(expect.arrayContaining([ 327 | expect.objectContaining({ 328 | property: 'bcc', 329 | constraints: expect.objectContaining({ 330 | isArray: 'bcc must be an array' 331 | }) 332 | }) 333 | ])); 334 | }); 335 | }); 336 | 337 | it('when request with cc has at least one invalid email address then return 400', () => { 338 | return request(app.getHttpServer()) 339 | .post('/api/emails') 340 | .send({ 341 | bcc: [ 342 | 'test@gmail.com', 343 | 'invalid' 344 | ] 345 | }) 346 | .set('accept', 'json') 347 | .expect(400) 348 | .then((ret) => { 349 | expect(ret.body.message).toEqual(expect.arrayContaining([ 350 | expect.objectContaining({ 351 | property: 'bcc', 352 | constraints: expect.objectContaining({ 353 | isEmail: 'each value in bcc must be an email' 354 | }) 355 | }) 356 | ])); 357 | }); 358 | }); 359 | }); 360 | }); 361 | 362 | it('when request to send email with correct info then insert new record in database', () => { 363 | const inp = { 364 | title: randomString(), 365 | content: randomString(), 366 | to: ['thisisvalidemail2019s@gmail.com'] 367 | }; 368 | const mailService = app.get(IMailService); 369 | return request(app.getHttpServer()) 370 | .post('/api/emails') 371 | .send({ ...inp }) 372 | .set('accept', 'json') 373 | .then(async (ret) => { 374 | const actualEmail = await mailService.getMailById(ret.body.data.id); 375 | expect(actualEmail).toMatchObject({ 376 | to: inp.to, 377 | cc: null, 378 | bcc: null, 379 | title: inp.title, 380 | content: inp.content, 381 | status: [ 382 | { type: EMailStatus.Init } 383 | ], 384 | sentOn: expect.any(Date) 385 | }); 386 | }); 387 | }); 388 | }); 389 | }); 390 | }); 391 | --------------------------------------------------------------------------------