From 3d241089ee42c18ff14edfa46901cadec69e2be6 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 8 Jan 2026 10:56:51 -0500 Subject: [PATCH 1/6] feat: make room_id required in send_email tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The room_id parameter is now required to ensure all outbound emails include the chat link footer, enabling email thread continuity. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/emails/sendEmailSchema.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/emails/sendEmailSchema.ts b/lib/emails/sendEmailSchema.ts index 7411dd4d..42c0920e 100644 --- a/lib/emails/sendEmailSchema.ts +++ b/lib/emails/sendEmailSchema.ts @@ -28,8 +28,7 @@ export const sendEmailSchema = z.object({ .string() .describe( "Room ID to include in the email footer link. Use the active_conversation_id from context.", - ) - .optional(), + ), }); export type SendEmailInput = z.infer; From daa25c966679ee66b84c858b0782cc5e706c2a4a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 9 Jan 2026 09:44:44 -0500 Subject: [PATCH 2/6] Add POST /api/chats endpoint for creating chat rooms - Add createChatHandler in lib/chats/ - Add POST route at app/api/chats/ - Account ID inferred from API key - Optional artistId and chatId params - chatId auto-generated if not provided Co-Authored-By: Claude Opus 4.5 --- app/api/chats/route.ts | 35 +++++++++++++++++ lib/chats/createChatHandler.ts | 71 ++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 app/api/chats/route.ts create mode 100644 lib/chats/createChatHandler.ts diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts new file mode 100644 index 00000000..4e311045 --- /dev/null +++ b/app/api/chats/route.ts @@ -0,0 +1,35 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createChatHandler } from "@/lib/chats/createChatHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/chats + * + * Create a new chat room. + * + * Authentication: x-api-key header required. + * The account ID is inferred from the API key. + * + * Optional body parameters: + * - artistId: UUID of the artist account the chat is associated with + * - chatId: UUID for the new chat (auto-generated if not provided) + * + * @param request - The request object + * @returns A NextResponse with the created chat or an error + */ +export async function POST(request: NextRequest): Promise { + return createChatHandler(request); +} diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts new file mode 100644 index 00000000..b8f08d81 --- /dev/null +++ b/lib/chats/createChatHandler.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import { generateUUID } from "@/lib/uuid/generateUUID"; + +interface CreateChatBody { + artistId?: string; + chatId?: string; +} + +/** + * Handler for creating a new chat room. + * + * Requires authentication via x-api-key header. + * The account ID is inferred from the API key. + * + * @param request - The NextRequest object + * @returns A NextResponse with the created chat or an error + */ +export async function createChatHandler(request: NextRequest): Promise { + try { + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + + const accountId = accountIdOrError; + + let body: CreateChatBody = {}; + try { + body = await request.json(); + } catch { + // Empty body is valid - all params are optional + } + + const { artistId, chatId } = body; + + const roomId = chatId || generateUUID(); + + const chat = await insertRoom({ + id: roomId, + account_id: accountId, + artist_id: artistId || null, + topic: null, + }); + + return NextResponse.json( + { + status: "success", + chat, + }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + console.error("[ERROR] createChatHandler:", error); + return NextResponse.json( + { + status: "error", + message: "Failed to create chat", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} From f8baa3a59710cafbd5d204757fd69fb9bc164d97 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 9 Jan 2026 09:47:01 -0500 Subject: [PATCH 3/6] Add input validation pattern to CLAUDE.md and chats endpoint - Document validate function pattern using Zod in CLAUDE.md - Add validateCreateChatBody.ts for POST /api/chats - Update createChatHandler to use the validate function Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 65 +++++++++++++++++++++++++++++ lib/chats/createChatHandler.ts | 15 +++---- lib/chats/validateCreateChatBody.ts | 37 ++++++++++++++++ 3 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 lib/chats/validateCreateChatBody.ts diff --git a/CLAUDE.md b/CLAUDE.md index 42327a58..20dc37f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,71 @@ pnpm format:check # Check formatting - All API routes should have JSDoc comments - Run `pnpm lint` before committing +## Input Validation + +All API endpoints should use a **validate function** for input parsing. Use Zod for schema validation. + +### Pattern + +Create a `validateBody.ts` or `validateQuery.ts` file: + +```typescript +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +// Define the schema +export const createExampleBodySchema = z.object({ + name: z.string({ message: "name is required" }).min(1, "name cannot be empty"), + id: z.string().uuid("id must be a valid UUID").optional(), +}); + +// Export the inferred type +export type CreateExampleBody = z.infer; + +/** + * Validates request body for POST /api/example. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateCreateExampleBody(body: unknown): NextResponse | CreateExampleBody { + const result = createExampleBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} +``` + +### Usage in Handler + +```typescript +const validated = validateCreateExampleBody(body); +if (validated instanceof NextResponse) { + return validated; +} +// validated is now typed as CreateExampleBody +``` + +### Naming Convention + +- `validateBody.ts` - For POST/PUT request bodies +- `validateQuery.ts` - For GET query parameters + ## Constants (`lib/const.ts`) All shared constants live in `lib/const.ts`: diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts index b8f08d81..a9c0d6f8 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -3,11 +3,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { generateUUID } from "@/lib/uuid/generateUUID"; - -interface CreateChatBody { - artistId?: string; - chatId?: string; -} +import { validateCreateChatBody } from "@/lib/chats/validateCreateChatBody"; /** * Handler for creating a new chat room. @@ -27,14 +23,19 @@ export async function createChatHandler(request: NextRequest): Promise; + +/** + * Validates request body for POST /api/chats. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateCreateChatBody(body: unknown): NextResponse | CreateChatBody { + const result = createChatBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} From 2f03d7fca2a89a6331ad3cf8bb7cba0565c2c721 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 9 Jan 2026 09:52:22 -0500 Subject: [PATCH 4/6] Remove try-catch around request.json(), follow existing pattern Co-Authored-By: Claude Opus 4.5 --- lib/chats/createChatHandler.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts index a9c0d6f8..ff0a301c 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -23,12 +23,7 @@ export async function createChatHandler(request: NextRequest): Promise Date: Fri, 9 Jan 2026 09:56:21 -0500 Subject: [PATCH 5/6] Add safeParseJson utility for optional request bodies - Create safeParseJson helper that returns {} if body is empty/invalid - Use in createChatHandler so body is not required - All params are optional, so empty body should work Co-Authored-By: Claude Opus 4.5 --- lib/chats/createChatHandler.ts | 3 ++- lib/networking/safeParseJson.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 lib/networking/safeParseJson.ts diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts index ff0a301c..ad1c592f 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -4,6 +4,7 @@ import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { generateUUID } from "@/lib/uuid/generateUUID"; import { validateCreateChatBody } from "@/lib/chats/validateCreateChatBody"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; /** * Handler for creating a new chat room. @@ -23,7 +24,7 @@ export async function createChatHandler(request: NextRequest): Promise { + try { + return await request.json(); + } catch { + return {}; + } +} From a84ec738c7b264b97bf04b0df4f9cc9e874936ad Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Tue, 13 Jan 2026 10:28:28 -0300 Subject: [PATCH 6/6] feat: extract conversation ID from email HTML for Superhuman replies (#107) Superhuman email client inserts tags in link text which breaks plain text extraction. Added extractRoomIdFromHtml function as secondary fallback in getEmailRoomId to handle this case. Co-authored-by: Claude Opus 4.5 --- .../__tests__/extractRoomIdFromHtml.test.ts | 168 ++++++++++++++++++ .../inbound/__tests__/getEmailRoomId.test.ts | 42 ++++- lib/emails/inbound/extractRoomIdFromHtml.ts | 48 +++++ lib/emails/inbound/getEmailRoomId.ts | 14 +- 4 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts create mode 100644 lib/emails/inbound/extractRoomIdFromHtml.ts diff --git a/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts b/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts new file mode 100644 index 00000000..5fcf968b --- /dev/null +++ b/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from "vitest"; +import { extractRoomIdFromHtml } from "../extractRoomIdFromHtml"; + +describe("extractRoomIdFromHtml", () => { + describe("Superhuman reply with conversation link in quoted content", () => { + it("extracts roomId from Superhuman reply with wbr tags in link text", () => { + // This is the actual HTML from a Superhuman reply where the link text + // contains tags for word breaking + const html = ` + + + + +
+
+
+
Send a picture of him
+

