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",