feat(email): phase 6 — replace Resend with Brevo transactional email
Removes resend npm dependency. Adds lib/notifications/mailer.ts with a raw-HTTP Brevo wrapper (sendEmail + sendPasswordResetEmail). All three notification files updated to use the new wrapper. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import 'server-only'
|
import 'server-only'
|
||||||
import { Resend } from 'resend'
|
import { sendEmail } from '@/lib/notifications/mailer'
|
||||||
import { asAdmin } from '@/lib/db/with-user'
|
import { asAdmin } from '@/lib/db/with-user'
|
||||||
import { capaActions, incidents, users, notificationsLog } from '@/lib/db/schema'
|
import { capaActions, incidents, users, notificationsLog } from '@/lib/db/schema'
|
||||||
import { and, eq, inArray, not } from 'drizzle-orm'
|
import { and, eq, inArray, not } from 'drizzle-orm'
|
||||||
@@ -52,8 +52,6 @@ export async function escalateOverdueCapa(): Promise<{ notified: number }> {
|
|||||||
|
|
||||||
if (!capas || capas.length === 0) return { notified: 0 }
|
if (!capas || capas.length === 0) return { notified: 0 }
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
|
||||||
const from = process.env.RESEND_FROM_EMAIL ?? 'onboarding@resend.dev'
|
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
|
||||||
let notified = 0
|
let notified = 0
|
||||||
|
|
||||||
@@ -105,11 +103,7 @@ export async function escalateOverdueCapa(): Promise<{ notified: number }> {
|
|||||||
to.push(...hseUsers.map(u => u.email).filter(Boolean) as string[])
|
to.push(...hseUsers.map(u => u.email).filter(Boolean) as string[])
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await resend.emails.send({ from, to: [...new Set(to)], subject, html, text })
|
await sendEmail({ to: [...new Set(to)], subject, html, text })
|
||||||
if (error) {
|
|
||||||
console.error('Escalation email error:', error)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// WhatsApp for urgent thresholds only (owner must have a phone number)
|
// WhatsApp for urgent thresholds only (owner must have a phone number)
|
||||||
if (['overdue_3d', 'overdue_7d'].includes(threshold)) {
|
if (['overdue_3d', 'overdue_7d'].includes(threshold)) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'server-only'
|
import 'server-only'
|
||||||
import { Resend } from 'resend'
|
import { sendEmail } from '@/lib/notifications/mailer'
|
||||||
import { asAdmin } from '@/lib/db/with-user'
|
import { asAdmin } from '@/lib/db/with-user'
|
||||||
import { capaActions, incidents, users } from '@/lib/db/schema'
|
import { capaActions, incidents, users } from '@/lib/db/schema'
|
||||||
import { and, eq, inArray, isNotNull, lt } from 'drizzle-orm'
|
import { and, eq, inArray, isNotNull, lt } from 'drizzle-orm'
|
||||||
@@ -58,8 +58,6 @@ export async function sendEffectivenessRecheckNotifications(): Promise<{ notifie
|
|||||||
|
|
||||||
if (!capas || capas.length === 0) return { notified: 0 }
|
if (!capas || capas.length === 0) return { notified: 0 }
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
|
||||||
const from = process.env.RESEND_FROM_EMAIL ?? 'onboarding@resend.dev'
|
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
|
||||||
|
|
||||||
let whatsappPhoneId: string | null = null
|
let whatsappPhoneId: string | null = null
|
||||||
@@ -99,11 +97,7 @@ export async function sendEffectivenessRecheckNotifications(): Promise<{ notifie
|
|||||||
`
|
`
|
||||||
const text = `${roundLabel} effectiveness check for ${incidentRef}\nAction: ${capaDesc}\n${capaUrl}`
|
const text = `${roundLabel} effectiveness check for ${incidentRef}\nAction: ${capaDesc}\n${capaUrl}`
|
||||||
|
|
||||||
const { error } = await resend.emails.send({ from, to: [verifierEmail], subject, html, text })
|
await sendEmail({ to: verifierEmail, subject, html, text })
|
||||||
if (error) {
|
|
||||||
console.error('Effectiveness recheck email error:', error)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// WhatsApp (non-blocking, best-effort)
|
// WhatsApp (non-blocking, best-effort)
|
||||||
if (whatsappPhoneId && whatsappToken && verifierPhone) {
|
if (whatsappPhoneId && whatsappToken && verifierPhone) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'server-only'
|
import 'server-only'
|
||||||
import { Resend } from 'resend'
|
import { sendEmail } from '@/lib/notifications/mailer'
|
||||||
import { asAdmin } from '@/lib/db/with-user'
|
import { asAdmin } from '@/lib/db/with-user'
|
||||||
import { users, incidents, sites } from '@/lib/db/schema'
|
import { users, incidents, sites } from '@/lib/db/schema'
|
||||||
import { and, eq, inArray } from 'drizzle-orm'
|
import { and, eq, inArray } from 'drizzle-orm'
|
||||||
@@ -55,9 +55,5 @@ export async function sendNewIncidentEmail(
|
|||||||
incident_id: incidentId,
|
incident_id: incidentId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
await sendEmail({ to, subject, html, text })
|
||||||
const from = process.env.RESEND_FROM_EMAIL ?? 'onboarding@resend.dev'
|
|
||||||
|
|
||||||
const { error } = await resend.emails.send({ from, to, subject, html, text })
|
|
||||||
if (error) console.error('Resend error:', error)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-30
@@ -1,52 +1,62 @@
|
|||||||
interface SendEmailParams {
|
import 'server-only'
|
||||||
to: string
|
|
||||||
|
interface MailOptions {
|
||||||
|
to: string | string[]
|
||||||
subject: string
|
subject: string
|
||||||
html: string
|
html: string
|
||||||
|
text?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendEmail({ to, subject, html }: SendEmailParams): Promise<void> {
|
export async function sendEmail({ to, subject, html, text }: MailOptions): Promise<void> {
|
||||||
const apiKey = process.env.BREVO_API_KEY
|
const apiKey = process.env.BREVO_API_KEY
|
||||||
if (!apiKey) throw new Error('BREVO_API_KEY not configured')
|
if (!apiKey) {
|
||||||
|
console.error('BREVO_API_KEY not set — email not sent')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const from = process.env.BREVO_FROM_EMAIL ?? 'noreply@setia.com.my'
|
const fromEmail = process.env.BREVO_FROM_EMAIL ?? 'noreply@setia.com.my'
|
||||||
|
const fromName = process.env.BREVO_FROM_NAME ?? 'Setia IMS'
|
||||||
|
const recipients = Array.isArray(to) ? to : [to]
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
sender: { name: fromName, email: fromEmail },
|
||||||
|
to: recipients.map(email => ({ email })),
|
||||||
|
subject,
|
||||||
|
htmlContent: html,
|
||||||
|
...(text ? { textContent: text } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch('https://api.brevo.com/v3/smtp/email', {
|
const res = await fetch('https://api.brevo.com/v3/smtp/email', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'api-key': apiKey,
|
'api-key': apiKey,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
sender: { email: from },
|
|
||||||
to: [{ email: to }],
|
|
||||||
subject,
|
|
||||||
htmlContent: html,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const detail = await res.text().catch(() => '')
|
||||||
throw new Error(`Brevo API error ${res.status}: ${text}`)
|
console.error(`Brevo send failed (${res.status}): ${detail}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendPasswordResetEmail({
|
interface PasswordResetOptions {
|
||||||
to,
|
|
||||||
name,
|
|
||||||
resetLink,
|
|
||||||
}: {
|
|
||||||
to: string
|
to: string
|
||||||
name: string
|
name: string | null
|
||||||
resetLink: string
|
resetLink: string
|
||||||
}): Promise<void> {
|
}
|
||||||
await sendEmail({
|
|
||||||
to,
|
export async function sendPasswordResetEmail({ to, name, resetLink }: PasswordResetOptions): Promise<void> {
|
||||||
subject: 'IMS — Reset your password',
|
const displayName = name ?? 'User'
|
||||||
html: `
|
const subject = '[IMS] Password Reset Request'
|
||||||
<p>Hi ${name},</p>
|
const html = `
|
||||||
<p>Click the link below to reset your IMS password. The link expires in 1 hour.</p>
|
<p>Hi ${displayName},</p>
|
||||||
<p><a href="${resetLink}">${resetLink}</a></p>
|
<p>You requested a password reset for your IMS account.</p>
|
||||||
<p>If you did not request a password reset, ignore this email.</p>
|
<p><a href="${resetLink}">Reset your password</a></p>
|
||||||
`,
|
<p>This link expires in 1 hour. If you did not request this, you can safely ignore this email.</p>
|
||||||
})
|
`
|
||||||
|
const text = `Hi ${displayName},\n\nReset your IMS password: ${resetLink}\n\nThis link expires in 1 hour.`
|
||||||
|
await sendEmail({ to, subject, html, text })
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-29
@@ -21,8 +21,7 @@
|
|||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7"
|
||||||
"resend": "^6.17.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -8797,12 +8796,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postal-mime": {
|
|
||||||
"version": "2.7.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
|
|
||||||
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
|
|
||||||
"license": "MIT-0"
|
|
||||||
},
|
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.16",
|
"version": "8.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||||
@@ -9093,27 +9086,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/resend": {
|
|
||||||
"version": "6.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/resend/-/resend-6.17.2.tgz",
|
|
||||||
"integrity": "sha512-hbaXEORFIFfT2Bh03NsA/akTTTKkD1hiuJ88ke64c5dWVV6DyoLxzFve3OiZqVOQ+JKLJ5uVkPR6hlUslpJFoA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"postal-mime": "2.7.4",
|
|
||||||
"standardwebhooks": "1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@react-email/render": "*"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@react-email/render": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.7",
|
"version": "2.0.0-next.7",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||||
|
|||||||
+1
-2
@@ -25,8 +25,7 @@
|
|||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7"
|
||||||
"resend": "^6.17.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
Reference in New Issue
Block a user