+
+
+

+
+
Sent via Superhuman

+
+

+
+
On Fri, Jan 09, 2026 at 11:59 AM, Agent by Recoup <agent@recoupable.com> wrote:
+
+
+
+

Short answer: Brian Kernighan.

+

Details: the earliest known use in computing appears in Kernighan's 1972 tutorial for the B language (the "hello, world!" example). It was then popularized by Kernighan & Ritchie's 1978 book The C Programming Language. (There are older claims—BCPL examples from the late 1960s and the exact phrase appeared as a radio catchphrase in the 1950s—but Kernighan is usually credited for putting it into programming tradition.)

+

Want the sources/links?

+ + +
+

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

+

+ Or continue the conversation on Recoup: + + https://chat.recoupable.com/chat/d5c473ec-04cf-4a23-a577-e0dc71542392 + +

+
+
+
+
+

+
+
+ + +`; + + const result = extractRoomIdFromHtml(html); + + expect(result).toBe("d5c473ec-04cf-4a23-a577-e0dc71542392"); + }); + }); + + describe("Gmail reply with proper threading", () => { + it("extracts roomId from Gmail reply with quoted content", () => { + const html = ` + + +

Thanks for the info!

+
+
+

Original message here

+

Continue the conversation: https://chat.recoupable.com/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890

