Creando un Microservicio en Node.js para mandar emails

Tiempo de lectura: 9 minutos

Mucho se ha hablado de los microservicios en los últimos años. Hay gente que no puede vivir sin ellos, y otros que se mantienen reticentes a migrar de un diseño monolítico a una aplicación compuesta de partes móviles. Yo estoy al medio: ni muy ansioso por transformar todo mi software, pero con ganas de buscar aplicaciones útiles para usarlos (que conste que an algún momento intenté desarrollar un software lo más modular posible y fracasé estrepitósamente).

Hay muchas definiciones de Microservicios, pero a grandes rasgos podemos decir que nos referimos a una forma de diseñar software en la que el software no es una única entidad, sino que un conjunto de entidades interdepentiendes donde cada una de estas entidades realiza una tarea específica.

En este caso, para un software que estoy desarrollando, se me ocurrió probar el crear un microservicio que sirviera para enviar emails desde la aplicación principal. Así, cuando necesite enviar mails en otro sistema, la funcionalidad ya estará implementada y será fácil de consumir.

Nótese que este microservicio sólo enviará correos. No tendrá nada que ver con el diseño. Al final nombraré algunas herramientas que se pueden usar para esto.

¿Qué usaremos?

Este microservicio será escrito usando Node.js. Para ponerlo en ejecución, usaremos Docker. También necesitaremos una dirección de correo que podamos usar para enviar los emails.

¡A programar!

Si nunca han usado Docker, no se preocupen. Para la parte inicial sólo será un desarrollo normal con Node.js. Después veremos cómo empaquetar el desarrollo realizado en una imagen de Docker.

Asumiendo que tenemos Node.js instalado (si no lo tienen instalado pueden revisar aquí) vamos a crear un proyecto básico:

npm init

Esto va a crear un package.json con casi nada:

{
  "name": "mailer",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
  }
}

Nuestro objetivo principal es crear una aplicación que reciba una request http a un endpoint, con todo el contenido del email que queremos mandar y se encargue de enviarlo por nosotros. Para eso, vamos a instalar express y crear una aplicación básica. Para eso vamos a ejecutar en la consola:

npm install --save express body-parser

El módulo express de Node nos permite crear un servidor HTTP casi sin esfuerzo, y el módulo body-parser es un middleware que nos permite decirle a express que interprete como json los mensajes que reciba.

Ahora vamos a crear nuestro código principal. Como es una función específica, lo vamos a crear en un único archivo llamado index.js. Una buena práctica sería después organizarlo en más archivos.

Comencemos con este contenido en index.js:

const express = require('express')
const bodyParser = require('body-parser')

const app = express()
app.set('port', process.env.PORT || 3000)
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({extended: true}))

app.get('/', (req, res) => {
  res.json({
    message: 'Mailer service is ready.'
  })
})

const server = app.listen(app.get('port'), () => {
  const port = server.address().port
  console.log('Mailer service ejecutándose en http://localhost:' + port)
})

Si ejecutamos este código con ‘node index.js’, comenzará a correr nuestro servidor en el puerto 3000. En el objeto app definimos la aplicación de express. En las líneas siguientes la configuramos especificando el puerto y activando el middleware con ‘use()’. Luego definimos un endpoint básico que responderá a una llamada GET en la raiz de la ruta y devolverá un objeto JSON avisando que el servicio está funcionando bien.

Finalmente con app.listen, el servidor comienza su ejecución. La podemos detener con CTRL + C. Para probar que esté funcionando bien, desde una terminal podemos escribir:

curl http://localhost:300/

Y recibiríamos:

{
  message: 'Mailer service is ready.'
}

Ya tenemos la aplicación básica funcionando. Ahora vamos a agregar el módulo que nos va a permitir enviar correos desde nuestra aplicación. Este módulo es ‘nodemailer‘. Lo instalamos con:

npm install --save nodemailer

En index.js, añadiremos la importación de nodemailer y definiremos un objeto transportador. Cuando vayamos a enviar correos, este objeto ya configurado se encargará de realizar el envío.

const nodemailer = require('nodemailer')

const transporter = nodemailer.createTransport({
  host: 'URL DEL SERVIDOR DE CORREOS',
  port: 587 / 465,
  secure: true / false,
  auth: {
    user: 'USUARIO',
    pass: 'CONTRASEÑA'
  },
  tls:{
    ciphers:'SSLv3'
  }
})

