Skip to content
Merged
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
41 changes: 41 additions & 0 deletions app/api/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
return createNotificationHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
114 changes: 114 additions & 0 deletions lib/emails/__tests__/processAndSendEmail.test.ts
Original file line number Diff line number Diff line change
@@ -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 <agent@recoupable.com>",
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: "<h1>HTML body</h1>",
});

expect(mockSendEmailWithResend).toHaveBeenCalledWith(
expect.objectContaining({
html: expect.stringContaining("<h1>HTML body</h1>"),
}),
);
});

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");
}
});
});
70 changes: 70 additions & 0 deletions lib/emails/processAndSendEmail.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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<ProcessAndSendEmailResult> {
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,
};
}
43 changes: 22 additions & 21 deletions lib/mcp/tools/__tests__/registerSendEmailTool.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -41,21 +35,22 @@ 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 <agent@recoupable.com> to test@example.com. CC: none.",
id: "email-123",
});

const result = await registeredHandler({
to: ["test@example.com"],
subject: "Test Subject",
text: "Test body",
});

expect(mockSendEmailWithResend).toHaveBeenCalledWith({
from: "Agent by Recoup <agent@recoupable.com>",
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({
Expand All @@ -68,25 +63,31 @@ 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"],
cc: ["cc@example.com"],
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"],
Expand Down
33 changes: 6 additions & 27 deletions lib/mcp/tools/registerSendEmailTool.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 },
});
},
);
Expand Down
Loading