From 8aaacf4dbafa66563ebe634de843f89f79af6f32 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 21 May 2026 18:11:12 +0300 Subject: [PATCH] fix(email): replace nodemailer SMTP with Brevo HTTP API SMTP connections to port 587 are blocked on Render. Switch to Brevo transactional API over HTTPS instead. Remove nodemailer dependency. Test env keeps an in-memory capture without any transport. --- apps/backend/.env.example | 9 +-- apps/backend/package.json | 2 - apps/backend/src/services/emailService.ts | 83 +++++++++++------------ package-lock.json | 20 ------ 4 files changed, 43 insertions(+), 71 deletions(-) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 961ace9..b92021b 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -32,9 +32,6 @@ GOOGLE_REDIRECT_URI=http://localhost:4000/api/auth/google/callback # Defaults to http://localhost:4000 in development. API_PUBLIC_URL=http://localhost:4000 -# SMTP — email verification (Gmail example) -SMTP_HOST="smtp.gmail.com" -SMTP_PORT="587" -SMTP_USER="your@gmail.com" -SMTP_PASS="your_app_password" #https://myaccount.google.com/apppasswords -SMTP_FROM="CoinRadar " +# Email — Brevo transactional API (https://app.brevo.com/settings/keys/api) +BREVO_API_KEY=your_brevo_api_key +EMAIL_FROM="CoinRadar " diff --git a/apps/backend/package.json b/apps/backend/package.json index c4342d3..e6b2fe7 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -33,12 +33,10 @@ "@types/express": "^5.0.5", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.10.0", - "@types/nodemailer": "^8.0.0", "bcrypt": "^6.0.0", "cors": "^2.8.5", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", - "nodemailer": "^8.0.7", "pg": "^8.21.0", "prisma": "^7.8.0", "typescript": "^5.9.3", diff --git a/apps/backend/src/services/emailService.ts b/apps/backend/src/services/emailService.ts index 3a0075c..b01ec28 100644 --- a/apps/backend/src/services/emailService.ts +++ b/apps/backend/src/services/emailService.ts @@ -1,11 +1,6 @@ -import nodemailer, { type Transporter } from "nodemailer"; - -const SMTP_HOST = process.env.SMTP_HOST; -const SMTP_PORT = Number(process.env.SMTP_PORT || 587); -const SMTP_USER = process.env.SMTP_USER; -const SMTP_PASS = process.env.SMTP_PASS; -const SMTP_FROM = - process.env.SMTP_FROM || "CoinRadar "; +const EMAIL_FROM = + process.env.EMAIL_FROM || "CoinRadar "; +const BREVO_API_KEY = process.env.BREVO_API_KEY; const API_PUBLIC_URL = process.env.API_PUBLIC_URL || "http://localhost:4000"; export type EmailPurpose = "verify_email"; @@ -22,30 +17,6 @@ export interface SentEmailRecord { const captured: SentEmailRecord[] = []; -let transporter: Transporter | null = null; - -const getTransporter = (): Transporter => { - if (transporter) return transporter; - - if (process.env.NODE_ENV === "test") { - transporter = nodemailer.createTransport({ jsonTransport: true }); - } else if (SMTP_HOST && SMTP_USER && SMTP_PASS) { - transporter = nodemailer.createTransport({ - host: SMTP_HOST, - port: SMTP_PORT, - secure: SMTP_PORT === 465, - auth: { user: SMTP_USER, pass: SMTP_PASS }, - }); - } else { - transporter = nodemailer.createTransport({ jsonTransport: true }); - console.warn( - "[emailService] SMTP_* env vars not set - using jsonTransport. Emails will be logged, not delivered.", - ); - } - - return transporter; -}; - interface DispatchArgs { to: string; subject: string; @@ -55,15 +26,15 @@ interface DispatchArgs { token?: string; } -const dispatch = async (args: DispatchArgs) => { +const parseSender = (from: string): { name: string; email: string } => { + const match = from.match(/^(.+?)\s*<(.+?)>$/); + if (match?.[1] && match?.[2]) + return { name: match[1].trim(), email: match[2].trim() }; + return { name: "CoinRadar", email: from.trim() }; +}; + +const dispatch = async (args: DispatchArgs): Promise => { const { to, subject, purpose, text, html, token } = args; - const info = await getTransporter().sendMail({ - from: SMTP_FROM, - to, - subject, - text, - html, - }); if (process.env.NODE_ENV === "test") { captured.push({ @@ -75,11 +46,37 @@ const dispatch = async (args: DispatchArgs) => { sentAt: new Date(), ...(token !== undefined && { token }), }); - } else if (!SMTP_HOST) { - console.info(`[emailService][${purpose}] -> ${to}\n${text}`); + return; + } + + if (!BREVO_API_KEY) { + console.warn( + `[emailService] BREVO_API_KEY not set - email not sent.\n[${purpose}] -> ${to}\n${text}`, + ); + return; } - return info; + const sender = parseSender(EMAIL_FROM); + + const response = await fetch("https://api.brevo.com/v3/smtp/email", { + method: "POST", + headers: { + "api-key": BREVO_API_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sender, + to: [{ email: to }], + subject, + textContent: text, + htmlContent: html, + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Brevo API error ${response.status}: ${body}`); + } }; const wrapHtml = (title: string, body: string): string => ` diff --git a/package-lock.json b/package-lock.json index 35e29ca..049ca4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,12 +41,10 @@ "@types/express": "^5.0.5", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.10.0", - "@types/nodemailer": "^8.0.0", "bcrypt": "^6.0.0", "cors": "^2.8.5", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", - "nodemailer": "^8.0.7", "pg": "^8.21.0", "prisma": "^7.8.0", "typescript": "^5.9.3", @@ -5096,15 +5094,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/nodemailer": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", - "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -11821,15 +11810,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nodemailer": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", - "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", - "license": "MIT-0", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",