diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 00000000..49d3ccfe --- /dev/null +++ b/app/api/notifications/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createNotificationHandler } from "@/lib/notifications/createNotificationHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/notifications + * + * Sends a notification email to the authenticated account's email address via Resend. + * The recipient is automatically resolved from the API key or Bearer token. + * Requires authentication via x-api-key header or Authorization bearer token. + * + * Body parameters: + * - subject (required): email subject line + * - text (optional): plain text / Markdown body + * - html (optional): raw HTML body (takes precedence over text) + * - cc (optional): array of CC email addresses + * - headers (optional): custom email headers + * - room_id (optional): room ID for chat link in footer + * + * @param request - The request object. + * @returns A NextResponse with send result. + */ +export async function POST(request: NextRequest): Promise { + return createNotificationHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/emails/__tests__/processAndSendEmail.test.ts b/lib/emails/__tests__/processAndSendEmail.test.ts new file mode 100644 index 00000000..4a6b14d2 --- /dev/null +++ b/lib/emails/__tests__/processAndSendEmail.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { processAndSendEmail } from "../processAndSendEmail"; + +const mockSendEmailWithResend = vi.fn(); +const mockSelectRoomWithArtist = vi.fn(); + +vi.mock("@/lib/emails/sendEmail", () => ({ + sendEmailWithResend: (...args: unknown[]) => mockSendEmailWithResend(...args), +})); + +vi.mock("@/lib/supabase/rooms/selectRoomWithArtist", () => ({ + selectRoomWithArtist: (...args: unknown[]) => mockSelectRoomWithArtist(...args), +})); + +describe("processAndSendEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends email with text body converted to HTML", async () => { + mockSendEmailWithResend.mockResolvedValue({ id: "email-123" }); + + const result = await processAndSendEmail({ + to: ["user@example.com"], + subject: "Test", + text: "Hello world", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.id).toBe("email-123"); + expect(result.message).toContain("user@example.com"); + } + expect(mockSendEmailWithResend).toHaveBeenCalledWith( + expect.objectContaining({ + from: "Agent by Recoup ", + to: ["user@example.com"], + subject: "Test", + html: expect.stringContaining("Hello world"), + }), + ); + }); + + it("uses html body when provided (takes precedence over text)", async () => { + mockSendEmailWithResend.mockResolvedValue({ id: "email-456" }); + + await processAndSendEmail({ + to: ["user@example.com"], + subject: "Test", + text: "plain text", + html: "

HTML body

", + }); + + expect(mockSendEmailWithResend).toHaveBeenCalledWith( + expect.objectContaining({ + html: expect.stringContaining("

HTML body

