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
This commit is contained in:
Randall Stillwell 2026-04-16 12:51:17 -05:00
parent 42df7fc995
commit bf47fd262c
5 changed files with 61 additions and 57 deletions

View file

@ -25,12 +25,10 @@ AUTHENTIK_ISSUER=""
AUTHENTIK_CLIENT_ID="" AUTHENTIK_CLIENT_ID=""
AUTHENTIK_CLIENT_SECRET="" AUTHENTIK_CLIENT_SECRET=""
# ─── SMTP for outbound email ────────────────────────────────── # ─── Brevo transactional email ────────────────────────────────
SMTP_HOST="smtp.dreamhost.com" BREVO_API_KEY=""
SMTP_PORT="587" EMAIL_FROM_NAME="Echo OCR"
SMTP_USER="" EMAIL_FROM_ADDRESS="mars@noreply.stillwell.cloud"
SMTP_PASS=""
SMTP_FROM=""
# ─── Vercel Cron Secret ──────────────────────────────────────── # ─── Vercel Cron Secret ────────────────────────────────────────
# Vercel auto-sets this on Pro. Used to authenticate cron job requests. # Vercel auto-sets this on Pro. Used to authenticate cron job requests.

30
package-lock.json generated
View file

@ -15,6 +15,7 @@
"@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0",
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@getbrevo/brevo": "^5.0.4",
"@napi-rs/canvas": "^0.1.98", "@napi-rs/canvas": "^0.1.98",
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
@ -34,7 +35,6 @@
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^5.0.0-beta.31", "next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.6.205", "pdfjs-dist": "^5.6.205",
"pg": "^8.20.0", "pg": "^8.20.0",
@ -54,7 +54,6 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/node": "^25.4.0", "@types/node": "^25.4.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@ -2024,6 +2023,14 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT" "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": { "node_modules/@hono/node-server": {
"version": "1.19.11", "version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
@ -5199,16 +5206,6 @@
"undici-types": "~7.18.0" "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": { "node_modules/@types/pg": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz",
@ -10920,15 +10917,6 @@
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
"license": "MIT" "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": { "node_modules/npm-run-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",

View file

@ -19,6 +19,7 @@
"@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0",
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@getbrevo/brevo": "^5.0.4",
"@napi-rs/canvas": "^0.1.98", "@napi-rs/canvas": "^0.1.98",
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
@ -38,7 +39,6 @@
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^5.0.0-beta.31", "next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.6.205", "pdfjs-dist": "^5.6.205",
"pg": "^8.20.0", "pg": "^8.20.0",
@ -58,7 +58,6 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/node": "^25.4.0", "@types/node": "^25.4.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { sendInvitationEmail } from "@/lib/email-sender";
export async function GET() { export async function GET() {
try { try {
@ -91,7 +92,7 @@ export async function POST(req: NextRequest) {
const expiresAt = new Date(); const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7); expiresAt.setDate(expiresAt.getDate() + 7);
await prisma.invitation.create({ const invitation = await prisma.invitation.create({
data: { data: {
email, email,
role: role || "viewer", 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 }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("[org/invitations] POST error:", error); console.error("[org/invitations] POST error:", error);

View file

@ -1,37 +1,20 @@
import nodemailer from "nodemailer"; import { BrevoClient } from "@getbrevo/brevo";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import crypto from "crypto"; import crypto from "crypto";
function getTransporter() { const brevo = new BrevoClient({ apiKey: process.env.BREVO_API_KEY! });
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 || "";
return nodemailer.createTransport({ const DEFAULT_SENDER = {
host, name: process.env.EMAIL_FROM_NAME || "Echo OCR",
port, email: process.env.EMAIL_FROM_ADDRESS || "mars@noreply.stillwell.cloud",
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"
);
}
export async function sendEmail(to: string, subject: string, html: string) { export async function sendEmail(to: string, subject: string, html: string) {
const transporter = getTransporter(); await brevo.transactionalEmails.sendTransacEmail({
return transporter.sendMail({ sender: DEFAULT_SENDER,
from: getFromAddress(), to: [{ email: to }],
to,
subject, 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); 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 = `
<div style="max-width:480px;margin:0 auto;font-family:system-ui,sans-serif;color:#1a1a1a">
<h2 style="margin-bottom:16px">You're invited to ${orgName}</h2>
<p>${inviterName ? `${inviterName} has` : "You've been"} invited you to join <strong>${orgName}</strong> on Echo OCR.</p>
<a href="${inviteUrl}"
style="display:inline-block;margin:24px 0;padding:12px 24px;background:#6366f1;color:#fff;text-decoration:none;border-radius:8px;font-weight:600">
Accept Invitation
</a>
<p style="font-size:13px;color:#666">
This invitation expires in 7 days. If you weren't expecting this, you can ignore it.
</p>
</div>
`;
await sendEmail(email, `You're invited to ${orgName} — Echo OCR`, html);
}