Para enviar el correo, nodemailer usa el protocolo SMTP. Esto implica que debemos conocer ciertas variables sobre el servidor de correo que vamos a utilizar:

  • host: Una dirección del servidor de correo. Por ejemplo, en el caso de gmail el host es ‘smtp.gmail.com
  • port: Dependerá de si queremos usar la versión segura del servidor. Generalmente 587 es el puerto inseguro y 485 es el puerto seguro.
  • secure: Es donde especificamos si se quiere usar el protocolo seguro (TLS) o no
  • auth: Define los parámetros de autenticación. La forma más básica es con correo y contraseña, pero también se puede usar oauth2.
  • Los demás parámetros son opcionales y dependerán de la configuración específica del servidor que usemos. Pueden revisarlos aquí.

Como tener los parámetros en el código es inseguro (y antiestético) vamos a dejarlos como variables de entorno. Para esto vamos a usar el objeto de node ‘process.env’ y vamos a definir las variables en un archivo ‘.env’ en la raíz de nuestro proyecto. El código queda así:

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: parseInt(process.env.SMTP_PORT, 10) || 465,
  secure: (process.env.SMTP_SECURE === "true"),
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASSWORD
  },
  tls:{
    ciphers:'SSLv3'
  }
})

El archivo .env quedará así (las variables cambiarán para cada uno, estas son de ejemplo):

FROM_EMAIL=test@test.cl
FROM_NAME=Correos de Notificación
SMTP_HOST=mail.ejemplo.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=contacto@ejemplo.com
SMTP_PASSWORD=CLAVESUPERSECRETA

Se definieron dos variables extra: FROM_EMAIL y FROM_NAME. Estas se usarán para definir el nombre que se va a mostrar en nuestros correos cuando se envíen.

Pero ahora queda una pregunta. ¿Cómo pasamos estas variables al objeto ‘process.env’? La forma más fácil es usar el módulo ‘dotenv’.

npm install --save dotenv

Si importamos dotenv y lo configuramos, automáticamente va a detectar el archivo .env en la raíz de nuestro proyecto y va a importar esas variables (es importante no guardar ese archivo en el proyecto usando un .gitignore, ya que son variables sensibles).

const dotenv = require('dotenv')
dotenv.config()

Ahora podemos reescribir el código del transporte y usar las variables de entorno:

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: parseInt(process.env.SMTP_PORT, 10) || 465,
  secure: (process.env.SMTP_SECURE === "true"),
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASSWORD
  },
  tls:{
    ciphers:'SSLv3'
  }
})

Ahora lo que nos falta es el endpoint al que le vamos a pedir que envíe el correo que necesitamos. Este endpoint será ‘/send’ y le enviaremos una request tipo POST con los siguientes parámetros en el body.

  • fromName: Opcional. Nombre que queremos mostrar en el correo.
  • toEmail: Correo del destinatario.
  • subject: Asunto del correo.
  • textBody: Cuerpo en versión texto del correo.
  • htmlBody: Cuerpo en versión HTML del correo.

El cuerpo se manda en dos versiones para el caso en que el cliente de correo no sea capaz de leer el diseño HTML del correo (cosa poco frecuente en estos tiempos, pero que igual pasa).

Aquí está el código completo del endpoint que recibe estos parámetros y envía el correo. Definimos un objeto mailOptions para configurar el envío, y luego le pasamos esa información al transportador:

app.post('/send', (req, res) => {
  try {
    let fromName = (req.body.fromName || process.env.FROM_NAME)
    let toEmail = req.body.toEmail
    let subject = req.body.subject
    let textBody = req.body.textBody
    let htmlBody = req.body.htmlBody

    if (!subject || typeof subject !== 'string') {
      throw new TypeError('Subject was not defined')
      return
    }
    if (!toEmail || typeof toEmail !== 'string') {
      throw new TypeError('To Email was not defined')
      return
    }

    let mailOptions = {
      from: fromName + '<' + process.env.FROM_EMAIL + '>',
      to: toEmail,
      subject: subject,
      text: textBody,
      html: htmlBody
    }

    transporter.sendMail(mailOptions, (error, info) => {
      if (error) {
        throw new TypeError(error.message)
      }
      console.log('Mensaje %s enviado: %s', info.messageId, info.response)
      res.json({
        message: 'Mensaje enviado exitosamente'
      })
    })
  } catch (error) {
    res.status(500)
    res.json({
      error: error.message
    })
    console.error('Ocurrió un error:')
    console.error(error.message)
  }
})

Preparando para Docker

Con eso ya se podría decir que creamos un microservicio: tenemos una aplicación pequeña que realiza una función específica y que se puede comunicar con otras aplicaciones a través de un protocolo, en este caso HTTP.

