diff --git a/lib/const.ts b/lib/const.ts index 271afcf3..72902d2a 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -19,3 +19,6 @@ export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com"; export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com"; export const SUPABASE_STORAGE_BUCKET = "user-files"; + +/** Default from address for outbound emails sent by the agent */ +export const RECOUP_FROM_EMAIL = `Agent by Recoup `; diff --git a/lib/emails/sendEmailSchema.ts b/lib/emails/sendEmailSchema.ts new file mode 100644 index 00000000..97198294 --- /dev/null +++ b/lib/emails/sendEmailSchema.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +export const sendEmailSchema = z.object({ + to: z.array(z.string()).describe("Recipient email address or array of addresses"), + cc: z + .array(z.string()) + .describe( + "Optional array of CC email addresses. active_account_email should always be included unless already in 'to'.", + ) + .default([]) + .optional(), + subject: z.string().describe("Email subject line"), + text: z + .string() + .describe("Plain text body of the email. Use context to make this creative and engaging.") + .optional(), + html: z + .string() + .describe("HTML body of the email. Use context to make this creative and engaging.") + .default("") + .optional(), + headers: z + .record(z.string(), z.string()) + .describe("Optional custom headers for the email") + .default({}) + .optional(), +}); + +export type SendEmailInput = z.infer; diff --git a/lib/mcp/tools/__tests__/registerSendEmailTool.test.ts b/lib/mcp/tools/__tests__/registerSendEmailTool.test.ts new file mode 100644 index 00000000..848c7406 --- /dev/null +++ b/lib/mcp/tools/__tests__/registerSendEmailTool.test.ts @@ -0,0 +1,101 @@ +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(); + +vi.mock("@/lib/emails/sendEmail", () => ({ + sendEmailWithResend: (...args: unknown[]) => mockSendEmailWithResend(...args), +})); + +describe("registerSendEmailTool", () => { + let mockServer: McpServer; + let registeredHandler: (args: unknown) => Promise; + + beforeEach(() => { + vi.clearAllMocks(); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredHandler = handler; + }), + } as unknown as McpServer; + + registerSendEmailTool(mockServer); + }); + + it("registers the send_email tool", () => { + expect(mockServer.registerTool).toHaveBeenCalledWith( + "send_email", + expect.objectContaining({ + description: expect.stringContaining("Send an email using the Resend API"), + }), + expect.any(Function), + ); + }); + + it("returns success when email is sent successfully", async () => { + mockSendEmailWithResend.mockResolvedValue({ id: "email-123" }); + + const result = await registeredHandler({ + to: ["test@example.com"], + subject: "Test Subject", + text: "Test body", + }); + + expect(mockSendEmailWithResend).toHaveBeenCalledWith({ + from: "Agent by Recoup ", + to: ["test@example.com"], + cc: undefined, + subject: "Test Subject", + text: "Test body", + html: undefined, + headers: {}, + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Email sent successfully"), + }, + ], + }); + }); + + it("includes CC addresses when provided", async () => { + mockSendEmailWithResend.mockResolvedValue({ id: "email-123" }); + + await registeredHandler({ + to: ["test@example.com"], + cc: ["cc@example.com"], + subject: "Test Subject", + }); + + expect(mockSendEmailWithResend).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); + + const result = await registeredHandler({ + to: ["test@example.com"], + subject: "Test Subject", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Rate limited"), + }, + ], + }); + }); +}); diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index 033b80a6..69199f36 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -13,6 +13,7 @@ import { registerAllFileTools } from "./files"; import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool"; import { registerAllYouTubeTools } from "./youtube"; import { registerTranscribeTools } from "./transcribe"; +import { registerSendEmailTool } from "./registerSendEmailTool"; /** * Registers all MCP tools on the server. @@ -35,4 +36,5 @@ export const registerAllTools = (server: McpServer): void => { registerUpdateAccountInfoTool(server); registerCreateSegmentsTool(server); registerAllYouTubeTools(server); + registerSendEmailTool(server); }; diff --git a/lib/mcp/tools/registerSendEmailTool.ts b/lib/mcp/tools/registerSendEmailTool.ts new file mode 100644 index 00000000..8f0c02ef --- /dev/null +++ b/lib/mcp/tools/registerSendEmailTool.ts @@ -0,0 +1,49 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { sendEmailSchema, type SendEmailInput } from "@/lib/emails/sendEmailSchema"; +import { sendEmailWithResend } from "@/lib/emails/sendEmail"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { RECOUP_FROM_EMAIL } from "@/lib/const"; +import { NextResponse } from "next/server"; + +/** + * Registers the "send_email" tool on the MCP server. + * Send an email using the Resend API. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerSendEmailTool(server: McpServer): void { + server.registerTool( + "send_email", + { + description: `Send an email using the Resend API. Requires 'to' and 'subject'. Optionally include 'text', 'html', and custom headers.\n\nNotes:\n- Emails are sent from ${RECOUP_FROM_EMAIL}.\n- Use context to make the email creative and engaging.\n- Use this tool to send transactional or notification emails to users or admins.`, + inputSchema: sendEmailSchema, + }, + async (args: SendEmailInput) => { + const { to, cc = [], subject, text, html = "", headers = {} } = args; + + const result = await sendEmailWithResend({ + from: RECOUP_FROM_EMAIL, + to, + cc: cc.length > 0 ? cc : undefined, + subject, + text, + html: html || undefined, + 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}.`, + ); + } + + return getToolResultSuccess({ + success: true, + message: `Email sent successfully from ${RECOUP_FROM_EMAIL} to ${to}. CC: ${cc.length > 0 ? JSON.stringify(cc) : "none"}.`, + data: result, + }); + }, + ); +}