Skip to content
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
103 changes: 103 additions & 0 deletions lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
160 changes: 160 additions & 0 deletions lib/emails/inbound/__tests__/getEmailRoomId.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<old-message-id@example.com>" },
} 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<ReturnType<typeof selectMemoryEmails>>);

const emailContent = {
text: "Link: https://chat.recoupable.com/chat/11111111-2222-3333-4444-555555555555",
headers: { references: "<message-id@example.com>" },
} 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<ReturnType<typeof selectMemoryEmails>>);

const emailContent = {
text: "No chat link here",
headers: { references: "<message-id@example.com>" },
} as GetReceivingEmailResponseSuccess;

const result = await getEmailRoomId(emailContent);

expect(result).toBe("22222222-3333-4444-5555-666666666666");
expect(mockSelectMemoryEmails).toHaveBeenCalledWith({
messageIds: ["<message-id@example.com>"],
});
});

it("parses space-separated references header", async () => {
mockSelectMemoryEmails.mockResolvedValue([
{ memories: { room_id: "33333333-4444-5555-6666-777777777777" } },
] as Awaited<ReturnType<typeof selectMemoryEmails>>);

const emailContent = {
text: undefined,
headers: {
references: "<first@example.com> <second@example.com> <third@example.com>",
},
} as GetReceivingEmailResponseSuccess;

const result = await getEmailRoomId(emailContent);

expect(mockSelectMemoryEmails).toHaveBeenCalledWith({
messageIds: ["<first@example.com>", "<second@example.com>", "<third@example.com>"],
});
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<ReturnType<typeof selectMemoryEmails>>);

const emailContent = {
text: "",
headers: {
references: "<first@example.com>\n<second@example.com>",
},
} as GetReceivingEmailResponseSuccess;

const result = await getEmailRoomId(emailContent);

expect(mockSelectMemoryEmails).toHaveBeenCalledWith({
messageIds: ["<first@example.com>", "<second@example.com>"],
});
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: "<unknown@example.com>" },
} 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<typeof selectMemoryEmails>
>);

const emailContent = {
text: "No link",
headers: { references: "<orphan@example.com>" },
} as GetReceivingEmailResponseSuccess;

const result = await getEmailRoomId(emailContent);

expect(result).toBeUndefined();
});
});
});
13 changes: 13 additions & 0 deletions lib/emails/inbound/extractRoomIdFromText.ts
Original file line number Diff line number Diff line change
@@ -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];
}
17 changes: 17 additions & 0 deletions lib/emails/inbound/extractTextFromParts.ts
Original file line number Diff line number Diff line change
@@ -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");
}
11 changes: 10 additions & 1 deletion lib/emails/inbound/getEmailRoomId.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
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
*/
export async function getEmailRoomId(
emailContent: GetReceivingEmailResponseSuccess,
): Promise<string | undefined> {
// 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;
Expand Down
34 changes: 24 additions & 10 deletions lib/emails/inbound/getEmailRoomMessages.ts
Original file line number Diff line number Diff line change
@@ -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<ModelMessage[]> {
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;
Expand Down
Loading