Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ PAYSTACK_PUBLIC_KEY=replace_with_paystack_test_public_key
PAYSTACK_SECRET_KEY=replace_with_paystack_test_secret
PAYSTACK_DVA_PREFERRED_BANK=test-bank

# Email
# Email (mock mode below skips Resend and does not require this key)
RESEND_API_KEY=replace_with_resend_key

# File uploads / Vercel Blob
Expand Down
8 changes: 3 additions & 5 deletions actions/user.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"use server"

import { revalidatePath } from "next/cache"
import { Resend } from "resend"

import { getSessionFromCookies } from "@/lib/auth/session"
import dbConnect from "@/lib/dbConnect"
import { sendEmail } from "@/lib/services/email.service"
import { logAuditEvent } from "@/lib/security/audit-log"
import { isSupportedKycDocumentReference } from "@/lib/security/kyc-documents"
import User from "@/models/User"
Expand All @@ -17,7 +17,6 @@ const KYC_NOTIFICATION_LINK: Record<KycUserRole, string> = {
driver: "/dashboard/driver/kyc/status",
investor: "/dashboard/investor/kyc/status",
}
const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null

function normalizeDateInput(value: Date | string | null | undefined) {
if (!value) return null
Expand Down Expand Up @@ -76,11 +75,10 @@ function buildEmailHtml(name: string, message: string) {
}

async function sendKycEmail(user: any, subject: string, message: string) {
if (!resend || !user?.email) return
if (!user?.email) return

try {
await resend.emails.send({
from: "onboarding@chainmove.xyz",
await sendEmail({
to: user.email,
subject,
html: buildEmailHtml(user.name || "there", message),
Expand Down
29 changes: 15 additions & 14 deletions app/api/send-email/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { NextResponse } from "next/server"
import { Resend } from "resend"
import { z } from "zod"

import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard"
import { parseJsonBody } from "@/lib/api/validation"
import { logAuditEvent } from "@/lib/security/audit-log"
import { buildRateLimitKey, consumeRateLimit, getClientIpAddress, rateLimitExceededResponse } from "@/lib/security/rate-limit"
import {
EmailConfigurationError,
EmailDeliveryError,
sendEmail,
type SendEmailResult,
} from "@/lib/services/email.service"

function normalizeRecipients(value: unknown) {
const items = Array.isArray(value) ? value : [value]
Expand All @@ -27,11 +32,6 @@ const bodySchema = z.object({

export async function POST(request: Request) {
try {
const apiKey = process.env.RESEND_API_KEY
if (!apiKey) {
return NextResponse.json({ error: "Email service is not configured" }, { status: 500 })
}

const authContext = await requireAuthenticatedUser(request, ["admin"], {
forbiddenMessage: "Admin access required",
})
Expand Down Expand Up @@ -65,15 +65,15 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Email content is required" }, { status: 400 })
}

const resend = new Resend(apiKey)
const { data, error } = await resend.emails.send({
from: "onboarding@chainmove.xyz",
to: recipients,
subject,
html,
})
let data: SendEmailResult
try {
data = await sendEmail({ to: recipients, subject, html })
} catch (error) {
if (error instanceof EmailConfigurationError) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (!(error instanceof EmailDeliveryError)) throw error

if (error) {
console.error("RESEND_EMAIL_ERROR", error)
await logAuditEvent({
actor: authContext.user,
Expand All @@ -99,6 +99,7 @@ export async function POST(request: Request) {
metadata: {
recipientsCount: recipients.length,
subject,
mocked: data.mocked,
},
})

Expand Down
44 changes: 44 additions & 0 deletions lib/services/email.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from "vitest"

import { EmailConfigurationError, isMockEmailEnabled, sendEmail } from "./email.service"

describe("email service", () => {
afterEach(() => {
vi.restoreAllMocks()
})

it("returns a mock result without a Resend API key", async () => {
const log = vi.spyOn(console, "info").mockImplementation(() => undefined)

const result = await sendEmail(
{ to: "driver@example.com", subject: "Loan update", html: "<p>Approved</p>" },
{ ENABLE_MOCK_EMAILS: "true" },
)

expect(result).toMatchObject({ mocked: true })
expect(result.id).toMatch(/^mock_email_\d+$/)
expect(log).toHaveBeenCalledWith("MOCK_EMAIL_SEND", {
recipientsCount: 1,
subject: "Loan update",
})
})

it("does not include recipient addresses or HTML in mock logs", async () => {
const log = vi.spyOn(console, "info").mockImplementation(() => undefined)

await sendEmail(
{ to: ["one@example.com", "two@example.com"], subject: "Update", html: "<p>Private</p>" },
{ ENABLE_MOCK_EMAILS: "true" },
)

expect(JSON.stringify(log.mock.calls)).not.toContain("one@example.com")
expect(JSON.stringify(log.mock.calls)).not.toContain("Private")
})

it("requires a Resend API key when mock mode is disabled", async () => {
expect(isMockEmailEnabled({ ENABLE_MOCK_EMAILS: "false" })).toBe(false)
await expect(
sendEmail({ to: "driver@example.com", subject: "Update", html: "<p>Hello</p>" }, {}),
).rejects.toBeInstanceOf(EmailConfigurationError)
})
})
76 changes: 76 additions & 0 deletions lib/services/email.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Resend } from "resend"

export interface SendEmailInput {
to: string | string[]
subject: string
html: string
from?: string
}

export interface SendEmailResult {
id: string
mocked: boolean
}

export interface EmailEnvironment {
ENABLE_MOCK_EMAILS?: string
RESEND_API_KEY?: string
NODE_ENV?: string
}

export class EmailConfigurationError extends Error {
constructor() {
super("Email service is not configured")
this.name = "EmailConfigurationError"
}
}

export class EmailDeliveryError extends Error {
constructor(message = "Unable to send email") {
super(message)
this.name = "EmailDeliveryError"
}
}

let resendClient: Resend | null = null
let resendApiKey: string | null = null

export function isMockEmailEnabled(env: EmailEnvironment = process.env): boolean {
return env.ENABLE_MOCK_EMAILS?.trim().toLowerCase() === "true"
}

function getResendClient(apiKey: string): Resend {
if (!resendClient || resendApiKey !== apiKey) {
resendClient = new Resend(apiKey)
resendApiKey = apiKey
}
return resendClient
}

export async function sendEmail(
input: SendEmailInput,
env: EmailEnvironment = process.env,
): Promise<SendEmailResult> {
if (isMockEmailEnabled(env)) {
console.info("MOCK_EMAIL_SEND", {
recipientsCount: Array.isArray(input.to) ? input.to.length : 1,
subject: input.subject,
})
return { id: `mock_email_${Date.now()}`, mocked: true }
}

const apiKey = env.RESEND_API_KEY?.trim()
if (!apiKey) throw new EmailConfigurationError()

const { data, error } = await getResendClient(apiKey).emails.send({
from: input.from || "onboarding@chainmove.xyz",
to: input.to,
subject: input.subject,
html: input.html,
})

if (error) throw new EmailDeliveryError(error.message)
if (!data?.id) throw new EmailDeliveryError("Email provider returned no delivery ID")

return { id: data.id, mocked: false }
}