diff --git a/.env.example b/.env.example index 4498bab..4eab2c3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/actions/user.ts b/actions/user.ts index be9442f..ca37b83 100644 --- a/actions/user.ts +++ b/actions/user.ts @@ -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" @@ -17,7 +17,6 @@ const KYC_NOTIFICATION_LINK: Record = { 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 @@ -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), diff --git a/app/api/send-email/route.ts b/app/api/send-email/route.ts index c53df70..b630966 100644 --- a/app/api/send-email/route.ts +++ b/app/api/send-email/route.ts @@ -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] @@ -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", }) @@ -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, @@ -99,6 +99,7 @@ export async function POST(request: Request) { metadata: { recipientsCount: recipients.length, subject, + mocked: data.mocked, }, }) diff --git a/lib/services/email.service.test.ts b/lib/services/email.service.test.ts new file mode 100644 index 0000000..1c1fe76 --- /dev/null +++ b/lib/services/email.service.test.ts @@ -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: "

Approved

" }, + { 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: "

Private

" }, + { 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: "

Hello

" }, {}), + ).rejects.toBeInstanceOf(EmailConfigurationError) + }) +}) diff --git a/lib/services/email.service.ts b/lib/services/email.service.ts new file mode 100644 index 0000000..f26bb61 --- /dev/null +++ b/lib/services/email.service.ts @@ -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 { + 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 } +}