From 45ebb742b54fcb91a519c0c0f77be1b5da1ec6c2 Mon Sep 17 00:00:00 2001
From: FORESTIER Fabien <fabien.forestier@soprasteria.com>
Date: Tue, 18 Jun 2019 15:27:15 +0200
Subject: [PATCH] Remove rabbitMQ from the stack and directly send mails to
 SMTP server

---
 .gitlab-ci.yml                      | 10 ++----
 docker-compose.yml                  | 24 ++-----------
 package-lock.json                   |  7 +++-
 package.json                        |  2 +-
 src/configuration/config.service.ts |  7 ++--
 src/configuration/config.ts         | 16 ++++++---
 src/email/email.controller.ts       | 13 +++----
 src/email/email.service.ts          | 55 ++++++++---------------------
 src/health/health-check.service.ts  | 18 ++--------
 src/health/health.module.ts         |  4 +++
 src/health/smtp.healthIndicator.ts  | 34 ++++++++++++++++++
 src/helpers.ts                      | 14 ++++++++
 template.env                        |  8 ++---
 13 files changed, 104 insertions(+), 108 deletions(-)
 create mode 100644 src/health/smtp.healthIndicator.ts
 create mode 100644 src/helpers.ts

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index aa83843..2280fe0 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -4,6 +4,8 @@ stages:
 
 variables:
   USER_SUPPORT_MAILBOX: alpha-test@erasme.org
+  SMTP_HOST: mail0.erasme.org
+  SMTP_PORT: 25
   # ADDITIONAL_FEEDBACK_EMAILS: 
 
 build_development:
@@ -13,8 +15,6 @@ build_development:
   script:
     - export TAG=dev
     - export MAIL_SERVICE_BIND_PORT=3000
-    - export RABBITMQ_LISTENING_PORT=5672
-    - export RABBITMQ_GUI_PORT=15672
     - docker-compose build 
     - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
     - docker-compose push
@@ -28,8 +28,6 @@ build_release:
   script:
     - export TAG=$(echo $CI_COMMIT_TAG | sed 's/v//g')
     - export MAIL_SERVICE_BIND_PORT=3000
-    - export RABBITMQ_LISTENING_PORT=5672
-    - export RABBITMQ_GUI_PORT=15672
     - docker-compose build
     - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
     - docker-compose push
@@ -41,8 +39,6 @@ deploy_development:
   script:
     - export TAG=dev
     - export MAIL_SERVICE_BIND_PORT=3001
-    - export RABBITMQ_LISTENING_PORT=5672
-    - export RABBITMQ_GUI_PORT=15672
     - export MAIL_SUBJECT_PREFIX=alpha
     - export NO_REPLY_MAIL_ADDRESS=no-reply@erasme.org
     - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
@@ -59,8 +55,6 @@ deploy_staging:
   script:
     - export TAG=staging
     - export MAIL_SERVICE_BIND_PORT=3101
-    - export RABBITMQ_LISTENING_PORT=5673
-    - export RABBITMQ_GUI_PORT=15673
     - export MAIL_SUBJECT_PREFIX=alpha
     - export NO_REPLY_MAIL_ADDRESS=no-reply@erasme.org
     - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
diff --git a/docker-compose.yml b/docker-compose.yml
index 8dba4d7..afa59fd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,11 +8,8 @@ services:
     ports:
       - ${MAIL_SERVICE_BIND_PORT}:3000
     environment:
-      - RABBITMQ_USER=rabbitmq-user
-      - RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD}
-      - RABBITMQ_HOST=rabbitmq
-      - RABBITMQ_LISTENING_PORT=5672
-      - MAILER_QUEUE=portail-data-send-email
+      - SMTP_HOST=${SMTP_HOST}
+      - SMTP_PORT=${SMTP_PORT}
       - MAIL_SUBJECT_PREFIX=${MAIL_SUBJECT_PREFIX}
       - USER_SUPPORT_MAILBOX=${USER_SUPPORT_MAILBOX}
       - NO_REPLY_MAIL_ADDRESS=${NO_REPLY_MAIL_ADDRESS}
@@ -20,19 +17,4 @@ services:
       - GROUP_HEADER=x-consumer-groups
       - EMAIL_WRITER_GROUP_NAME=email-writer
       - IMAGE_HOST=https://minio.alpha.grandlyon.com/email-template-assets
