MailCatcher e envio de e-mails
Agora chegou a hora de implementarmos um sistema de envio de e-mails, e mais do que isso, subir uma infraestrutura interna de e-mails, com um servidor chamado Mailcatcher, que consegue falar o protocolo SMTP, e disponibiliza uma "mailbox" online para consultarmos esses e-mails.
Instalando o Mailcatcher
A instalação dele é muito simples. Basta adicionarmos a declaração desse container no nosso arquivo de compose.yaml, para subir junto com o Banco de Dados:
services:
database:
container_name: postgres-dev
image: postgres:17.0
env_file:
- ../.env.development
ports:
- "5432:5432"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
mailcacher:
container_name: mailcacher-dev
image: sj26/mailcatcher
ports:
- "1025:1025"
- "1080:1080"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
Success
E pronto, agora ao dar o comando npm run dev, esse container já será inicializado! É possível acessar a "mailbox" no endereço http://localhost:1080
Tip
Para testar o envio de e-mails, é possível fazer um telnet na porta 1025 do Mailcatcher:
telnet localhost 1025
Trying ::1...
Connected to localhost.
Escape character is '^]'.
220 EventMachine SMTP Server
E agora esses comandos farão nos comunicarmos via SMTP:
HELO
250 Ok EventMachine SMTP Server
MAIL FROM:<bruno.nonogaki@gmail.com>
250 Ok
RCPT TO:<brunono@gmail.com>
250 Ok
DATA
354 Send it
Subject: Teste por Telnet
Corpo do email.
.
250 Message accepted
quit
221 Ok
Connection closed by foreign host.
Agora, ao abrir a nossa Mailbox, o e-mail estará lá. Ele não vai enviar o e-mail de verdade, ele só está capturando o e-mail para podermos usar isso nos nossos testes!
Instalando o nodemailer e criando o módulo email.js
O nodemailer é um módulo do NPM para abstrair o envio de e-mails, que é basicamente a comunicação que simulamos agora via telnet. Vamos adicioná-lo como dependência do projeto:
Agora vamos construir o módulo email.js na pasta infra, similar ao que fizemos por exemplo com o módulo do database.js:
import nodemailer from "nodemailer";
async function send() {}
const email = {
send,
};
export default email;
Testando envio de e-mails
Por enquanto nossa função send está vazia, e vamos começar a codificá-la. Mas antes, vamos entender como vamos fazer para testar isso.
A real utilização do módulo email.js virá mais pra frente, quando o usuário tiver que confirmar o seu cadastro, por exemplo. Mas ainda não temos isso implementado. Uma solução seria escrever um código temporário para ficar chamando esse módulo, mas como já temos os testes automatizados meio que fazendo isso, podemos já começar a escrever direto em um teste! Para isso, dentro da pasta ./tests/integration, vamos criar uma pasta nova chamada infra, e dentro dela o arquivo email.test.js:
import email from "infra/email.js";
describe("Test infra/email.js", () => {
test("send()", async () => {
await email.send();
});
});
Pronto, a estrutura está montada. Ao fazer um npm run test:watch --email, ficaremos executando o método send() do módulo que criamos. Por enquanto sem assertions e nem nada, só queremos chamar o método e ver se conseguimos enviar um e-mail pelos testes. Agora vamos programar isso no método. É só criar um transporter, usando as variáveis do .env, e depois invocar o método sendMail, passando um objeto com as configurações do e-mail:
import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_SMTP_HOST,
port: process.env.EMAIL_SMTP_PORT,
auth: {
user: process.env.EMAIL_SMTP_USER,
pass: process.env.EMAIL_SMTP_PASSWORD,
secure: process.env.NODE_ENV === "production" ? true : false,
},
});
async function send(mailOptions) {
await transporter.sendMail(mailOptions);
}
const email = {
send,
};
export default email;
No nosso .env.development, adicionaremos isso:
EMAIL_SMTP_HOST=0.0.0.0
EMAIL_SMTP_PORT=1025
EMAIL_SMTP_USER=
EMAIL_SMTP_PASSWORD=
E por fim, vamos passar o objeto de configuração do email para o método send():
import email from "infra/email.js";
describe("Test infra/email.js", () => {
test("send()", async () => {
await email.send({
from: "MeuBonsai <contato@meubonsai.app>",
to: "contato@brunononogaki.com",
subject: "Teste de email",
text: "Text de corpo",
});
});
});
Success
Sucesso, agora já estamos conseguindo fazer o envio do e-mail pelo mailcatcher!
Finalizando o teste de integração
Agora vamos acrescentar algumas coisas no nosso teste, como limpar a caixa de entrada a cada início de teste (como fazemos com a Database), e uma função para pegar o ultimo e-mail da caixa, e assim podermos fazer os assertions. O mailcatcher disponibiliza uma interface via API para executarmos essas operações, então vamos adicionar mais duas variáveis no nosso .env:
Agora vamos criar a função deleteAllEmails() no orchestrator.js:
async function deleteAllEmails() {
await fetch(
`http://${process.env.EMAIL_HTTP_HOST}:${process.env.EMAIL_HTTP_PORT}/messages`,
{
method: "DELETE",
}
);
}
E agora chamar essa função no beforeAll dos testes de e-mail:
import email from "infra/email.js";
import orchestrator from "tests/orchestrator";
beforeAll(async () => {
await orchestrator.deleteAllEmails();
});
describe("Test infra/email.js", () => {
test("send()", async () => {
await email.send({
from: "MeuBonsai <contato@meubonsai.app>",
to: "contato@brunononogaki.com",
subject: "Teste de email",
text: "Text de corpo",
// html:
});
});
});
Success
Agora toda vez que iniciarmos os testes, a caixa de entrada será limpada!
Vamos agora implementar a função getLastEmail():
async function getLastEmail() {
// Collect all messages in the mailbox
const emailListResponse = await fetch(
`http://${process.env.EMAIL_HTTP_HOST}:${process.env.EMAIL_HTTP_PORT}/messages`
);
const emailListBody = await emailListResponse.json();
// Get the last item
const lastEmailItem = emailListBody.pop();
// Get the text of this email
const emailTextResponse = await fetch(
`http://${process.env.EMAIL_HTTP_HOST}:${process.env.EMAIL_HTTP_PORT}/messages/${lastEmailItem.id}.plain`
);
// Add the email text in the response payload
const emailTextBody = await emailTextResponse.text();
lastEmailItem.text = emailTextBody;
return lastEmailItem;
}
Tip
Pela API do mailcatcher, precisamos primeiramente coletar a lista de emails da fila, mas o retorno dessa API não vai trazer o texto do e-mail. Então depois de identificarmos o Id o último e-mail (mais recente), enviamos um outro GET, mas adicionando o ID do email na URL. Essa request sim nos trará o corpo do email. E outro detalhe é que a lista de e-mails retornada na nossa primeira requisição é ordenada do mais velho para o mais recente. Por isso utilizamos o método pop(), para trazer o último elemento da lista
Agora sim podemos invocar esse método no nosso teste, e fazer um assertion do conteúdo do e-mail. Nesse teste, vamos fazer o envio de dois e-mails, para garantir que estamos pegando sempre o último (mais recente):
import email from "infra/email.js";
import orchestrator from "tests/orchestrator";
beforeAll(async () => {
await orchestrator.deleteAllEmails();
});
describe("Test infra/email.js", () => {
test("send()", async () => {
await email.send({
from: "MeuBonsai <contato@meubonsai.app>",
to: "contato@brunononogaki.com",
subject: "Teste de email",
text: "Text de corpo",
});
await email.send({
from: "MeuBonsai <contato@meubonsai.app>",
to: "contato@brunononogaki.com",
subject: "Último email enviado",
text: "Text de corpo",
});
const lastEmail = await orchestrator.getLastEmail();
expect(lastEmail.sender).toBe("<contato@meubonsai.app>");
expect(lastEmail.recipients[0]).toBe("<contato@brunononogaki.com>");
expect(lastEmail.subject).toBe("Último email enviado");
expect(lastEmail.text).toBe("Text de corpo\n");
});
});
Esperando o serviço de e-mail subir
Por fim, da mesma forma que fizemos o waitForWebServer no orchestrator, garantindo que os testes iniciariam somente depois de o webserver estar de pé, vamos fazer com o serviço de e-mail. Vamos adicionar o método waitForEmailServer():
async function waitForAllServices() {
await waitForWebServer();
await waitForEmailServer();
async function waitForWebServer() {
return retry(fetchStatusPage, {
retries: 100,
maxTimeout: 1000,
});
async function fetchStatusPage() {
const response = await fetch("http://localhost:3000/api/v1/status");
if (response.status !== 200) {
throw Error();
}
}
}
async function waitForEmailServer() {
return retry(fetchStatusPage, {
retries: 100,
maxTimeout: 1000,
});
async function fetchStatusPage() {
const response = await fetch(
`http://${process.env.EMAIL_HTTP_HOST}:${process.env.EMAIL_HTTP_PORT}`
);
if (response.status !== 200) {
throw Error();
}
}
}
}
E agora vamos adicionar isso no beforeAll dos testes:
import email from "infra/email.js";
import orchestrator from "tests/orchestrator";
beforeAll(async () => {
await orchestrator.deleteAllEmails();
await orchestrator.waitForAllServices();
});
Success
Pronto, temos agora a infraestrutura de e-mails pronta, com os métodos sendo testados!