"), + }), + ); + }); + + it("includes CC when provided", async () => { + mockSendEmailWithResend.mockResolvedValue({ id: "email-789" }); + + await processAndSendEmail({ + to: ["user@example.com"], + cc: ["cc@example.com"], + subject: "Test", + }); + + expect(mockSendEmailWithResend).toHaveBeenCalledWith( + expect.objectContaining({ + cc: ["cc@example.com"], + }), + ); + }); + + it("includes artist name in footer when room_id is provided", async () => { + mockSendEmailWithResend.mockResolvedValue({ id: "email-room" }); + mockSelectRoomWithArtist.mockResolvedValue({ artist_name: "Test Artist" }); + + await processAndSendEmail({ + to: ["user@example.com"], + subject: "Test", + text: "Hello", + room_id: "room-abc", + }); + + expect(mockSelectRoomWithArtist).toHaveBeenCalledWith("room-abc"); + expect(mockSendEmailWithResend).toHaveBeenCalledWith( + expect.objectContaining({ + html: expect.stringContaining("Test Artist"), + }), + ); + }); + + it("returns error when Resend fails", async () => { + const errorResponse = NextResponse.json( + { error: { message: "Rate limited" } }, + { status: 429 }, + ); + mockSendEmailWithResend.mockResolvedValue(errorResponse); + + const result = await processAndSendEmail({ + to: ["user@example.com"], + subject: "Test", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Rate limited"); + } + }); +}); diff --git a/lib/emails/processAndSendEmail.ts b/lib/emails/processAndSendEmail.ts new file mode 100644 index 00000000..c70e20d0 --- /dev/null +++ b/lib/emails/processAndSendEmail.ts @@ -0,0 +1,70 @@ +import { sendEmailWithResend } from "@/lib/emails/sendEmail"; +import { getEmailFooter } from "@/lib/emails/getEmailFooter"; +import { selectRoomWithArtist } from "@/lib/supabase/rooms/selectRoomWithArtist"; +import { RECOUP_FROM_EMAIL } from "@/lib/const"; +import { NextResponse } from "next/server"; +import { marked } from "marked"; + +export interface ProcessAndSendEmailInput { + to: string[]; + cc?: string[]; + subject: string; + text?: string; + html?: string; + headers?: Record; + room_id?: string; +} + +export interface ProcessAndSendEmailSuccess { + success: true; + message: string; + id: string; +} + +export interface ProcessAndSendEmailError { + success: false; + error: string; +} + +export type ProcessAndSendEmailResult = ProcessAndSendEmailSuccess | ProcessAndSendEmailError; + +/** + * Shared email processing and sending logic used by both the + * POST /api/notifications handler and the send_email MCP tool. + * + * Handles room lookup, footer generation, markdown-to-HTML conversion, + * and the Resend API call. + */ +export async function processAndSendEmail( + input: ProcessAndSendEmailInput, +): Promise { + const { to, cc = [], subject, text, html = "", headers = {}, room_id } = input; + + const roomData = room_id ? await selectRoomWithArtist(room_id) : null; + const footer = getEmailFooter(room_id, roomData?.artist_name || undefined); + const bodyHtml = html || (text ? await marked(text) : ""); + const htmlWithFooter = `${bodyHtml}\n\n${footer}`; + + const result = await sendEmailWithResend({ + from: RECOUP_FROM_EMAIL, + to, + cc: cc.length > 0 ? cc : undefined, + subject, + html: htmlWithFooter, + headers, + }); + + if (result instanceof NextResponse) { + const data = await result.json(); + return { + success: false, + error: data?.error?.message || `Failed to send email from ${RECOUP_FROM_EMAIL} to ${to.join(", ")}.`, + }; + } + + return { + success: true, + message: `Email sent successfully from ${RECOUP_FROM_EMAIL} to ${to.join(", ")}. CC: ${cc.length > 0 ? cc.join(", ") : "none"}.`, + id: result.id, + }; +} diff --git a/lib/mcp/tools/__tests__/registerSendEmailTool.test.ts b/lib/mcp/tools/__tests__/registerSendEmailTool.test.ts index 84c5344d..1d880847 100644 --- a/lib/mcp/tools/__tests__/registerSendEmailTool.test.ts +++ b/lib/mcp/tools/__tests__/registerSendEmailTool.test.ts @@ -1,17 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerSendEmailTool } from "../registerSendEmailTool"; -import { NextResponse } from "next/server"; -const mockSendEmailWithResend = vi.fn(); -const mockSelectRoomWithArtist = vi.fn(); +const mockProcessAndSendEmail = vi.fn(); -vi.mock("@/lib/emails/sendEmail", () => ({ - sendEmailWithResend: (...args: unknown[]) => mockSendEmailWithResend(...args), -})); - -vi.mock("@/lib/supabase/rooms/selectRoomWithArtist", () => ({ - selectRoomWithArtist: (...args: unknown[]) => mockSelectRoomWithArtist(...args), +vi.mock("@/lib/emails/processAndSendEmail", () => ({ + processAndSendEmail: (...args: unknown[]) => mockProcessAndSendEmail(...args), })); describe("registerSendEmailTool", () => { @@ -41,7 +35,11 @@ describe("registerSendEmailTool", () => { }); it("returns success when email is sent successfully", async () => { - mockSendEmailWithResend.mockResolvedValue({ id: "email-123" }); + mockProcessAndSendEmail.mockResolvedValue({ + success: true, + message: "Email sent successfully from Agent by Recoup to test@example.com. CC: none.", + id: "email-123", + }); const result = await registeredHandler({ to: ["test@example.com"], @@ -49,13 +47,10 @@ describe("registerSendEmailTool", () => { text: "Test body", }); - expect(mockSendEmailWithResend).toHaveBeenCalledWith({ - from: "Agent by Recoup ", + expect(mockProcessAndSendEmail).toHaveBeenCalledWith({ to: ["test@example.com"], - cc: undefined, subject: "Test Subject", - html: expect.stringMatching(/Test body.*you can reply directly to this email/s), - headers: {}, + text: "Test body", }); expect(result).toEqual({ @@ -68,8 +63,12 @@ describe("registerSendEmailTool", () => { }); }); - it("includes CC addresses when provided", async () => { - mockSendEmailWithResend.mockResolvedValue({ id: "email-123" }); + it("passes CC addresses through to processAndSendEmail", async () => { + mockProcessAndSendEmail.mockResolvedValue({ + success: true, + message: "Email sent successfully.", + id: "email-123", + }); await registeredHandler({ to: ["test@example.com"], @@ -77,16 +76,18 @@ describe("registerSendEmailTool", () => { subject: "Test Subject", }); - expect(mockSendEmailWithResend).toHaveBeenCalledWith( + expect(mockProcessAndSendEmail).toHaveBeenCalledWith( expect.objectContaining({ cc: ["cc@example.com"], }), ); }); - it("returns error when sendEmailWithResend returns NextResponse", async () => { - const errorResponse = NextResponse.json({ error: { message: "Rate limited" } }, { status: 429 }); - mockSendEmailWithResend.mockResolvedValue(errorResponse); + it("returns error when processAndSendEmail fails", async () => { + mockProcessAndSendEmail.mockResolvedValue({ + success: false, + error: "Rate limited", + }); const result = await registeredHandler({ to: ["test@example.com"], diff --git a/lib/mcp/tools/registerSendEmailTool.ts b/lib/mcp/tools/registerSendEmailTool.ts index 93d6565a..361b7827 100644 --- a/lib/mcp/tools/registerSendEmailTool.ts +++ b/lib/mcp/tools/registerSendEmailTool.ts @@ -1,13 +1,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { sendEmailSchema, type SendEmailInput } from "@/lib/emails/sendEmailSchema"; -import { sendEmailWithResend } from "@/lib/emails/sendEmail"; +import { processAndSendEmail } from "@/lib/emails/processAndSendEmail"; import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; import { RECOUP_FROM_EMAIL } from "@/lib/const"; -import { getEmailFooter } from "@/lib/emails/getEmailFooter"; -import { selectRoomWithArtist } from "@/lib/supabase/rooms/selectRoomWithArtist"; -import { NextResponse } from "next/server"; -import { marked } from "marked"; /** * Registers the "send_email" tool on the MCP server. @@ -23,33 +19,16 @@ export function registerSendEmailTool(server: McpServer): void { inputSchema: sendEmailSchema, }, async (args: SendEmailInput) => { - const { to, cc = [], subject, text, html = "", headers = {}, room_id } = args; + const result = await processAndSendEmail(args); - const roomData = room_id ? await selectRoomWithArtist(room_id) : null; - const footer = getEmailFooter(room_id, roomData?.artist_name || undefined); - const bodyHtml = html || (text ? marked(text) : ""); - const htmlWithFooter = `${bodyHtml}\n\n${footer}`; - - const result = await sendEmailWithResend({ - from: RECOUP_FROM_EMAIL, - to, - cc: cc.length > 0 ? cc : undefined, - subject, - html: htmlWithFooter, - headers, - }); - - if (result instanceof NextResponse) { - const data = await result.json(); - return getToolResultError( - data?.error?.message || `Failed to send email from ${RECOUP_FROM_EMAIL} to ${to}.`, - ); + if (result.success === false) { + return getToolResultError(result.error); } return getToolResultSuccess({ success: true, - message: `Email sent successfully from ${RECOUP_FROM_EMAIL} to ${to}. CC: ${cc.length > 0 ? JSON.stringify(cc) : "none"}.`, - data: result, + message: result.message, + data: { id: result.id }, }); }, ); diff --git a/lib/notifications/__tests__/createNotificationHandler.test.ts b/lib/notifications/__tests__/createNotificationHandler.test.ts new file mode 100644 index 00000000..c681d742 --- /dev/null +++ b/lib/notifications/__tests__/createNotificationHandler.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { createNotificationHandler } from "../createNotificationHandler"; + +const mockValidateAuthContext = vi.fn(); +const mockSelectAccountEmails = vi.fn(); +const mockProcessAndSendEmail = vi.fn(); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), +})); + +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: (...args: unknown[]) => mockSelectAccountEmails(...args), +})); + +vi.mock("@/lib/emails/processAndSendEmail", () => ({ + processAndSendEmail: (...args: unknown[]) => mockProcessAndSendEmail(...args), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(async (req: Request) => req.json()), +})); + +function createRequest(body: unknown): NextRequest { + return new NextRequest("https://recoup-api.vercel.app/api/notifications", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify(body), + }); +} + +describe("createNotificationHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidateAuthContext.mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-key", + }); + mockSelectAccountEmails.mockResolvedValue([ + { id: "email-1", account_id: "account-123", email: "owner@example.com", updated_at: "" }, + ]); + }); + + it("returns 401 when authentication fails", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const request = createRequest({ subject: "Test" }); + const response = await createNotificationHandler(request); + + expect(response.status).toBe(401); + }); + + it("returns 400 when body validation fails", async () => { + const request = createRequest({}); + const response = await createNotificationHandler(request); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.status).toBe("error"); + }); + + it("returns 400 when account has no email", async () => { + mockSelectAccountEmails.mockResolvedValue([]); + + const request = createRequest({ subject: "Test", text: "Hello" }); + const response = await createNotificationHandler(request); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toContain("No email address found"); + }); + + it("sends email to account owner with text body", async () => { + mockProcessAndSendEmail.mockResolvedValue({ + success: true, + message: "Email sent successfully from Agent by Recoup to owner@example.com. CC: none.", + id: "email-123", + }); + + const request = createRequest({ + subject: "Test Subject", + text: "Hello world", + }); + const response = await createNotificationHandler(request); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.success).toBe(true); + expect(data.id).toBe("email-123"); + expect(data.message).toContain("owner@example.com"); + expect(mockSelectAccountEmails).toHaveBeenCalledWith({ accountIds: "account-123" }); + expect(mockProcessAndSendEmail).toHaveBeenCalledWith({ + to: ["owner@example.com"], + cc: [], + subject: "Test Subject", + text: "Hello world", + html: "", + headers: {}, + room_id: undefined, + }); + }); + + it("passes CC and room_id through to processAndSendEmail", async () => { + mockProcessAndSendEmail.mockResolvedValue({ + success: true, + message: "Email sent successfully.", + id: "email-789", + }); + + const request = createRequest({ + cc: ["cc@example.com"], + subject: "Test", + text: "Hello", + room_id: "room-abc", + }); + const response = await createNotificationHandler(request); + + expect(response.status).toBe(200); + expect(mockProcessAndSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + cc: ["cc@example.com"], + room_id: "room-abc", + }), + ); + }); + + it("returns 502 when email delivery fails", async () => { + mockProcessAndSendEmail.mockResolvedValue({ + success: false, + error: "Rate limited", + }); + + const request = createRequest({ + subject: "Test", + text: "Hello", + }); + const response = await createNotificationHandler(request); + + expect(response.status).toBe(502); + const data = await response.json(); + expect(data.status).toBe("error"); + expect(data.error).toContain("Rate limited"); + }); +}); diff --git a/lib/notifications/__tests__/validateCreateNotificationBody.test.ts b/lib/notifications/__tests__/validateCreateNotificationBody.test.ts new file mode 100644 index 00000000..d988dadb --- /dev/null +++ b/lib/notifications/__tests__/validateCreateNotificationBody.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; +import { validateCreateNotificationBody } from "../validateCreateNotificationBody"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +describe("validateCreateNotificationBody", () => { + it("returns validated body for valid input with subject and text", () => { + const result = validateCreateNotificationBody({ + subject: "Test Subject", + text: "Hello world", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual( + expect.objectContaining({ + subject: "Test Subject", + text: "Hello world", + }), + ); + }); + + it("returns validated body with all optional fields", () => { + const result = validateCreateNotificationBody({ + cc: ["cc@example.com"], + subject: "Test Subject", + text: "Hello", + html: "

Hello

", + headers: { "X-Custom": "value" }, + room_id: "room-123", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual( + expect.objectContaining({ + cc: ["cc@example.com"], + subject: "Test Subject", + text: "Hello", + html: "

Hello

", + headers: { "X-Custom": "value" }, + room_id: "room-123", + }), + ); + }); + + it("returns 400 when 'subject' is missing", () => { + const result = validateCreateNotificationBody({ + text: "Hello", + }); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when 'subject' is empty", () => { + const result = validateCreateNotificationBody({ + subject: "", + }); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when 'cc' contains invalid email", () => { + const result = validateCreateNotificationBody({ + subject: "Test", + cc: ["not-valid"], + }); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("accepts subject-only body (no text or html)", () => { + const result = validateCreateNotificationBody({ + subject: "Test Subject", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual( + expect.objectContaining({ + subject: "Test Subject", + }), + ); + }); +}); diff --git a/lib/notifications/createNotificationHandler.ts b/lib/notifications/createNotificationHandler.ts new file mode 100644 index 00000000..23a75f41 --- /dev/null +++ b/lib/notifications/createNotificationHandler.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateCreateNotificationBody } from "./validateCreateNotificationBody"; +import { processAndSendEmail } from "@/lib/emails/processAndSendEmail"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +/** + * Handler for POST /api/notifications. + * Sends a notification email to the authenticated account's email address. + * The recipient is automatically resolved from the API key or Bearer token. + * Requires authentication via x-api-key header or Authorization bearer token. + * + * @param request - The request object. + * @returns A NextResponse with the send result. + */ +export async function createNotificationHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const body = await safeParseJson(request); + const validated = validateCreateNotificationBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + const { cc = [], subject, text, html = "", headers = {}, room_id } = validated; + + // Resolve recipient email from authenticated account + const accountEmails = await selectAccountEmails({ accountIds: authResult.accountId }); + const recipientEmail = accountEmails?.[0]?.email; + + if (!recipientEmail) { + return NextResponse.json( + { + status: "error", + error: "No email address found for the authenticated account.", + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const result = await processAndSendEmail({ + to: [recipientEmail], + cc, + subject, + text, + html, + headers, + room_id, + }); + + if (result.success === false) { + return NextResponse.json( + { + status: "error", + error: result.error, + }, + { + status: 502, + headers: getCorsHeaders(), + }, + ); + } + + return NextResponse.json( + { + success: true, + message: result.message, + id: result.id, + }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); +} diff --git a/lib/notifications/validateCreateNotificationBody.ts b/lib/notifications/validateCreateNotificationBody.ts new file mode 100644 index 00000000..cbe1e99c --- /dev/null +++ b/lib/notifications/validateCreateNotificationBody.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const createNotificationBodySchema = z.object({ + cc: z.array(z.string().email("each 'cc' entry must be a valid email")).default([]).optional(), + subject: z.string({ message: "subject is required" }).min(1, "subject cannot be empty"), + text: z.string().optional(), + html: z.string().default("").optional(), + headers: z.record(z.string(), z.string()).default({}).optional(), + room_id: z.string().optional(), +}); + +export type CreateNotificationBody = z.infer; + +/** + * Validates request body for POST /api/notifications. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body. + */ +export function validateCreateNotificationBody(body: unknown): NextResponse | CreateNotificationBody { + const result = createNotificationBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +}