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:
parent
42df7fc995
commit
bf47fd262c
5 changed files with 61 additions and 57 deletions
10
.env.example
10
.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.
|
||||
|
|
|
|||
30
package-lock.json
generated
30
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue