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:
2026-07-23 22:20:12 +08:00
co-authored by Claude Sonnet 4.6
parent 186c732ac0
commit d785d86636
6 changed files with 48 additions and 83 deletions
+2 -8
View File
@@ -1,5 +1,5 @@
import 'server-only'
import { Resend } from 'resend'
import { sendEmail } from '@/lib/notifications/mailer'
import { asAdmin } from '@/lib/db/with-user'
import { capaActions, incidents, users, notificationsLog } from '@/lib/db/schema'
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 }
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'
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[])
}
const { error } = await resend.emails.send({ from, to: [...new Set(to)], subject, html, text })
if (error) {
console.error('Escalation email error:', error)
continue
}
await sendEmail({ to: [...new Set(to)], subject, html, text })
// WhatsApp for urgent thresholds only (owner must have a phone number)
if (['overdue_3d', 'overdue_7d'].includes(threshold)) {
+2 -8
View File
@@ -1,5 +1,5 @@
import 'server-only'
import { Resend } from 'resend'
import { sendEmail } from '@/lib/notifications/mailer'
import { asAdmin } from '@/lib/db/with-user'
import { capaActions, incidents, users } from '@/lib/db/schema'
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 }
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'
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 { error } = await resend.emails.send({ from, to: [verifierEmail], subject, html, text })
if (error) {
console.error('Effectiveness recheck email error:', error)
continue
}
await sendEmail({ to: verifierEmail, subject, html, text })
// WhatsApp (non-blocking, best-effort)
if (whatsappPhoneId && whatsappToken && verifierPhone) {
+2 -6
View File
@@ -1,5 +1,5 @@
import 'server-only'
import { Resend } from 'resend'
import { sendEmail } from '@/lib/notifications/mailer'
import { asAdmin } from '@/lib/db/with-user'
import { users, incidents, sites } from '@/lib/db/schema'
import { and, eq, inArray } from 'drizzle-orm'
@@ -55,9 +55,5 @@ export async function sendNewIncidentEmail(
incident_id: incidentId,
})
const resend = new Resend(process.env.RESEND_API_KEY)
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)
await sendEmail({ to, subject, html, text })
}
+40 -30
View File
@@ -1,52 +1,62 @@
interface SendEmailParams {
to: string
import 'server-only'
interface MailOptions {
to: string | string[]
subject: 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
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', {
method: 'POST',
headers: {
'api-key': apiKey,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
sender: { email: from },
to: [{ email: to }],
subject,
htmlContent: html,
}),
body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`Brevo API error ${res.status}: ${text}`)
const detail = await res.text().catch(() => '')
console.error(`Brevo send failed (${res.status}): ${detail}`)
}
}
export async function sendPasswordResetEmail({
to,
name,
resetLink,
}: {
interface PasswordResetOptions {
to: string
name: string
name: string | null
resetLink: string
}): Promise<void> {
await sendEmail({
to,
subject: 'IMS — Reset your password',
html: `
<p>Hi ${name},</p>
<p>Click the link below to reset your IMS password. The link expires in 1 hour.</p>
<p><a href="${resetLink}">${resetLink}</a></p>
<p>If you did not request a password reset, ignore this email.</p>
`,
})
}
export async function sendPasswordResetEmail({ to, name, resetLink }: PasswordResetOptions): Promise<void> {
const displayName = name ?? 'User'
const subject = '[IMS] Password Reset Request'
const html = `
<p>Hi ${displayName},</p>
<p>You requested a password reset for your IMS account.</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 })
}