diff --git a/CLAUDE.md b/CLAUDE.md index 981b7ad9..42327a58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,8 +51,11 @@ pnpm format:check # Check formatting - `lib/trigger/` - Trigger.dev task triggers - `lib/x402/` - Payment middleware utilities -## Key Patterns +## Code Principles +- **SRP (Single Responsibility Principle)**: One exported function per file. Each file should do one thing well. +- **DRY (Don't Repeat Yourself)**: Extract shared logic into reusable utilities. +- **KISS (Keep It Simple)**: Prefer simple solutions over clever ones. - All API routes should have JSDoc comments - Run `pnpm lint` before committing diff --git a/lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts b/lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts new file mode 100644 index 00000000..2db2eb66 --- /dev/null +++ b/lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { extractRoomIdFromText } from "../extractRoomIdFromText"; + +describe("extractRoomIdFromText", () => { + describe("valid chat links", () => { + it("extracts roomId from a valid Recoup chat link", () => { + const text = + "Check out this chat: https://chat.recoupable.com/chat/550e8400-e29b-41d4-a716-446655440000"; + + const result = extractRoomIdFromText(text); + + expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); + }); + + it("extracts roomId from chat link embedded in longer text", () => { + const text = ` + Hey there, + + I wanted to follow up on our conversation. + Here's the link: https://chat.recoupable.com/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890 + + Let me know if you have questions. + `; + + const result = extractRoomIdFromText(text); + + expect(result).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + }); + + it("handles case-insensitive domain matching", () => { + const text = "Visit HTTPS://CHAT.RECOUPABLE.COM/CHAT/12345678-1234-1234-1234-123456789abc"; + + const result = extractRoomIdFromText(text); + + expect(result).toBe("12345678-1234-1234-1234-123456789abc"); + }); + + it("extracts first roomId when multiple links present", () => { + const text = ` + First link: https://chat.recoupable.com/chat/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee + Second link: https://chat.recoupable.com/chat/11111111-2222-3333-4444-555555555555 + `; + + const result = extractRoomIdFromText(text); + + expect(result).toBe("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + }); + }); + + describe("invalid inputs", () => { + it("returns undefined for undefined input", () => { + const result = extractRoomIdFromText(undefined); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for empty string", () => { + const result = extractRoomIdFromText(""); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when no chat link present", () => { + const text = "This email has no Recoup chat link."; + + const result = extractRoomIdFromText(text); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for invalid UUID format in link", () => { + const text = "https://chat.recoupable.com/chat/not-a-valid-uuid"; + + const result = extractRoomIdFromText(text); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for partial UUID", () => { + const text = "https://chat.recoupable.com/chat/550e8400-e29b-41d4"; + + const result = extractRoomIdFromText(text); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for wrong domain", () => { + const text = "https://chat.otherdomain.com/chat/550e8400-e29b-41d4-a716-446655440000"; + + const result = extractRoomIdFromText(text); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for wrong path structure", () => { + const text = "https://chat.recoupable.com/room/550e8400-e29b-41d4-a716-446655440000"; + + const result = extractRoomIdFromText(text); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/lib/emails/inbound/__tests__/getEmailRoomId.test.ts b/lib/emails/inbound/__tests__/getEmailRoomId.test.ts new file mode 100644 index 00000000..690beb59 --- /dev/null +++ b/lib/emails/inbound/__tests__/getEmailRoomId.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getEmailRoomId } from "../getEmailRoomId"; +import type { GetReceivingEmailResponseSuccess } from "resend"; + +import selectMemoryEmails from "@/lib/supabase/memory_emails/selectMemoryEmails"; + +vi.mock("@/lib/supabase/memory_emails/selectMemoryEmails", () => ({ + default: vi.fn(), +})); + +const mockSelectMemoryEmails = vi.mocked(selectMemoryEmails); + +describe("getEmailRoomId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("primary: extracting from email text", () => { + it("returns roomId when chat link found in email text", async () => { + const emailContent = { + text: "Check out this chat: https://chat.recoupable.com/chat/550e8400-e29b-41d4-a716-446655440000", + headers: { references: "" }, + } as GetReceivingEmailResponseSuccess; + + const result = await getEmailRoomId(emailContent); + + expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(mockSelectMemoryEmails).not.toHaveBeenCalled(); + }); + + it("prioritizes chat link over references header", async () => { + mockSelectMemoryEmails.mockResolvedValue([ + { memories: { room_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" } }, + ] as Awaited>); + + const emailContent = { + text: "Link: https://chat.recoupable.com/chat/11111111-2222-3333-4444-555555555555", + headers: { references: "" }, + } as GetReceivingEmailResponseSuccess; + + const result = await getEmailRoomId(emailContent); + + expect(result).toBe("11111111-2222-3333-4444-555555555555"); + expect(mockSelectMemoryEmails).not.toHaveBeenCalled(); + }); + }); + + describe("fallback: checking references header", () => { + it("falls back to references header when no chat link in text", async () => { + mockSelectMemoryEmails.mockResolvedValue([ + { memories: { room_id: "22222222-3333-4444-5555-666666666666" } }, + ] as Awaited>); + + const emailContent = { + text: "No chat link here", + headers: { references: "" }, + } as GetReceivingEmailResponseSuccess; + + const result = await getEmailRoomId(emailContent); + + expect(result).toBe("22222222-3333-4444-5555-666666666666"); + expect(mockSelectMemoryEmails).toHaveBeenCalledWith({ + messageIds: [""], + }); + }); + + it("parses space-separated references header", async () => { + mockSelectMemoryEmails.mockResolvedValue([ + { memories: { room_id: "33333333-4444-5555-6666-777777777777" } }, + ] as Awaited>); + + const emailContent = { + text: undefined, + headers: { + references: " ", + }, + } as GetReceivingEmailResponseSuccess; + + const result = await getEmailRoomId(emailContent); + + expect(mockSelectMemoryEmails).toHaveBeenCalledWith({ + messageIds: ["", "", ""], + }); + expect(result).toBe("33333333-4444-5555-6666-777777777777"); + }); + + it("parses newline-separated references header", async () => { + mockSelectMemoryEmails.mockResolvedValue([ + { memories: { room_id: "44444444-5555-6666-7777-888888888888" } }, + ] as Awaited>); + + const emailContent = { + text: "", + headers: { + references: "\n", + }, + } as GetReceivingEmailResponseSuccess; + + const result = await getEmailRoomId(emailContent); + + expect(mockSelectMemoryEmails).toHaveBeenCalledWith({ + messageIds: ["", ""], + }); + expect(result).toBe("44444444-5555-6666-7777-888888888888"); + }); + }); + + describe("returning undefined", () => { + it("returns undefined when no chat link and no references header", async () => { + const emailContent = { + text: "No chat link here", + headers: {}, + } as GetReceivingEmailResponseSuccess; + + const result = await getEmailRoomId(emailContent); + + expect(result).toBeUndefined(); + expect(mockSelectMemoryEmails).not.toHaveBeenCalled(); + }); + + it("returns undefined when references header is empty", async () => { + const emailContent = { + text: "No chat link", + headers: { references: "" }, + } as GetReceivingEmailResponseSuccess; + + const result = await getEmailRoomId(emailContent); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when no memory_emails found for references", async () => { + mockSelectMemoryEmails.mockResolvedValue([]); + + const emailContent = { + text: "No link", + headers: { references: "" }, + } as GetReceivingEmailResponseSuccess; + + const result = await getEmailRoomId(emailContent); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when memory_email has no associated memory", async () => { + mockSelectMemoryEmails.mockResolvedValue([{ memories: null }] as unknown as Awaited< + ReturnType + >); + + const emailContent = { + text: "No link", + headers: { references: "" }, + } as GetReceivingEmailResponseSuccess; + + const result = await getEmailRoomId(emailContent); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/lib/emails/inbound/extractRoomIdFromText.ts b/lib/emails/inbound/extractRoomIdFromText.ts new file mode 100644 index 00000000..446bdbbe --- /dev/null +++ b/lib/emails/inbound/extractRoomIdFromText.ts @@ -0,0 +1,13 @@ +const CHAT_LINK_REGEX = /https:\/\/chat\.recoupable\.com\/chat\/([0-9a-f-]{36})/i; + +/** + * Extracts the roomId from the email text body by looking for a Recoup chat link. + * + * @param text - The email text body + * @returns The roomId if found, undefined otherwise + */ +export function extractRoomIdFromText(text: string | undefined): string | undefined { + if (!text) return undefined; + const match = text.match(CHAT_LINK_REGEX); + return match?.[1]; +} diff --git a/lib/emails/inbound/extractTextFromParts.ts b/lib/emails/inbound/extractTextFromParts.ts new file mode 100644 index 00000000..7b3fb1b5 --- /dev/null +++ b/lib/emails/inbound/extractTextFromParts.ts @@ -0,0 +1,17 @@ +interface UIPart { + type: string; + text?: string; +} + +/** + * Extracts text content from UI parts. + * + * @param parts - UI parts from stored memory + * @returns Combined text string from all text parts + */ +export function extractTextFromParts(parts: UIPart[]): string { + return parts + .filter(p => p.type === "text" && p.text) + .map(p => p.text!) + .join("\n"); +} diff --git a/lib/emails/inbound/getEmailRoomId.ts b/lib/emails/inbound/getEmailRoomId.ts index 13937892..ef889381 100644 --- a/lib/emails/inbound/getEmailRoomId.ts +++ b/lib/emails/inbound/getEmailRoomId.ts @@ -1,8 +1,10 @@ import type { GetReceivingEmailResponseSuccess } from "resend"; import selectMemoryEmails from "@/lib/supabase/memory_emails/selectMemoryEmails"; +import { extractRoomIdFromText } from "./extractRoomIdFromText"; /** - * Extracts the roomId from an email's references header by looking up existing memory_emails. + * Extracts the roomId from an email. First checks the email text for a Recoup chat link, + * then falls back to looking up existing memory_emails via the references header. * * @param emailContent - The email content from Resend's Receiving API * @returns The roomId if found, undefined otherwise @@ -10,6 +12,13 @@ import selectMemoryEmails from "@/lib/supabase/memory_emails/selectMemoryEmails" export async function getEmailRoomId( emailContent: GetReceivingEmailResponseSuccess, ): Promise { + // Primary: check email text for Recoup chat link + const roomIdFromText = extractRoomIdFromText(emailContent.text); + if (roomIdFromText) { + return roomIdFromText; + } + + // Fallback: check references header for existing memory_emails const references = emailContent.headers?.references; if (!references) { return undefined; diff --git a/lib/emails/inbound/getEmailRoomMessages.ts b/lib/emails/inbound/getEmailRoomMessages.ts index cea1fa99..daf20275 100644 --- a/lib/emails/inbound/getEmailRoomMessages.ts +++ b/lib/emails/inbound/getEmailRoomMessages.ts @@ -1,24 +1,38 @@ import type { ModelMessage } from "ai"; import selectMemories from "@/lib/supabase/memories/selectMemories"; +import { extractTextFromParts } from "./extractTextFromParts"; + +interface MemoryContent { + role: string; + parts: { type: string; text?: string }[]; +} /** * Builds a messages array for agent.generate, including conversation history if roomId exists. + * Converts UI parts to simple text-based ModelMessages for compatibility. * * @param roomId - Optional room ID to fetch existing conversation history * @returns Array of ModelMessage objects with conversation history */ export async function getEmailRoomMessages(roomId: string): Promise { - let messages: ModelMessage[] = []; - const existingMemories = await selectMemories(roomId, { ascending: true }); - if (existingMemories) { - messages = existingMemories.map(memory => { - const content = memory.content as { role: string; parts: unknown[] }; - return { - role: content.role as "user" | "assistant" | "system", - content: content.parts, - } as ModelMessage; - }); + if (!existingMemories) return []; + + const messages: ModelMessage[] = []; + + for (const memory of existingMemories) { + const content = memory.content as unknown as MemoryContent; + if (!content?.role || !content?.parts) continue; + + const role = content.role; + let text = ""; + + if (role === "user" || role === "assistant") { + text = extractTextFromParts(content.parts); + if (text) { + messages.push({ role, content: text }); + } + } } return messages; diff --git a/lib/emails/inbound/validateCcReplyExpected.ts b/lib/emails/inbound/validateCcReplyExpected.ts index ad33f258..27c5dc7f 100644 --- a/lib/emails/inbound/validateCcReplyExpected.ts +++ b/lib/emails/inbound/validateCcReplyExpected.ts @@ -24,7 +24,6 @@ export async function validateCcReplyExpected( // If recoup email is only in TO (not CC), always reply - skip LLM call if (isInTo && !isInCc) { - console.log("[validateCcReplyExpected] Recoup email in TO only, replying"); return null; } @@ -38,7 +37,6 @@ export async function validateCcReplyExpected( }); if (!shouldReply) { - console.log("[validateCcReplyExpected] No reply expected, skipping"); return { response: NextResponse.json({ message: "No reply expected" }, { status: 200 }), };