diff --git a/CLAUDE.md b/CLAUDE.md index 4300a2ce..86debadf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 2. Push commits to the current feature branch 3. **NEVER push directly to `main` or `test` branches** - always use feature branches and PRs 4. Before pushing, verify the current branch is not `main` or `test` +5. **Open PRs against the `test` branch**, not `main` + +### Starting a New Task + +When starting a new task, **first sync the `test` branch with `main`**: + +```bash +git checkout test && git pull origin test && git fetch origin main && git merge origin/main && git push origin test +``` + +Then checkout main, pull latest, and create your feature branch from there. + +This is the **only** time you should push directly to `test`. ## Build Commands diff --git a/lib/const.ts b/lib/const.ts index 271afcf3..8717e4a5 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -18,4 +18,7 @@ export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com"; /** Domain for sending outbound emails (e.g., support@recoupable.com) */ export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com"; +/** Default from address for outbound emails */ +export const RECOUP_FROM_EMAIL = `Agent by Recoup `; + export const SUPABASE_STORAGE_BUCKET = "user-files"; diff --git a/lib/emails/__tests__/getEmailFooter.test.ts b/lib/emails/__tests__/getEmailFooter.test.ts new file mode 100644 index 00000000..0e5890a9 --- /dev/null +++ b/lib/emails/__tests__/getEmailFooter.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { getEmailFooter } from "../getEmailFooter"; + +describe("getEmailFooter", () => { + it("includes reply note in all cases", () => { + const footer = getEmailFooter(); + expect(footer).toContain("you can reply directly to this email"); + }); + + it("includes horizontal rule", () => { + const footer = getEmailFooter(); + expect(footer).toContain(" { + const footer = getEmailFooter(); + expect(footer).not.toContain("chat.recoupable.com"); + expect(footer).not.toContain("Or continue the conversation"); + }); + + it("includes chat link when roomId is provided", () => { + const roomId = "test-room-123"; + const footer = getEmailFooter(roomId); + expect(footer).toContain(`https://chat.recoupable.com/chat/${roomId}`); + expect(footer).toContain("Or continue the conversation on Recoup"); + }); + + it("generates proper HTML with roomId", () => { + const roomId = "my-room-id"; + const footer = getEmailFooter(roomId); + expect(footer).toContain(`href="https://chat.recoupable.com/chat/${roomId}"`); + expect(footer).toContain('target="_blank"'); + expect(footer).toContain('rel="noopener noreferrer"'); + }); + + it("applies proper styling", () => { + const footer = getEmailFooter("room-id"); + expect(footer).toContain("font-size:12px"); + expect(footer).toContain("color:#6b7280"); + }); +}); diff --git a/lib/emails/getEmailFooter.ts b/lib/emails/getEmailFooter.ts new file mode 100644 index 00000000..2557ef35 --- /dev/null +++ b/lib/emails/getEmailFooter.ts @@ -0,0 +1,27 @@ +/** + * Generates a standardized email footer HTML. + * + * @param roomId - Optional room ID for the chat link. If not provided, only the reply note is shown. + * @returns HTML string for the email footer. + */ +export function getEmailFooter(roomId?: string): string { + const replyNote = ` +

+ Note: you can reply directly to this email to continue the conversation. +

`.trim(); + + const chatLink = roomId + ? ` +

+ Or continue the conversation on Recoup: + + https://chat.recoupable.com/chat/${roomId} + +

`.trim() + : ""; + + return ` +
+${replyNote} +${chatLink}`.trim(); +} diff --git a/lib/emails/inbound/generateEmailResponse.ts b/lib/emails/inbound/generateEmailResponse.ts index 9cfd0113..95f19c43 100644 --- a/lib/emails/inbound/generateEmailResponse.ts +++ b/lib/emails/inbound/generateEmailResponse.ts @@ -2,6 +2,7 @@ import { marked } from "marked"; import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; import getGeneralAgent from "@/lib/agents/generalAgent/getGeneralAgent"; import { getEmailRoomMessages } from "@/lib/emails/inbound/getEmailRoomMessages"; +import { getEmailFooter } from "@/lib/emails/getEmailFooter"; /** * Generates the assistant response HTML for an email, including: @@ -29,20 +30,7 @@ export async function generateEmailResponse( const text = chatResponse.text; const bodyHtml = marked(text); - - const footerHtml = ` -
-

- Note: you can reply directly to this email to continue the conversation. -

-

- Or continue the conversation on Recoup: - - https://chat.recoupable.com/chat/${roomId} - -

-`.trim(); - + const footerHtml = getEmailFooter(roomId); const html = `${bodyHtml}\n\n${footerHtml}`; return { text, html }; diff --git a/lib/emails/sendEmailSchema.ts b/lib/emails/sendEmailSchema.ts new file mode 100644 index 00000000..820a59de --- /dev/null +++ b/lib/emails/sendEmailSchema.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +export const sendEmailSchema = z.object({ + to: z.array(z.string().email()).describe("Recipient email address or array of addresses"), + cc: z + .array(z.string().email()) + .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(), + room_id: z + .string() + .describe("Optional room ID to include in the email footer link") + .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..509e1aba --- /dev/null +++ b/lib/mcp/tools/__tests__/registerSendEmailTool.test.ts @@ -0,0 +1,100 @@ +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", + html: expect.stringMatching(/Test body.*you can reply directly to this email/s), + 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..39261efa 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. @@ -32,6 +33,7 @@ export const registerAllTools = (server: McpServer): void => { registerContactTeamTool(server); registerGetLocalTimeTool(server); registerSearchWebTool(server); + registerSendEmailTool(server); registerUpdateAccountInfoTool(server); registerCreateSegmentsTool(server); registerAllYouTubeTools(server); diff --git a/lib/mcp/tools/registerSendEmailTool.ts b/lib/mcp/tools/registerSendEmailTool.ts new file mode 100644 index 00000000..3f29eb5d --- /dev/null +++ b/lib/mcp/tools/registerSendEmailTool.ts @@ -0,0 +1,54 @@ +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 { getEmailFooter } from "@/lib/emails/getEmailFooter"; +import { NextResponse } from "next/server"; +import { marked } from "marked"; + +/** + * 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 = {}, room_id } = args; + + const footer = getEmailFooter(room_id); + 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}.`, + ); + } + + return getToolResultSuccess({ + success: true, + message: `Email sent successfully from ${RECOUP_FROM_EMAIL} to ${to}. CC: ${cc.length > 0 ? JSON.stringify(cc) : "none"}.`, + data: result, + }); + }, + ); +}