+
+
+ + + `; + + const result = extractRoomIdFromHtml(html); + + expect(result).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + }); + }); + + describe("no conversation ID", () => { + it("returns undefined for undefined input", () => { + const result = extractRoomIdFromHtml(undefined); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for empty string", () => { + const result = extractRoomIdFromHtml(""); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when no chat link present", () => { + const html = "

This email has no Recoup chat link.

"; + + const result = extractRoomIdFromHtml(html); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for invalid UUID format in link", () => { + const html = + 'link'; + + const result = extractRoomIdFromHtml(html); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for wrong domain", () => { + const html = + 'link'; + + const result = extractRoomIdFromHtml(html); + + expect(result).toBeUndefined(); + }); + }); + + describe("edge cases", () => { + it("handles URL-encoded link in href attribute", () => { + // Resend tracking redirects URL-encode the destination + const html = + 'Click here'; + + const result = extractRoomIdFromHtml(html); + + expect(result).toBe("12345678-1234-1234-1234-123456789abc"); + }); + + it("extracts first roomId when multiple links present", () => { + const html = ` + First + Second + `; + + const result = extractRoomIdFromHtml(html); + + expect(result).toBe("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + }); + + it("handles link text with wbr tags breaking up the URL", () => { + const html = ` + + https://chat.recoupable.com/chat/abcdef12-3456-7890-abcd-ef1234567890 + + `; + + const result = extractRoomIdFromHtml(html); + + expect(result).toBe("abcdef12-3456-7890-abcd-ef1234567890"); + }); + + it("handles mixed case in URL", () => { + const html = + 'link'; + + const result = extractRoomIdFromHtml(html); + + expect(result).toBe("12345678-1234-1234-1234-123456789abc"); + }); + }); +}); diff --git a/lib/emails/inbound/__tests__/getEmailRoomId.test.ts b/lib/emails/inbound/__tests__/getEmailRoomId.test.ts index 690beb59..2850f7c3 100644 --- a/lib/emails/inbound/__tests__/getEmailRoomId.test.ts +++ b/lib/emails/inbound/__tests__/getEmailRoomId.test.ts @@ -45,14 +45,54 @@ describe("getEmailRoomId", () => { }); }); + describe("secondary: extracting from email HTML", () => { + it("returns roomId from HTML when text has no chat link", async () => { + const emailContent = { + text: "No chat link in text", + html: 'link', + headers: { references: "" }, + } as GetReceivingEmailResponseSuccess; + + const result = await getEmailRoomId(emailContent); + + expect(result).toBe("abcdef12-3456-7890-abcd-ef1234567890"); + expect(mockSelectMemoryEmails).not.toHaveBeenCalled(); + }); + + it("handles Superhuman wbr tags in HTML link text", async () => { + const emailContent = { + text: undefined, + html: 'https://chat.recoupable.com/chat/d5c473ec-04cf-4a23-a577-e0dc71542392', + headers: {}, + } as GetReceivingEmailResponseSuccess; + + const result = await getEmailRoomId(emailContent); + + expect(result).toBe("d5c473ec-04cf-4a23-a577-e0dc71542392"); + }); + + it("prioritizes text over HTML", async () => { + const emailContent = { + text: "https://chat.recoupable.com/chat/11111111-1111-1111-1111-111111111111", + html: 'link', + headers: {}, + } as GetReceivingEmailResponseSuccess; + + const result = await getEmailRoomId(emailContent); + + expect(result).toBe("11111111-1111-1111-1111-111111111111"); + }); + }); + describe("fallback: checking references header", () => { - it("falls back to references header when no chat link in text", async () => { + it("falls back to references header when no chat link in text or html", async () => { mockSelectMemoryEmails.mockResolvedValue([ { memories: { room_id: "22222222-3333-4444-5555-666666666666" } }, ] as Awaited>); const emailContent = { text: "No chat link here", + html: "