Pero cada vez que usemos este microservicio tendremos que clonar el proyecto, instalar los módulos de Node (y todos sabemos que node_modules no es una carpeta pequeña) y ejecutar.

Podemos reducir esos pasos si convertimos el proyecto en una imagen de Docker que después podamos reutilizar sin tener que andar paseando el código por todos lados.

¿Cómo se define una imagen de Docker?

En docker se usan dos conceptos principales: imágenes y contenedores. La imagen es un ‘paquete’ que incluye todo el código y sus dependencias. Es un entorno aislado donde el código ‘cree’ que está incluso en un sistema operativo específico. El contenedor, es una instancia en memoria de la imagen. Es cuando realmente se está ejecutando el proceso. Pueden haber varios contenedores en ejecución usando una misma imagen.

Para definir la imagen (y luego poder convertirla en contenedor cada vez que necesitemos) tenemos que definir un Dockerfile. Este archivo es una especificación de la construcción de esta imagen. En el Dockerfile incluso podemos referenciar a otras imágenes y luego copiar nuestros archivos dentro del entorno que se va creando.

En este caso, yo parto de una imagen que ya tiene el ejecutable de Node instalado. Existen distintas imágenes oficiales de Node, pero yo para estos casos ocupo la imagen ‘alpine’. Esta imagen se basa en la distribución ‘Alpine Linux’ que sólo contiene lo mínimo para que funcione el entorno de ejecución. Otras imágenes incluyen módulos que no son necesarios y que harían nuestra imagen más pesada.

Luego de importar la imagen base, copio los archivos package.json y package-lock.json para instalar las dependencias. Después copio el código a una carpeta específica. Mediante la instrucción EXPOSE se documenta el puerto en el que escucha el ejecutable. De esta manera, al ejecutar la imagen como contenedor, podemos saber qué puerto tenemos que abrir hacia nuestra máquina. Al final se define con CMD el comando que se ejecuta para lanzar el proceso principal.

El Dockerfile finalmente queda así:

FROM node:alpine

RUN mkdir -p /opt/app

WORKDIR /opt/app

COPY package.json .
COPY package-lock.json .
RUN npm install --quiet

COPY . .

EXPOSE 3000

CMD node index.js

Para construir la imagen de nuestro proceso, estando en la misma carpeta de nuestro proyecto llamamos el comando ‘docker build’. Con el parámetro -t le podemos dar un nombre a nuestra imagen, así que la llamaremos ‘mailer’.

docker build -t mailer .

Docker construirá la imagen en una serie de etapas y al final le asignará automáticamente la etiqueta :latest. Se puede especificar manualmente esta etiqueta en caso de que queramos "versionar" la imagen.

Sending build context to Docker daemon  2.557MB
Step 1/9 : FROM node:alpine
 ---> 953c516e1466
Step 2/9 : RUN mkdir -p /opt/app
 ---> Using cache
 ---> 3767e29f83bf
Step 3/9 : WORKDIR /opt/app
 ---> Using cache
 ---> fe503bc0d076
Step 4/9 : COPY package.json .
 ---> ca429f5d1a54
Step 5/9 : COPY package-lock.json .
 ---> 1a6f35049a1f
Step 6/9 : RUN npm install --quiet
 ---> Running in e5dfb96038d2
npm WARN mailer@1.0.0 No description
npm WARN mailer@1.0.0 No repository field.

added 52 packages from 39 contributors and audited 160 packages in 1.807s
found 0 vulnerabilities

Removing intermediate container e5dfb96038d2
 ---> 7f5371221df8
Step 7/9 : COPY . .
 ---> 659f95e1d42f
Step 8/9 : EXPOSE 3000
 ---> Running in cc5925859a2c
Removing intermediate container cc5925859a2c
 ---> b1c9f188ae1d
Step 9/9 : CMD node index.js
 ---> Running in f9c12431cf74
Removing intermediate container f9c12431cf74
 ---> 289a92e3675f
Successfully built 289a92e3675f
Successfully tagged mailer:latest

Si ejecutammos ‘docker image ls’ podremos ver la imagen que recién creamos:

REPOSITORY                  TAG                 IMAGE ID            CREATED             SIZE
mailer                      latest              289a92e3675f        3 minutes ago       81.2MB

Para ejecutar esta imagen como contenedor usamos ‘docker run’. La aplicación internamente está ejecutándose en el puerto 3000, pero ese no es el mismo puerto 3000 de la máquina en la que estamos. Al ejecutar el contenedor, podemos asociar un puerto de nuestra máquina al puerto que queremos dentro del contenedor usando –publish. Es como si abriéramos una pequeña puerta al interior del contenedor.

