Skip to content
Merged

Test #96

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
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com";
/** Domain for sending outbound emails (e.g., [email protected]) */
export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com";

/** Default from address for outbound emails */
export const RECOUP_FROM_EMAIL = `Agent by Recoup <agent${OUTBOUND_EMAIL_DOMAIN}>`;

export const SUPABASE_STORAGE_BUCKET = "user-files";
41 changes: 41 additions & 0 deletions lib/emails/__tests__/getEmailFooter.test.ts
Original file line number Diff line number Diff line change
@@ -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("<hr");
});

it("excludes chat link when roomId is not provided", () => {
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");
});
});
27 changes: 27 additions & 0 deletions lib/emails/getEmailFooter.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<p style="font-size:12px;color:#6b7280;margin:0 0 4px;">
Note: you can reply directly to this email to continue the conversation.
</p>`.trim();

const chatLink = roomId
? `
<p style="font-size:12px;color:#6b7280;margin:0;">
Or continue the conversation on Recoup:
<a href="https://chat.recoupable.com/chat/${roomId}" target="_blank" rel="noopener noreferrer">
https://chat.recoupable.com/chat/${roomId}
</a>
</p>`.trim()
: "";

return `
<hr style="margin-top:24px;margin-bottom:16px;border:none;border-top:1px solid #e5e7eb;" />
${replyNote}
${chatLink}`.trim();
}
16 changes: 2 additions & 14 deletions lib/emails/inbound/generateEmailResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -29,20 +30,7 @@ export async function generateEmailResponse(
const text = chatResponse.text;

const bodyHtml = marked(text);

const footerHtml = `
<hr style="margin-top:24px;margin-bottom:16px;border:none;border-top:1px solid #e5e7eb;" />
<p style="font-size:12px;color:#6b7280;margin:0 0 4px;">
Note: you can reply directly to this email to continue the conversation.
</p>
<p style="font-size:12px;color:#6b7280;margin:0;">
Or continue the conversation on Recoup:
<a href="https://chat.recoupable.com/chat/${roomId}" target="_blank" rel="noopener noreferrer">
https://chat.recoupable.com/chat/${roomId}
</a>
</p>
`.trim();

const footerHtml = getEmailFooter(roomId);
const html = `${bodyHtml}\n\n${footerHtml}`;

return { text, html };
Expand Down
33 changes: 33 additions & 0 deletions lib/emails/sendEmailSchema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof sendEmailSchema>;
100 changes: 100 additions & 0 deletions lib/mcp/tools/__tests__/registerSendEmailTool.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;

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: ["[email protected]"],
subject: "Test Subject",
text: "Test body",
});

expect(mockSendEmailWithResend).toHaveBeenCalledWith({
from: "Agent by Recoup <[email protected]>",
to: ["[email protected]"],
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: ["[email protected]"],
cc: ["[email protected]"],
subject: "Test Subject",
});

expect(mockSendEmailWithResend).toHaveBeenCalledWith(
expect.objectContaining({
cc: ["[email protected]"],
}),
);
});

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: ["[email protected]"],
subject: "Test Subject",
});

expect(result).toEqual({
content: [
{
type: "text",
text: expect.stringContaining("Rate limited"),
},
],
});
});
});
2 changes: 2 additions & 0 deletions lib/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -32,6 +33,7 @@ export const registerAllTools = (server: McpServer): void => {
registerContactTeamTool(server);
registerGetLocalTimeTool(server);
registerSearchWebTool(server);
registerSendEmailTool(server);
registerUpdateAccountInfoTool(server);
registerCreateSegmentsTool(server);
registerAllYouTubeTools(server);
Expand Down
54 changes: 54 additions & 0 deletions lib/mcp/tools/registerSendEmailTool.ts
Original file line number Diff line number Diff line change
@@ -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,
});
},
);
}