No chat link in HTML either

", headers: { references: "" }, } as GetReceivingEmailResponseSuccess; diff --git a/lib/emails/inbound/extractRoomIdFromHtml.ts b/lib/emails/inbound/extractRoomIdFromHtml.ts new file mode 100644 index 00000000..f637b17e --- /dev/null +++ b/lib/emails/inbound/extractRoomIdFromHtml.ts @@ -0,0 +1,48 @@ +const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + +// Matches chat.recoupable.com/chat/{uuid} in various formats: +// - Direct URL: https://chat.recoupable.com/chat/uuid +// - URL-encoded (in tracking redirects): chat.recoupable.com%2Fchat%2Fuuid +const CHAT_LINK_PATTERNS = [ + new RegExp(`https?://chat\\.recoupable\\.com/chat/(${UUID_PATTERN})`, "i"), + new RegExp(`chat\\.recoupable\\.com%2Fchat%2F(${UUID_PATTERN})`, "i"), +]; + +// Pattern to find UUID after /chat/ or %2Fchat%2F in link text that may contain tags +// The link text version: "https:///chat.recoupable.com/chat/uuid" +const WBR_STRIPPED_PATTERN = new RegExp( + `chat\\.recoupable\\.com/chat/(${UUID_PATTERN})`, + "i", +); + +/** + * Extracts the roomId from email HTML by looking for a Recoup chat link. + * Handles various formats including: + * - Direct URLs in href attributes + * - URL-encoded URLs in tracking redirect links + * - Link text with tags inserted for word breaking (common in Superhuman) + * + * @param html - The email HTML body + * @returns The roomId if found, undefined otherwise + */ +export function extractRoomIdFromHtml(html: string | undefined): string | undefined { + if (!html) return undefined; + + // Try direct URL patterns first (most common case) + for (const pattern of CHAT_LINK_PATTERNS) { + const match = html.match(pattern); + if (match?.[1]) { + return match[1]; + } + } + + // Fallback: strip tags and try again + // This handles Superhuman's link text formatting: "https://chat...." + const strippedHtml = html.replace(//gi, ""); + const strippedMatch = strippedHtml.match(WBR_STRIPPED_PATTERN); + if (strippedMatch?.[1]) { + return strippedMatch[1]; + } + + return undefined; +} diff --git a/lib/emails/inbound/getEmailRoomId.ts b/lib/emails/inbound/getEmailRoomId.ts index ef889381..f12db939 100644 --- a/lib/emails/inbound/getEmailRoomId.ts +++ b/lib/emails/inbound/getEmailRoomId.ts @@ -1,10 +1,13 @@ import type { GetReceivingEmailResponseSuccess } from "resend"; import selectMemoryEmails from "@/lib/supabase/memory_emails/selectMemoryEmails"; import { extractRoomIdFromText } from "./extractRoomIdFromText"; +import { extractRoomIdFromHtml } from "./extractRoomIdFromHtml"; /** - * 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. + * Extracts the roomId from an email. Checks multiple sources in order: + * 1. Email text body for a Recoup chat link + * 2. Email HTML body for a Recoup chat link (handles Superhuman's wbr tags) + * 3. References header to look up existing memory_emails * * @param emailContent - The email content from Resend's Receiving API * @returns The roomId if found, undefined otherwise @@ -18,6 +21,13 @@ export async function getEmailRoomId( return roomIdFromText; } + // Secondary: check email HTML for Recoup chat link + // This handles clients like Superhuman that insert tags in link text + const roomIdFromHtml = extractRoomIdFromHtml(emailContent.html); + if (roomIdFromHtml) { + return roomIdFromHtml; + } + // Fallback: check references header for existing memory_emails const references = emailContent.headers?.references; if (!references) {