-
Notifications
You must be signed in to change notification settings - Fork 1
Test #96
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Test #96
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
11d216b
feat: add send_email MCP tool
sweetmantech 77d163f
refactor: use OUTBOUND_EMAIL_DOMAIN in RECOUP_FROM_EMAIL constant
sweetmantech 88a19a4
Merge pull request #94 from Recoupable-com/sweetmantech/myc-3868-chat…
sweetmantech ec9eb10
docs: sync test branch with main when starting new tasks
sweetmantech 5076d40
feat: add shared email footer and send_email MCP tool
sweetmantech a3519ef
docs: note PRs should target test branch
sweetmantech d48983c
merge: resolve conflicts with test branch, add email footer to send_e…
sweetmantech a9ed734
fix: always use HTML for emails, convert text to HTML if needed
sweetmantech 54bb239
Merge pull request #95 from Recoupable-com/sweetmantech/myc-3863-api-…
sweetmantech 1f05769
refactor: use marked for text-to-HTML conversion in send_email tool
sweetmantech ed45944
refactor: use zod email validation for to and cc fields
sweetmantech File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
sweetmantech marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| : ""; | ||
sweetmantech marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return ` | ||
| <hr style="margin-top:24px;margin-bottom:16px;border:none;border-top:1px solid #e5e7eb;" /> | ||
| ${replyNote} | ||
| ${chatLink}`.trim(); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"), | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}.`, | ||
sweetmantech marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ); | ||
| } | ||
sweetmantech marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return getToolResultSuccess({ | ||
| success: true, | ||
| message: `Email sent successfully from ${RECOUP_FROM_EMAIL} to ${to}. CC: ${cc.length > 0 ? JSON.stringify(cc) : "none"}.`, | ||
| data: result, | ||
| }); | ||
| }, | ||
| ); | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.