diff --git a/lib/notifications/capa-escalation.ts b/lib/notifications/capa-escalation.ts index 422d877..3c38b20 100644 --- a/lib/notifications/capa-escalation.ts +++ b/lib/notifications/capa-escalation.ts @@ -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)) { diff --git a/lib/notifications/effectiveness-recheck.ts b/lib/notifications/effectiveness-recheck.ts index f042e8a..b40b9d5 100644 --- a/lib/notifications/effectiveness-recheck.ts +++ b/lib/notifications/effectiveness-recheck.ts @@ -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) { diff --git a/lib/notifications/email.ts b/lib/notifications/email.ts index 3c10948..6d36e0d 100644 --- a/lib/notifications/email.ts +++ b/lib/notifications/email.ts @@ -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 }) } diff --git a/lib/notifications/mailer.ts b/lib/notifications/mailer.ts index 3f109fd..f43f539 100644 --- a/lib/notifications/mailer.ts +++ b/lib/notifications/mailer.ts @@ -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 { +export async function sendEmail({ to, subject, html, text }: MailOptions): Promise { 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 { - await sendEmail({ - to, - subject: 'IMS — Reset your password', - html: ` -

Hi ${name},

-

Click the link below to reset your IMS password. The link expires in 1 hour.

-

${resetLink}

-

If you did not request a password reset, ignore this email.

- `, - }) +} + +export async function sendPasswordResetEmail({ to, name, resetLink }: PasswordResetOptions): Promise { + const displayName = name ?? 'User' + const subject = '[IMS] Password Reset Request' + const html = ` +

Hi ${displayName},

+

You requested a password reset for your IMS account.

+

Reset your password

+

This link expires in 1 hour. If you did not request this, you can safely ignore this email.

+ ` + const text = `Hi ${displayName},\n\nReset your IMS password: ${resetLink}\n\nThis link expires in 1 hour.` + await sendEmail({ to, subject, html, text }) } diff --git a/package-lock.json b/package-lock.json index 5638bc1..95ef2ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,7 @@ "pdf-lib": "^1.17.1", "pg": "^8.22.0", "react": "^19.2.7", - "react-dom": "^19.2.7", - "resend": "^6.17.2" + "react-dom": "^19.2.7" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -8797,12 +8796,6 @@ "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": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -9093,27 +9086,6 @@ "dev": true, "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": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", diff --git a/package.json b/package.json index f6a5546..6fe697d 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,7 @@ "pdf-lib": "^1.17.1", "pg": "^8.22.0", "react": "^19.2.7", - "react-dom": "^19.2.7", - "resend": "^6.17.2" + "react-dom": "^19.2.7" }, "devDependencies": { "@tailwindcss/postcss": "^4",