-    restart: unless-stopped
-
-  rabbitmq:
-    image: rabbitmq:3-management-alpine
-    ports:
-      - ${RABBITMQ_LISTENING_PORT}:5672 # standard port for communication
-      - ${RABBITMQ_GUI_PORT}:15672 # graphic interface
-    environment:
-      - RABBITMQ_DEFAULT_USER=rabbitmq-user
-      - RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
-    volumes:
-      - rabbitmq-data-volume:/var/lib/rabbitmq
-    restart: unless-stopped
-
-volumes:
-  rabbitmq-data-volume:
+    restart: unless-stopped
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index a774298..9497820 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "service-email",
-  "version": "1.2.0",
+  "version": "1.2.1",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -7787,6 +7787,11 @@
         "which": "^1.3.0"
       }
     },
+    "nodemailer": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.2.1.tgz",
+      "integrity": "sha512-TagB7iuIi9uyNgHExo8lUDq3VK5/B0BpbkcjIgNvxbtVrjNqq0DwAOTuzALPVkK76kMhTSzIgHqg8X1uklVs6g=="
+    },
     "nodemon": {
       "version": "1.18.9",
       "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-1.18.9.tgz",
diff --git a/package.json b/package.json
index 6d970e1..7bbeb3d 100644
--- a/package.json
+++ b/package.json
@@ -25,11 +25,11 @@
     "@nestjs/core": "^5.1.0",
     "@nestjs/swagger": "^2.5.1",
     "@nestjs/terminus": "^5.5.0",
-    "amqplib": "^0.5.2",
     "class-transformer": "^0.1.9",
     "class-validator": "^0.9.1",
     "moment": "^2.24.0",
     "moment-timezone": "^0.5.23",
+    "nodemailer": "^6.2.1",
     "reflect-metadata": "^0.1.12",
     "rxjs": "^6.2.2",
     "serve-favicon": "^2.5.0",
diff --git a/src/configuration/config.service.ts b/src/configuration/config.service.ts
index 67c9ba1..492dbb7 100644
--- a/src/configuration/config.service.ts
+++ b/src/configuration/config.service.ts
@@ -7,11 +7,8 @@ export class ConfigService {
   constructor() {
 
     // Initializing conf with values from var env
-    this._config.rabbitMQ.user = process.env.RABBITMQ_USER;
-    this._config.rabbitMQ.password = process.env.RABBITMQ_PASSWORD;
-    this._config.rabbitMQ.host = process.env.RABBITMQ_HOST;
-    this._config.rabbitMQ.port = process.env.RABBITMQ_LISTENING_PORT;
-    this._config.mailerQueue = process.env.MAILER_QUEUE;
+    this._config.smtpConfig.host = process.env.SMTP_HOST;
+    this._config.smtpConfig.port = process.env.SMTP_PORT;
     this._config.mailSubjectPrefix = process.env.MAIL_SUBJECT_PREFIX;
     this._config.userSupportMailbox = process.env.USER_SUPPORT_MAILBOX;
     this._config.noReplyMailAddress = process.env.NO_REPLY_MAIL_ADDRESS;
diff --git a/src/configuration/config.ts b/src/configuration/config.ts
index 07512a4..69f00c4 100644
--- a/src/configuration/config.ts
+++ b/src/configuration/config.ts
@@ -1,11 +1,17 @@
 export const config = {
-  rabbitMQ: {
-    user: null,
-    password: null,
-    host: null,
+  smtpConfig: {
+    host: '',
     port: null,
+    secure: false,
+    tls: {
+      // do not fail on invalid certs
+      rejectUnauthorized: false,
+    },
+    // auth: {
+    //   user: process.env.SMTP_USER,
+    //   pass: process.env.SMTP_PASS
+    // }
   },
-  mailerQueue: '',
   userSupportMailbox: '',
   imageHost: '',
   groupNames: {
diff --git a/src/email/email.controller.ts b/src/email/email.controller.ts
index dec2cd7..079cf5b 100644
--- a/src/email/email.controller.ts
+++ b/src/email/email.controller.ts
@@ -3,6 +3,7 @@ import { ContactForm, EmailWithoutFrom, FeedbackForm } from './email';
 import { EmailService } from './email.service';
 import { ApiBadRequestResponse, ApiOkResponse, ApiOperation, ApiInternalServerErrorResponse } from '@nestjs/swagger';
 import { Groups } from '../decorators/groups.decorators';
+import { handleError } from '../helpers';
 
 @Controller()
 export class EmailController {
@@ -16,13 +17,13 @@ export class EmailController {
   @ApiOperation({ title: 'Send email to admin (emails defined as var env of the project, see docker-compose.yml file) and recap email to user email.' })
   @ApiOkResponse({ description: 'OK' })
   @ApiBadRequestResponse({ description: 'Missing fields' })
-  @ApiInternalServerErrorResponse({ description: 'Internal error, this is probably a rabbitMQ related error (unreachable service...)' })
+  @ApiInternalServerErrorResponse({ description: 'Internal error (unreachable SMTP server...)' })
   @HttpCode(200)
   async create(@Body() contactForm: ContactForm) {
     try {
       return await this.emailService.sendContactEmails(contactForm);
     } catch (error) {
-      throw new InternalServerErrorException();
+      handleError(error, new InternalServerErrorException());
     }
   }
 
@@ -31,13 +32,13 @@ export class EmailController {
   @ApiOperation({ title: 'Send email to admin with the user feedback' })
   @ApiOkResponse({ description: 'OK' })
   @ApiBadRequestResponse({ description: 'Missing fields' })
-  @ApiInternalServerErrorResponse({ description: 'Internal error, this is probably a rabbitMQ related error (unreachable service...)' })
+  @ApiInternalServerErrorResponse({ description: 'Internal error (unreachable SMTP server...)' })
   @HttpCode(200)
   async sendFeedback(@Body() feedbackForm: FeedbackForm, @Req() req) {
     try {
       return await this.emailService.sendFeedback(feedbackForm, req.headers['user-agent']);
     } catch (error) {
-      throw new InternalServerErrorException();
+      handleError(error, new InternalServerErrorException());
     }
   }
 
@@ -46,13 +47,13 @@ export class EmailController {
   @ApiOperation({ title: 'Send email.' })
   @ApiOkResponse({ description: 'OK' })
   @ApiBadRequestResponse({ description: 'Missing fields' })
-  @ApiInternalServerErrorResponse({ description: 'Internal error, this is probably a rabbitMQ related error (unreachable service...)' })
+  @ApiInternalServerErrorResponse({ description: 'Internal error (unreachable SMTP server...)' })
   @HttpCode(200)
   async createEmail(@Body() email: EmailWithoutFrom) {
     try {
       return await this.emailService.send(email);
     } catch (error) {
-      throw new InternalServerErrorException();
+      handleError(error, new InternalServerErrorException());
     }
   }
 }
diff --git a/src/email/email.service.ts b/src/email/email.service.ts
index 366b905..d4dfa83 100644
--- a/src/email/email.service.ts
+++ b/src/email/email.service.ts
@@ -1,10 +1,10 @@
 import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
-import * as amqp from 'amqplib';
 import { ContactForm, Email, EmailWithoutFrom, FeedbackForm } from './email';
 import { ConfigService } from '../configuration/config.service';
 import { buildContactAdminEmail, buildContactUserEmail } from '../email-templates/contact';
 import { buildFeedbackEmail } from '../email-templates/feedback';
 import * as useragent from 'useragent';
+import * as Nodemailer from 'nodemailer';
 import moment = require('moment-timezone');
 moment.tz.setDefault('Europe/Paris');
 
@@ -12,7 +12,7 @@ moment.tz.setDefault('Europe/Paris');
 export class EmailService {
 
   private logger: Logger;
-  config: any = {};
+  private config;
 
   constructor(private configService: ConfigService) {
     this.logger = new Logger(EmailService.name);
@@ -95,11 +95,6 @@ export class EmailService {
   async send(emailInfo: EmailWithoutFrom) {
     this.logger.log('Entering function', `${EmailService.name} - ${this.send.name}`);
 
-    let conn, ch;
-    // tslint:disable-next-line:max-line-length
-    const rabbitmqUrl = `amqp://${this.config.rabbitMQ.user}:${this.config.rabbitMQ.password}@${this.config.rabbitMQ.host}:${this.config.rabbitMQ.port}`;
-    const mailerQueue = this.config.mailerQueue;
-
     let email = new Email();
     email.from = this.config.noReplyMailAddress;
     email = Object.assign(email, emailInfo);
@@ -108,44 +103,24 @@ export class EmailService {
       email.subject = `[${this.config.mailSubjectPrefix}] ${email.subject}`;
     }
 
-    // Connect to rabbitmq
-    try {
-      conn = await amqp.connect(rabbitmqUrl);
-    } catch (error) {
-      this.logger.error('Error connecting to RabbitMQ', error, `${EmailService.name} - ${this.send.name}`);
-      throw new InternalServerErrorException('Could not connect to rabbitMQ.');
-    }
-
-    try {
-      // Create a communication channel
-      ch = await conn.createChannel();
-    } catch (error) {
-      this.logger.error('Error creating channel', error, `${EmailService.name} - ${this.send.name}`);
-      throw new InternalServerErrorException('Could not create channel.');
-    }
+    const transporter = Nodemailer.createTransport(this.config.smtpConfig);
 
-    // Stringify and bufferise message
-    const buffer = Buffer.from(JSON.stringify(email));
+    await transporter.verify().catch((error) => {
+      this.logger.error('SMTP connection failed.', error, `${EmailService.name} - ${this.send.name}`);
+      throw new InternalServerErrorException({ error, message: 'SMTP connection failed.' });
+    });
 
-    try {
-      await ch.assertQueue(mailerQueue, { durable: true });
-    } catch (error) {
-      this.logger.error('Error asserting queue', error, `${EmailService.name} - ${this.send.name}`);
-      throw new InternalServerErrorException('Could not assert queue.');
-    }
+    this.logger.log('SMTP Server is ready to receive messages', `${EmailService.name} - ${this.send.name}`);
 
-    try {
-      await ch.sendToQueue(mailerQueue, buffer, { persistent: true });
-    } catch (error) {
-      this.logger.error('Error sending to queue', error, `${EmailService.name} - ${this.send.name}`);
-      throw new InternalServerErrorException('Could not send to queue.');
-    }
+    await transporter.sendMail(email).catch((error) => {
+      this.logger.error('Couldn\'t send email.', error, `${EmailService.name} - ${this.send.name}`);
+      transporter.close();
+      throw new InternalServerErrorException({ error, message: 'Couldn\'t send email.' });
+    });
 
-    this.logger.log(
-      `Sent to queue ${mailerQueue},{ from: ${email.from}, to: ${email.to}, subject: ${email.subject}}`, `${EmailService.name} - ${this.send.name}`,
-    );
+    transporter.close();
+    this.logger.log('Email sent to SMTP server', `${EmailService.name} - ${this.send.name}`);
 
-    setTimeout(() => { conn.close(); }, 500);
     return;
   }
 
diff --git a/src/health/health-check.service.ts b/src/health/health-check.service.ts
index 556584d..d50b0fc 100644
--- a/src/health/health-check.service.ts
+++ b/src/health/health-check.service.ts
@@ -2,33 +2,21 @@ import {
   TerminusEndpoint,
   TerminusOptionsFactory,
   TerminusModuleOptions,
-  MicroserviceHealthIndicator,
 } from '@nestjs/terminus';
 import { Injectable } from '@nestjs/common';
-import { Transport } from '@nestjs/microservices';
-import { ConfigService } from '../configuration/config.service';
+import { SmtpHealthIndicator } from './smtp.healthIndicator';
 
 @Injectable()
 export class HealthCheckService implements TerminusOptionsFactory {
   constructor(
-    private configService: ConfigService,
-    private readonly _microserviceHealthIndicator: MicroserviceHealthIndicator,
+    private readonly _smtpHealthIndicator: SmtpHealthIndicator,
   ) { }
 
   createTerminusOptions(): TerminusModuleOptions {
-    const config = this.configService.config;
     const healthEndpoint: TerminusEndpoint = {
       url: '/health',
       healthIndicators: [
-        async () => this._microserviceHealthIndicator.pingCheck('rabbitMQ', {
-          transport: Transport.RMQ,
-          options: {
-            urls: [
-              `amqp://${config.rabbitMQ.user}:${config.rabbitMQ.password}@${config.rabbitMQ.host}:${config.rabbitMQ.port}`,
-            ],
-            queue: config.mailerQueue,
-          },
-        }),
+        async () => this._smtpHealthIndicator.checkSMTPConnection(),
       ],
     };
     return {
diff --git a/src/health/health.module.ts b/src/health/health.module.ts
index 65e1112..e5209bd 100644
--- a/src/health/health.module.ts
+++ b/src/health/health.module.ts
@@ -1,12 +1,16 @@
 import { Module } from '@nestjs/common';
 import { TerminusModule } from '@nestjs/terminus';
 import { HealthCheckService } from './health-check.service';
+import { SmtpHealthIndicator } from './smtp.healthIndicator';
 
 @Module({
   imports: [
     TerminusModule.forRootAsync({
+      imports: [HealthModule],
       useClass: HealthCheckService,
     }),
   ],
+  providers: [SmtpHealthIndicator],
+  exports: [SmtpHealthIndicator],
 })
 export class HealthModule { }
\ No newline at end of file
diff --git a/src/health/smtp.healthIndicator.ts b/src/health/smtp.healthIndicator.ts
new file mode 100644
index 0000000..d7f7199
--- /dev/null
+++ b/src/health/smtp.healthIndicator.ts
@@ -0,0 +1,34 @@
+import { HealthIndicatorResult } from '@nestjs/terminus';
+import { Injectable, Logger } from '@nestjs/common';
+import { HealthCheckError } from '@godaddy/terminus';
+import { HealthIndicator } from '@nestjs/terminus/dist/health-indicators/abstract/health-indicator';
+import * as Nodemailer from 'nodemailer';
+import { ConfigService } from '../configuration/config.service';
+
+@Injectable()
+export class SmtpHealthIndicator extends HealthIndicator {
+
+  private logger: Logger;
+
+  constructor(
+    private configService: ConfigService,
+  ) {
+    super();
+    this.logger = new Logger(SmtpHealthIndicator.name);
+  }
+
+  async checkSMTPConnection(): Promise<HealthIndicatorResult> {
+
+    const transporter = Nodemailer.createTransport(this.configService.config.smtpConfig);
+
+    await transporter.verify().catch((error) => {
+      this.logger.error('SMTP connection failed.', error, `${SmtpHealthIndicator.name} - ${this.checkSMTPConnection.name}`);
+      transporter.close();
+      throw new HealthCheckError('SMTP connection failed', error);
+    });
+
+    transporter.close();
+    return this.getStatus('smtpServer', true);
+
+  }
+}
\ No newline at end of file
diff --git a/src/helpers.ts b/src/helpers.ts
new file mode 100644
index 0000000..7729554
--- /dev/null
+++ b/src/helpers.ts
@@ -0,0 +1,14 @@
+import { HttpException } from '@nestjs/common';
+
+export function handleError(err, defaultErr) {
+  if (err instanceof HttpException) {
+    throw err;
+  } else {
+    if (err && err.error) {
+      const error = JSON.parse(err.error);
+      throw new HttpException(error.message, err.statusCode);
+    } else {
+      throw defaultErr;
+    }
+  }
+}
\ No newline at end of file
diff --git a/template.env b/template.env
index eb388f2..64c25e5 100644
--- a/template.env
+++ b/template.env
@@ -1,11 +1,7 @@
 TAG=<version of the service to deploy>
 
-RABBITMQ_USER=<rabbitMQ user>
-RABBITMQ_PASSWORD=<rabbitMQ password>
-RABBITMQ_HOST=<rabbitMQ host>
-RABBITMQ_LISTENING_PORT=<rabiitMQ listening port, default 5672>
-RABBITMQ_GUI_PORT=<RabbitMQ interface, default 15672>
-MAILER_QUEUE=<sueue where to write the emails to be sent>
+SMTP_HOST=<host of the SMTP server>
+SMTP_PORT=<port of the SMTP server>
 
 MAIL_SERVICE_BIND_PORT=<listening port of the service>
 MAIL_SUBJECT_PREFIX=<prefix used in email subject>
-- 
GitLab