También debemos pasar las variables de entorno que configuran nuestro microservicio, o si no arrojará errores. Para eso, podemos especificar las variables con –env o -e

Ejecutamos el contenedor con:

docker run --name mailer_app --publish 4567:3000 \
--env FROM_EMAIL='ejemplo' \
--env FROM_NAME='ejemplo' \
--env SMTP_HOST='ejemplo' \
--env SMTP_PORT='ejemplo' \
--env SMTP_SECURE='ejemplo' \
--env SMTP_USER='ejemplo' \
--env SMTP_PASSWORD='ejemplo' \ 
mailer

Y listo. Ahora el contenedor está ejecutándose en nuestra máquina y con variables de entorno específicas. Le asignamos un nombre y el puerto 4567. Ahora si enviamos mensajes a ese puerto, docker los redireccionará al puerto 3000 del proceso dentro del contenedor.

curl localhost:4567
// Retorna: {"message":"Mailer service is ready."}

¿Ahora qué?

Ya creamos un microservicio y lo convertimos en una imagen que puede ser reutilizada dónde lo necesitemos.

Integrar

Ahora esta imagen la podemos integrar con otras aplicaciones que desarrollemos. Para esto, si usamos docker, podemos crear una red. No voy a entrar en detalles de cómo crear una red de docker, pero les recomiendo que lean este artículo si quieren profundizar. A grandes rasgos podemos decir que con una red se logra que un conjunto de contenedores puedan verse mutuamente como si estuvieran en una red local. Dentro de esta red todos los contenedores serían visibles entre sí, por lo que no habría que publicar un puerto de la máquina anfitriona para usar el servicio. En mi caso, tengo un proyecto que está en la misma red del contenedor de mailer y lo puede ver con un nombre asignado.

Para comunicarme desde otro proyecto, definí un servicio en js para poder enviar mensajes a este microservicio que envía correos:

const axios = require('axios')

const send = async ({
  fromName,
  toEmail,
  subject,
  textBody,
  htmlBody
}) => {
  try {
    let result = await axios.post(
      'http://mailer_service:3000/send', {
        fromName: fromName,
        toEmail: toEmail,
        subject: subject,
        textBody: textBody,
        htmlBody: htmlBody
      }
    )
    return result.data
  } catch (error) {
    throw new TypeError(error.message)
  }
}

module.exports.send = send

En caso de que quieran crear fácilmente una red con varios servicios integrados, lo más fácil es usar docker-compose. Con docker-compose podemos definir un archivo docker-compose.yml que especifica todos los servicios que vamos a lanzar y sus parámetros, además que permite crear automáticamente la red y hacer que estos servicios sean visibles entre sí. Pueden ver un tutorial de docker-compose acá.

Publicar

Cuando creamos una imagen, podemos publicarla en un ‘registro’. Existen varios registros e incluso uno podría tener un registro privado de imágenes. El más común de todos es Docker Hub, donde pueden encontrar una serie de imágenes de distintas aplicaciones que pueden ser integradas con sus proyectos. Acá hay un tutorial super bueno que seguí yo para publicar esta imagen y luego poder usarla en mis otros proyectos. Resumiendo, los pasos que yo seguí para publicar la imagen fueron:

// Primero crearse una cuenta en https://hub.docker.com/signup y crear un repositorio
docker login
docker build -t mailer .
docker tag mailer:latest ferativ/mailer:latest
docker push ferativ/mailer:latest

Ahora la imagen quedó publicada en: https://hub.docker.com/r/ferativ/mailer

(Siéntanse libres de usarla)

Código

La idea original del microservicio para enviar correos no es mía. La base de este proyecto viene de este repositorio.

Mi versión, incluyendo el Dockerfile, la pueden encontrar acá.

Conclusión

Crear microservicios puede sonar como algo intimidante al comienzo, pero comenzar no es algo tan terrible. Al final es una cosa conceptual, principalmente refiriéndose a crear software para una función puntual y poder reutilizarlo y escalarlo independientemente. Acá vimos una forma de hacerlo, pero hay muchas otras formas y frameworks que se pueden usar. Yo recién estoy comenzando en esto y tampoco puedo hablar mucho del tema, pero este fue un caso de uso puntual que vi en un proyecto y quise compartirlo.

Si cometí algún error o algo quedó mal explicado, no duden en dejar un comentario. También si quieren recibir más artículos como este en su correo, pueden suscribirse al Newsletter de la comunidad 🙂

También te podría gustar...