From 423f7c7e1b35d97a8bfee8eaf1397ebba3bb9f70 Mon Sep 17 00:00:00 2001 From: Diego Santos <106088657+dizerdev@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:10:03 +0000 Subject: [PATCH 1/2] feat: add `mailcatcher` to local services --- infra/compose.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/infra/compose.yaml b/infra/compose.yaml index aecd776..e7c9066 100644 --- a/infra/compose.yaml +++ b/infra/compose.yaml @@ -6,3 +6,9 @@ services: - ../.env.development ports: - "5432:5432" + mailcatcher: + container_name: "mailcatcher" + image: "sj26/mailcatcher" + ports: + - "1025:1025" + - "1080:1080" From a7f2ae7a84d63789e524ad6420ffcea14b7869bf Mon Sep 17 00:00:00 2001 From: Diego Santos <106088657+dizerdev@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:10:28 +0000 Subject: [PATCH 2/2] feat: add `email.js` infra module --- .env.development | 7 +++++ infra/email.js | 21 ++++++++++++++ package-lock.json | 10 +++++++ package.json | 1 + tests/integration/infra/email.test.js | 25 +++++++++++++++++ tests/orchestrator.js | 40 +++++++++++++++++++++++++++ 6 files changed, 104 insertions(+) create mode 100644 infra/email.js create mode 100644 tests/integration/infra/email.test.js diff --git a/.env.development b/.env.development index c7104b1..add16ee 100644 --- a/.env.development +++ b/.env.development @@ -4,3 +4,10 @@ POSTGRES_USER=local_user POSTGRES_DB=local_db POSTGRES_PASSWORD=local_password DATABASE_URL=postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB + +EMAIL_SMTP_HOST=localhost +EMAIL_SMTP_PORT=1025 +EMAIL_SMTP_USER= +EMAIL_SMTP_PASSWORD= +EMAIL_HTTP_HOST=localhost +EMAIL_HTTP_PORT=1080 diff --git a/infra/email.js b/infra/email.js new file mode 100644 index 0000000..13d7883 --- /dev/null +++ b/infra/email.js @@ -0,0 +1,21 @@ +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; diff --git a/package-lock.json b/package-lock.json index 1d995d3..17d4c7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "next": "15.3.1", "next-connect": "1.0.0", "node-pg-migrate": "7.9.1", + "nodemailer": "7.0.5", "pg": "8.15.6", "react": "19.1.0", "react-dom": "19.1.0", @@ -9442,6 +9443,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz", + "integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index ef27ae7..1a75e4f 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "next": "15.3.1", "next-connect": "1.0.0", "node-pg-migrate": "7.9.1", + "nodemailer": "7.0.5", "pg": "8.15.6", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/tests/integration/infra/email.test.js b/tests/integration/infra/email.test.js new file mode 100644 index 0000000..a31db1e --- /dev/null +++ b/tests/integration/infra/email.test.js @@ -0,0 +1,25 @@ +import email from "infra/email.js"; +import orchestrator from "tests/orchestrator.js"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); +}); + +describe("infra/email.js", () => { + test("send()", async () => { + await orchestrator.deleteAllEmails(); + + await email.send({ + from: "Diego Santos ", + to: "contato@curso.dev", + subject: "Teste de assunto", + text: "Teste de corpo.", + }); + + const lastEmail = await orchestrator.getLastEmail(); + expect(lastEmail.sender).toBe(""); + expect(lastEmail.recipients[0]).toBe(""); + expect(lastEmail.subject).toBe("Teste de assunto"); + expect(lastEmail.text).toBe("Teste de corpo.\n"); + }); +}); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index 3bfb041..d6226a6 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -6,8 +6,11 @@ import migrator from "models/migrator.js"; import user from "models/user"; import session from "models/session"; +const emailHttpUrl = `http://${process.env.EMAIL_HTTP_HOST}:${process.env.EMAIL_HTTP_PORT}`; + async function waitForAllServices() { await waitForWebServer(); + await waitForEmailServer(); async function waitForWebServer() { return retry(fetchStatusPage, { @@ -22,6 +25,20 @@ async function waitForAllServices() { } } } + + async function waitForEmailServer() { + return retry(fetchEmailPage, { + retries: 100, + maxTimeout: 6000, + }); + + async function fetchEmailPage() { + const response = await fetch(emailHttpUrl); + if (response.status !== 200) { + throw Error(); + } + } + } } async function clearDatabase() { @@ -45,12 +62,35 @@ async function createSession(userId) { return await session.create(userId); } +async function deleteAllEmails() { + await fetch(`${emailHttpUrl}/messages`, { + method: "DELETE", + }); +} + +async function getLastEmail() { + const emailListResponse = await fetch(`${emailHttpUrl}/messages`); + const emailListBody = await emailListResponse.json(); + const lastEmailItem = emailListBody.pop(); + + const textEmailResponse = await fetch( + `${emailHttpUrl}/messages/${lastEmailItem.id}.plain`, + ); + const emailTextBody = await textEmailResponse.text(); + + lastEmailItem.text = emailTextBody; + + return lastEmailItem; +} + const orchestrator = { waitForAllServices, clearDatabase, runPendingMigrations, createUser, createSession, + deleteAllEmails, + getLastEmail, }; export default orchestrator;