From ee8d36532161e631a6df53504ebb1249f7aa17f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 8 Sep 2026 12:17:41 -0300 Subject: [PATCH] feat: send welcome emails through smtp --- .env.example | 9 ++++++--- package-lock.json | 21 +++++++++++++++++++++ package.json | 2 ++ server/src/config.ts | 12 ++++++++---- server/src/routes/auth.ts | 2 +- server/src/services/email.ts | 25 ++++++++++++++----------- 6 files changed, 52 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 2f06eb5..311e577 100644 --- a/.env.example +++ b/.env.example @@ -22,9 +22,12 @@ PASSWORD_RESET_TTL_HOURS=24 AUDIT_LOG_PAGE_SIZE=50 # Optional welcome e-mails sent when a student creates their own account. -# Create an API key and verify this sender in Resend before setting both values. -RESEND_API_KEY= -EMAIL_FROM=Compor HUB +# Configure all five values together. Port 465 uses SSL; port 587 uses STARTTLS. +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +MAIL_FROM=Compor HUB SUPERADMIN_NAME=Compor HUB Superadmin # Bunny Stream. Set all three in Portainer to enable instructor uploads and diff --git a/package-lock.json b/package-lock.json index 77c36af..b611144 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "fastify": "^5.3.2", "fastify-plugin": "^5.0.1", "lucide-react": "0.344.0", + "nodemailer": "^10.0.1", "pg": "^8.16.3", "react": "18.2.0", "react-dom": "18.2.0", @@ -22,6 +23,7 @@ }, "devDependencies": { "@types/node": "^22.14.0", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.15.4", "@vitejs/plugin-react": "^5.0.0", "tsx": "^4.19.4", @@ -1475,6 +1477,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", @@ -2231,6 +2243,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.1.tgz", + "integrity": "sha512-c+gU9cL9HLDax3vjxL88kW+6NOgdtEUWaZ+AUtxdJR6LLhf0kGdCLExof7yiKW7zdO9EfXCSIgmhGyFmUM0mYQ==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/obliterator": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", diff --git a/package.json b/package.json index 2b9bf37..ed54dc2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "fastify": "^5.3.2", "fastify-plugin": "^5.0.1", "lucide-react": "0.344.0", + "nodemailer": "^10.0.1", "pg": "^8.16.3", "react": "18.2.0", "react-dom": "18.2.0", @@ -31,6 +32,7 @@ }, "devDependencies": { "@types/node": "^22.14.0", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.15.4", "@vitejs/plugin-react": "^5.0.0", "tsx": "^4.19.4", diff --git a/server/src/config.ts b/server/src/config.ts index 5d6098f..1bfe1cd 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -19,8 +19,11 @@ const environmentSchema = z.object({ INVITATION_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 90).default(24 * 7), PASSWORD_RESET_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 30).default(24), AUDIT_LOG_PAGE_SIZE: z.coerce.number().int().min(10).max(500).default(50), - RESEND_API_KEY: optionalEnvironmentValue(z.string().min(20)), - EMAIL_FROM: optionalEnvironmentValue(z.string().trim().min(3).max(320)), + SMTP_HOST: optionalEnvironmentValue(z.string().trim().min(1).max(255)), + SMTP_PORT: optionalEnvironmentValue(z.coerce.number().int().min(1).max(65_535)), + SMTP_USER: optionalEnvironmentValue(z.string().trim().min(1).max(320)), + SMTP_PASS: optionalEnvironmentValue(z.string().min(1).max(1_000)), + MAIL_FROM: optionalEnvironmentValue(z.string().trim().min(3).max(320)), BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()), BUNNY_STREAM_API_KEY: optionalEnvironmentValue(z.string().min(20)), BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)), @@ -61,6 +64,7 @@ if (bunnyStorageConfigurationValues.some(Boolean) && !bunnyStorageConfigurationV throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT, and BUNNY_STORAGE_CDN_HOST must be configured together.'); } -if (Boolean(config.RESEND_API_KEY) !== Boolean(config.EMAIL_FROM)) { - throw new Error('RESEND_API_KEY and EMAIL_FROM must be configured together.'); +const smtpConfigurationValues = [config.SMTP_HOST, config.SMTP_PORT, config.SMTP_USER, config.SMTP_PASS, config.MAIL_FROM]; +if (smtpConfigurationValues.some(Boolean) && !smtpConfigurationValues.every(Boolean)) { + throw new Error('SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, and MAIL_FROM must be configured together.'); } diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 604e535..59c2586 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -133,7 +133,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => { const token = await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL }); try { const sent = await sendWelcomeEmail({ name: user.name, email: user.email }); - if (!sent && config.RESEND_API_KEY) request.log.warn({ email: user.email }, 'Welcome email was rejected by the provider'); + if (!sent && config.SMTP_HOST) request.log.warn({ email: user.email }, 'Welcome email was rejected by the SMTP server'); } catch (error) { request.log.error(error, 'Welcome email could not be sent'); } diff --git a/server/src/services/email.ts b/server/src/services/email.ts index a6f6699..5251745 100644 --- a/server/src/services/email.ts +++ b/server/src/services/email.ts @@ -1,20 +1,23 @@ import { config } from '../config.js'; +import nodemailer from 'nodemailer'; type WelcomeRecipient = { name: string; email: string }; export async function sendWelcomeEmail(recipient: WelcomeRecipient) { - if (!config.RESEND_API_KEY || !config.EMAIL_FROM) return false; + if (!config.SMTP_HOST || !config.SMTP_PORT || !config.SMTP_USER || !config.SMTP_PASS || !config.MAIL_FROM) return false; const escapedName = recipient.name.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character] || character)); - const response = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { Authorization: `Bearer ${config.RESEND_API_KEY}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - from: config.EMAIL_FROM, - to: [recipient.email], - subject: 'Bem-vindo(a) ao Compor HUB', - html: `

Bem-vindo(a), ${escapedName}!

Sua conta no Compor HUB já está pronta.

Agora você pode acessar cursos, acompanhar suas aulas e baixar materiais complementares.

Acessar Compor HUB

`, - }), + const transporter = nodemailer.createTransport({ + host: config.SMTP_HOST, + port: config.SMTP_PORT, + secure: config.SMTP_PORT === 465, + auth: { user: config.SMTP_USER, pass: config.SMTP_PASS }, }); - return response.ok; + await transporter.sendMail({ + from: config.MAIL_FROM, + to: recipient.email, + subject: 'Bem-vindo(a) ao Compor HUB', + html: `

Bem-vindo(a), ${escapedName}!

Sua conta no Compor HUB já está pronta.

Agora você pode acessar cursos, acompanhar suas aulas e baixar materiais complementares.

Acessar Compor HUB

`, + }); + return true; }