From bf47fd262c853a74c4a9d61e6fb9c10feb6d4e9f Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Thu, 16 Apr 2026 12:51:17 -0500 Subject: [PATCH] Replace nodemailer with Brevo HTTP API for transactional email - Swap nodemailer SMTP transport for @getbrevo/brevo SDK, which uses HTTP with built-in retries (better for Vercel serverless) - Add sendInvitationEmail function and wire it into the org invitations POST route so new invites trigger an email automatically - Update .env.example with BREVO_API_KEY, EMAIL_FROM_NAME, EMAIL_FROM_ADDRESS replacing the old SMTP_* vars Made-with: Cursor --- .env.example | 10 ++--- package-lock.json | 30 ++++--------- package.json | 3 +- src/app/api/org/invitations/route.ts | 12 +++++- src/lib/email-sender.ts | 63 ++++++++++++++++------------ 5 files changed, 61 insertions(+), 57 deletions(-) diff --git a/.env.example b/.env.example index 1eb0b54..6cb0457 100644 --- a/.env.example +++ b/.env.example @@ -25,12 +25,10 @@ AUTHENTIK_ISSUER="" AUTHENTIK_CLIENT_ID="" AUTHENTIK_CLIENT_SECRET="" -# ─── SMTP for outbound email ────────────────────────────────── -SMTP_HOST="smtp.dreamhost.com" -SMTP_PORT="587" -SMTP_USER="" -SMTP_PASS="" -SMTP_FROM="" +# ─── Brevo transactional email ──────────────────────────────── +BREVO_API_KEY="" +EMAIL_FROM_NAME="Echo OCR" +EMAIL_FROM_ADDRESS="mars@noreply.stillwell.cloud" # ─── Vercel Cron Secret ──────────────────────────────────────── # Vercel auto-sets this on Pro. Used to authenticate cron job requests. diff --git a/package-lock.json b/package-lock.json index 0918d26..e2df600 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0", "@base-ui/react": "^1.2.0", + "@getbrevo/brevo": "^5.0.4", "@napi-rs/canvas": "^0.1.98", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", @@ -34,7 +35,6 @@ "next": "16.1.6", "next-auth": "^5.0.0-beta.31", "next-themes": "^0.4.6", - "nodemailer": "^7.0.13", "pdf-lib": "^1.17.1", "pdfjs-dist": "^5.6.205", "pg": "^8.20.0", @@ -54,7 +54,6 @@ "@types/bcryptjs": "^2.4.6", "@types/mailparser": "^3.4.6", "@types/node": "^25.4.0", - "@types/nodemailer": "^8.0.0", "@types/pg": "^8.18.0", "@types/react": "^19", "@types/react-dom": "^19", @@ -2024,6 +2023,14 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@getbrevo/brevo": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@getbrevo/brevo/-/brevo-5.0.4.tgz", + "integrity": "sha512-wN4mHE6O0Pb/d/Dh3E4MAm2zCHbLLUcFF8/wgwTeMPy9KzHBYLu7S/nsJbzd0AWL09MHn8vwue8ULL9YOzluOQ==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.11", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", @@ -5199,16 +5206,6 @@ "undici-types": "~7.18.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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/pg": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", @@ -10920,15 +10917,6 @@ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, - "node_modules/nodemailer": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", - "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", - "license": "MIT-0", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", diff --git a/package.json b/package.json index 424f979..c393149 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0", "@base-ui/react": "^1.2.0", + "@getbrevo/brevo": "^5.0.4", "@napi-rs/canvas": "^0.1.98", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", @@ -38,7 +39,6 @@ "next": "16.1.6", "next-auth": "^5.0.0-beta.31", "next-themes": "^0.4.6", - "nodemailer": "^7.0.13", "pdf-lib": "^1.17.1", "pdfjs-dist": "^5.6.205", "pg": "^8.20.0", @@ -58,7 +58,6 @@ "@types/bcryptjs": "^2.4.6", "@types/mailparser": "^3.4.6", "@types/node": "^25.4.0", - "@types/nodemailer": "^8.0.0", "@types/pg": "^8.18.0", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/app/api/org/invitations/route.ts b/src/app/api/org/invitations/route.ts index a4f07a1..b18888c 100644 --- a/src/app/api/org/invitations/route.ts +++ b/src/app/api/org/invitations/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth"; import { prisma } from "@/lib/db"; +import { sendInvitationEmail } from "@/lib/email-sender"; export async function GET() { try { @@ -91,7 +92,7 @@ export async function POST(req: NextRequest) { const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 7); - await prisma.invitation.create({ + const invitation = await prisma.invitation.create({ data: { email, role: role || "viewer", @@ -101,6 +102,15 @@ export async function POST(req: NextRequest) { }, }); + try { + const baseUrl = new URL(req.url).origin; + const orgName = session.user.orgName || "the organization"; + const inviterName = session.user.displayName || session.user.name || undefined; + await sendInvitationEmail(email, baseUrl, invitation.token, orgName, inviterName); + } catch (emailErr) { + console.warn("[org/invitations] Invitation email failed (non-blocking):", emailErr); + } + return NextResponse.json({ success: true }); } catch (error) { console.error("[org/invitations] POST error:", error); diff --git a/src/lib/email-sender.ts b/src/lib/email-sender.ts index 47e4a6d..a9ba488 100644 --- a/src/lib/email-sender.ts +++ b/src/lib/email-sender.ts @@ -1,37 +1,20 @@ -import nodemailer from "nodemailer"; +import { BrevoClient } from "@getbrevo/brevo"; import { prisma } from "@/lib/db"; import crypto from "crypto"; -function getTransporter() { - const host = process.env.SMTP_HOST || process.env.EMAIL_IMAP_HOST || ""; - const port = parseInt(process.env.SMTP_PORT || "587", 10); - const user = process.env.SMTP_USER || process.env.EMAIL_IMAP_USER || ""; - const pass = process.env.SMTP_PASS || process.env.EMAIL_IMAP_PASS || ""; +const brevo = new BrevoClient({ apiKey: process.env.BREVO_API_KEY! }); - return nodemailer.createTransport({ - host, - port, - secure: port === 465, - auth: { user, pass }, - }); -} - -function getFromAddress(): string { - return ( - process.env.SMTP_FROM || - process.env.SMTP_USER || - process.env.EMAIL_IMAP_USER || - "noreply@echoocr.app" - ); -} +const DEFAULT_SENDER = { + name: process.env.EMAIL_FROM_NAME || "Echo OCR", + email: process.env.EMAIL_FROM_ADDRESS || "mars@noreply.stillwell.cloud", +}; export async function sendEmail(to: string, subject: string, html: string) { - const transporter = getTransporter(); - return transporter.sendMail({ - from: getFromAddress(), - to, + await brevo.transactionalEmails.sendTransacEmail({ + sender: DEFAULT_SENDER, + to: [{ email: to }], subject, - html, + htmlContent: html, }); } @@ -75,3 +58,29 @@ export async function sendVerificationEmail(email: string, baseUrl: string) { await sendEmail(email, "Verify your email — Echo OCR", html); } + +export async function sendInvitationEmail( + email: string, + baseUrl: string, + inviteToken: string, + orgName: string, + inviterName?: string +) { + const inviteUrl = `${baseUrl}/invite/${inviteToken}`; + + const html = ` +
+

You're invited to ${orgName}

+

${inviterName ? `${inviterName} has` : "You've been"} invited you to join ${orgName} on Echo OCR.

+ + Accept Invitation + +

+ This invitation expires in 7 days. If you weren't expecting this, you can ignore it. +

+
+ `; + + await sendEmail(email, `You're invited to ${orgName} — Echo OCR`, html); +}