From cb56b29ef383ddf1944b14eb025a98b537163645 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 6 Jan 2026 12:54:17 -0500 Subject: [PATCH 01/10] Updated the CC filter logic in to optimize Files changed: - lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts - lib/emails/inbound/validateCcReplyExpected.ts --- .../__tests__/validateCcReplyExpected.test.ts | 137 ++++++++++++++---- lib/emails/inbound/validateCcReplyExpected.ts | 26 +++- 2 files changed, 131 insertions(+), 32 deletions(-) diff --git a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts index 08eaacd8..3a595803 100644 --- a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts +++ b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts @@ -28,47 +28,122 @@ describe("validateCcReplyExpected", () => { vi.clearAllMocks(); }); - it("always calls agent.generate regardless of TO/CC", async () => { - mockGenerate.mockResolvedValue({ output: { shouldReply: true } }); + describe("when recoup email is only in TO (not CC)", () => { + it("skips agent call and returns null (always reply)", async () => { + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["hi@mail.recoupable.com"], + cc: [], + }; + + const result = await validateCcReplyExpected(emailData, "Hello"); + + expect(mockGenerate).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it("handles multiple TO addresses with recoup email", async () => { + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["other@example.com", "hi@mail.recoupable.com"], + cc: [], + }; + + const result = await validateCcReplyExpected(emailData, "Hello"); + + expect(mockGenerate).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); - const emailData: ResendEmailData = { - ...baseEmailData, - to: ["hi@mail.recoupable.com"], - cc: [], - }; + describe("when recoup email is only in CC", () => { + it("calls agent to determine if reply is expected", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: true } }); - await validateCcReplyExpected(emailData, "Hello"); + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["someone@example.com"], + cc: ["hi@mail.recoupable.com"], + }; - expect(mockGenerate).toHaveBeenCalledTimes(1); - }); + await validateCcReplyExpected(emailData, "FYI"); - it("returns null when agent returns shouldReply: true", async () => { - mockGenerate.mockResolvedValue({ output: { shouldReply: true } }); + expect(mockGenerate).toHaveBeenCalledTimes(1); + }); - const emailData: ResendEmailData = { - ...baseEmailData, - to: ["hi@mail.recoupable.com"], - cc: [], - }; + it("returns null when agent returns shouldReply: true", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: true } }); + + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["someone@example.com"], + cc: ["hi@mail.recoupable.com"], + }; + + const result = await validateCcReplyExpected(emailData, "Please review"); + + expect(result).toBeNull(); + }); + + it("returns response when agent returns shouldReply: false", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: false } }); - const result = await validateCcReplyExpected(emailData, "Hello"); + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["someone@example.com"], + cc: ["hi@mail.recoupable.com"], + }; - expect(result).toBeNull(); + const result = await validateCcReplyExpected(emailData, "FYI"); + + expect(result).not.toBeNull(); + expect(result?.response).toBeDefined(); + }); }); - it("returns response when agent returns shouldReply: false", async () => { - mockGenerate.mockResolvedValue({ output: { shouldReply: false } }); + describe("when recoup email is in both TO and CC", () => { + it("treats as CC and calls agent", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: true } }); - const emailData: ResendEmailData = { - ...baseEmailData, - to: ["someone@example.com"], - cc: ["hi@mail.recoupable.com"], - }; + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["hi@mail.recoupable.com"], + cc: ["hi@mail.recoupable.com"], + }; + + await validateCcReplyExpected(emailData, "Hello"); + + expect(mockGenerate).toHaveBeenCalledTimes(1); + }); - const result = await validateCcReplyExpected(emailData, "FYI"); + it("returns null when agent returns shouldReply: true", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: true } }); - expect(result).not.toBeNull(); - expect(result?.response).toBeDefined(); + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["hi@mail.recoupable.com"], + cc: ["hi@mail.recoupable.com"], + }; + + const result = await validateCcReplyExpected(emailData, "Hello"); + + expect(result).toBeNull(); + }); + + it("returns response when agent returns shouldReply: false", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: false } }); + + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["hi@mail.recoupable.com"], + cc: ["hi@mail.recoupable.com"], + }; + + const result = await validateCcReplyExpected(emailData, "FYI"); + + expect(result).not.toBeNull(); + expect(result?.response).toBeDefined(); + }); }); it("passes email context in prompt to agent.generate", async () => { @@ -77,8 +152,8 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, from: "test@example.com", - to: ["hi@mail.recoupable.com"], - cc: ["cc@example.com"], + to: ["someone@example.com"], + cc: ["hi@mail.recoupable.com", "cc@example.com"], subject: "Test Subject", }; diff --git a/lib/emails/inbound/validateCcReplyExpected.ts b/lib/emails/inbound/validateCcReplyExpected.ts index 25410b0a..106ce5f2 100644 --- a/lib/emails/inbound/validateCcReplyExpected.ts +++ b/lib/emails/inbound/validateCcReplyExpected.ts @@ -2,8 +2,22 @@ import { NextResponse } from "next/server"; import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; import { shouldReplyToCcEmail } from "@/lib/emails/inbound/shouldReplyToCcEmail"; +const RECOUP_EMAIL_DOMAIN = "mail.recoupable.com"; + +/** + * Checks if any email address in the array is a recoup email address. + */ +function containsRecoupEmail(addresses: string[]): boolean { + return addresses.some(addr => addr.toLowerCase().includes(RECOUP_EMAIL_DOMAIN)); +} + /** - * Validates whether a reply should be sent by delegating to shouldReplyToCcEmail. + * Validates whether a reply should be sent. + * + * Logic: + * - If recoup email is only in TO (not CC): Always reply (skip LLM call) + * - If recoup email is in CC (regardless of TO): Use LLM to determine if reply is expected + * - If recoup email is in both TO and CC: Treat as CC (use LLM) * * @param original - The original email data from the Resend webhook * @param emailText - The parsed email body text @@ -13,6 +27,16 @@ export async function validateCcReplyExpected( original: ResendEmailData, emailText: string, ): Promise<{ response: NextResponse } | null> { + const isInTo = containsRecoupEmail(original.to); + const isInCc = containsRecoupEmail(original.cc); + + // 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; + } + + // If recoup email is in CC (or both TO and CC), use LLM to determine if reply is expected const shouldReply = await shouldReplyToCcEmail({ from: original.from, to: original.to, From e25f8d43e03afaad0171b65391258abf40b74787 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 6 Jan 2026 13:10:24 -0500 Subject: [PATCH 02/10] Add RECOUP_EMAIL_DOMAIN constant and refactor email handling - Introduced RECOUP_EMAIL_DOMAIN constant for better maintainability. - Updated EmailReplyAgent to utilize the new constant in instructions. - Created containsRecoupEmail function to check for Recoup email addresses. - Refactored getFromWithName and validateCcReplyExpected to use the new constant. - Updated tests to reflect changes in email address handling. --- .../EmailReplyAgent/createEmailReplyAgent.ts | 4 +-- lib/const.ts | 1 + lib/emails/containsRecoupEmail.ts | 11 ++++++++ .../__tests__/validateCcReplyExpected.test.ts | 25 ++++++++++--------- lib/emails/inbound/getFromWithName.ts | 10 ++++---- lib/emails/inbound/validateCcReplyExpected.ts | 10 +------- 6 files changed, 33 insertions(+), 28 deletions(-) create mode 100644 lib/emails/containsRecoupEmail.ts diff --git a/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts b/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts index f3949295..0779f2a5 100644 --- a/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts +++ b/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts @@ -1,12 +1,12 @@ import { Output, ToolLoopAgent, stepCountIs } from "ai"; import { z } from "zod"; -import { LIGHTWEIGHT_MODEL } from "@/lib/const"; +import { LIGHTWEIGHT_MODEL, RECOUP_EMAIL_DOMAIN } from "@/lib/const"; const replyDecisionSchema = z.object({ shouldReply: z.boolean().describe("Whether the Recoup AI assistant should reply to this email"), }); -const instructions = `You analyze emails to determine if a Recoup AI assistant (@mail.recoupable.com) should reply. +const instructions = `You analyze emails to determine if a Recoup AI assistant (${RECOUP_EMAIL_DOMAIN}) should reply. Rules (check in this order): 1. FIRST check the body/subject: If the sender explicitly asks NOT to reply (e.g., "don't reply", "do not reply", "stop replying", "no response needed") → return false diff --git a/lib/const.ts b/lib/const.ts index 622d20c3..9ca94fc2 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -12,3 +12,4 @@ export const IMAGE_GENERATE_PRICE = "0.15"; export const DEFAULT_MODEL = "openai/gpt-5-mini"; export const LIGHTWEIGHT_MODEL = "openai/gpt-4o-mini"; export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET; +export const RECOUP_EMAIL_DOMAIN = "@mail.recoupable.com"; diff --git a/lib/emails/containsRecoupEmail.ts b/lib/emails/containsRecoupEmail.ts new file mode 100644 index 00000000..9656071c --- /dev/null +++ b/lib/emails/containsRecoupEmail.ts @@ -0,0 +1,11 @@ +import { RECOUP_EMAIL_DOMAIN } from "@/lib/const"; + +/** + * Checks if any email address in the array is a recoup email address. + * + * @param addresses - The array of email addresses to check + * @returns True if any address is a recoup email, false otherwise + */ +export function containsRecoupEmail(addresses: string[]): boolean { + return addresses.some(addr => addr.toLowerCase().includes(RECOUP_EMAIL_DOMAIN)); +} diff --git a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts index 3a595803..2e832ad6 100644 --- a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts +++ b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { validateCcReplyExpected } from "../validateCcReplyExpected"; import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; +import { RECOUP_EMAIL_DOMAIN } from "@/lib/const"; const mockGenerate = vi.fn(); @@ -32,7 +33,7 @@ describe("validateCcReplyExpected", () => { it("skips agent call and returns null (always reply)", async () => { const emailData: ResendEmailData = { ...baseEmailData, - to: ["hi@mail.recoupable.com"], + to: [`hi${RECOUP_EMAIL_DOMAIN}`], cc: [], }; @@ -45,7 +46,7 @@ describe("validateCcReplyExpected", () => { it("handles multiple TO addresses with recoup email", async () => { const emailData: ResendEmailData = { ...baseEmailData, - to: ["other@example.com", "hi@mail.recoupable.com"], + to: ["other@example.com", `hi${RECOUP_EMAIL_DOMAIN}`], cc: [], }; @@ -63,7 +64,7 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, to: ["someone@example.com"], - cc: ["hi@mail.recoupable.com"], + cc: [`hi${RECOUP_EMAIL_DOMAIN}`], }; await validateCcReplyExpected(emailData, "FYI"); @@ -77,7 +78,7 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, to: ["someone@example.com"], - cc: ["hi@mail.recoupable.com"], + cc: [`hi${RECOUP_EMAIL_DOMAIN}`], }; const result = await validateCcReplyExpected(emailData, "Please review"); @@ -91,7 +92,7 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, to: ["someone@example.com"], - cc: ["hi@mail.recoupable.com"], + cc: [`hi${RECOUP_EMAIL_DOMAIN}`], }; const result = await validateCcReplyExpected(emailData, "FYI"); @@ -107,8 +108,8 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, - to: ["hi@mail.recoupable.com"], - cc: ["hi@mail.recoupable.com"], + to: [`hi${RECOUP_EMAIL_DOMAIN}`], + cc: [`hi${RECOUP_EMAIL_DOMAIN}`], }; await validateCcReplyExpected(emailData, "Hello"); @@ -121,8 +122,8 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, - to: ["hi@mail.recoupable.com"], - cc: ["hi@mail.recoupable.com"], + to: [`hi${RECOUP_EMAIL_DOMAIN}`], + cc: [`hi${RECOUP_EMAIL_DOMAIN}`], }; const result = await validateCcReplyExpected(emailData, "Hello"); @@ -135,8 +136,8 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, - to: ["hi@mail.recoupable.com"], - cc: ["hi@mail.recoupable.com"], + to: [`hi${RECOUP_EMAIL_DOMAIN}`], + cc: [`hi${RECOUP_EMAIL_DOMAIN}`], }; const result = await validateCcReplyExpected(emailData, "FYI"); @@ -153,7 +154,7 @@ describe("validateCcReplyExpected", () => { ...baseEmailData, from: "test@example.com", to: ["someone@example.com"], - cc: ["hi@mail.recoupable.com", "cc@example.com"], + cc: [`hi${RECOUP_EMAIL_DOMAIN}`, "cc@example.com"], subject: "Test Subject", }; diff --git a/lib/emails/inbound/getFromWithName.ts b/lib/emails/inbound/getFromWithName.ts index 5e227dad..496efe2b 100644 --- a/lib/emails/inbound/getFromWithName.ts +++ b/lib/emails/inbound/getFromWithName.ts @@ -1,3 +1,5 @@ +import { RECOUP_EMAIL_DOMAIN } from "@/lib/const"; + /** * Gets a formatted "from" email address with a human-readable name. * @@ -8,17 +10,15 @@ */ export function getFromWithName(toEmails: string[], ccEmails: string[] = []): string { // Find the first email in the 'to' array that ends with "@mail.recoupable.com" - let customFromEmail = toEmails.find(email => - email.toLowerCase().endsWith("@mail.recoupable.com"), - ); + let customFromEmail = toEmails.find(email => email.toLowerCase().endsWith(RECOUP_EMAIL_DOMAIN)); // If not found in 'to', check the 'cc' array as fallback if (!customFromEmail) { - customFromEmail = ccEmails.find(email => email.toLowerCase().endsWith("@mail.recoupable.com")); + customFromEmail = ccEmails.find(email => email.toLowerCase().endsWith(RECOUP_EMAIL_DOMAIN)); } if (!customFromEmail) { - throw new Error("No email found ending with @mail.recoupable.com in the 'to' or 'cc' array"); + throw new Error(`No email found ending with ${RECOUP_EMAIL_DOMAIN} in the 'to' or 'cc' array`); } // Extract the name part (everything before the @ sign) for a human-readable from name diff --git a/lib/emails/inbound/validateCcReplyExpected.ts b/lib/emails/inbound/validateCcReplyExpected.ts index 106ce5f2..ad33f258 100644 --- a/lib/emails/inbound/validateCcReplyExpected.ts +++ b/lib/emails/inbound/validateCcReplyExpected.ts @@ -1,15 +1,7 @@ import { NextResponse } from "next/server"; import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; import { shouldReplyToCcEmail } from "@/lib/emails/inbound/shouldReplyToCcEmail"; - -const RECOUP_EMAIL_DOMAIN = "mail.recoupable.com"; - -/** - * Checks if any email address in the array is a recoup email address. - */ -function containsRecoupEmail(addresses: string[]): boolean { - return addresses.some(addr => addr.toLowerCase().includes(RECOUP_EMAIL_DOMAIN)); -} +import { containsRecoupEmail } from "@/lib/emails/containsRecoupEmail"; /** * Validates whether a reply should be sent. From f5cf84752b81c9ae5917ccf9f4d0504907fe3583 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 7 Jan 2026 11:21:06 -0500 Subject: [PATCH 03/10] feat: send outbound emails from recoupable.com instead of mail.recoupable.com MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add INBOUND_EMAIL_DOMAIN and OUTBOUND_EMAIL_DOMAIN constants to lib/consts.ts - Update getFromWithName to convert inbound domain to outbound domain for replies - Inbound emails still received at @mail.recoupable.com - Outbound replies now sent from @recoupable.com 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/consts.ts | 5 +++++ lib/emails/inbound/getFromWithName.ts | 29 ++++++++++++++++----------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/lib/consts.ts b/lib/consts.ts index dc1ee155..2382ae0d 100644 --- a/lib/consts.ts +++ b/lib/consts.ts @@ -4,3 +4,8 @@ export const SUPABASE_STORAGE_BUCKET = "user-files"; +/** Domain for receiving inbound emails (e.g., support@mail.recoupable.com) */ +export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com"; + +/** Domain for sending outbound emails (e.g., support@recoupable.com) */ +export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com"; diff --git a/lib/emails/inbound/getFromWithName.ts b/lib/emails/inbound/getFromWithName.ts index 5e227dad..26a3942b 100644 --- a/lib/emails/inbound/getFromWithName.ts +++ b/lib/emails/inbound/getFromWithName.ts @@ -1,28 +1,33 @@ +import { INBOUND_EMAIL_DOMAIN, OUTBOUND_EMAIL_DOMAIN } from "@/lib/consts"; + /** * Gets a formatted "from" email address with a human-readable name. + * Finds the inbound email address and converts it to the outbound domain for sending. * * @param toEmails - Array of email addresses from the 'to' field * @param ccEmails - Optional array of email addresses from the 'cc' field (fallback) - * @returns Formatted email address with display name (e.g., "Support ") - * @throws Error if no email ending with "@mail.recoupable.com" is found in either array + * @returns Formatted email address with display name (e.g., "Support ") + * @throws Error if no email ending with the inbound domain is found in either array */ export function getFromWithName(toEmails: string[], ccEmails: string[] = []): string { - // Find the first email in the 'to' array that ends with "@mail.recoupable.com" - let customFromEmail = toEmails.find(email => - email.toLowerCase().endsWith("@mail.recoupable.com"), - ); + // Find the first email in the 'to' array that ends with the inbound domain + let inboundEmail = toEmails.find(email => email.toLowerCase().endsWith(INBOUND_EMAIL_DOMAIN)); // If not found in 'to', check the 'cc' array as fallback - if (!customFromEmail) { - customFromEmail = ccEmails.find(email => email.toLowerCase().endsWith("@mail.recoupable.com")); + if (!inboundEmail) { + inboundEmail = ccEmails.find(email => email.toLowerCase().endsWith(INBOUND_EMAIL_DOMAIN)); } - if (!customFromEmail) { - throw new Error("No email found ending with @mail.recoupable.com in the 'to' or 'cc' array"); + if (!inboundEmail) { + throw new Error(`No email found ending with ${INBOUND_EMAIL_DOMAIN} in the 'to' or 'cc' array`); } // Extract the name part (everything before the @ sign) for a human-readable from name - const emailNameRaw = customFromEmail.split("@")[0]; + const emailNameRaw = inboundEmail.split("@")[0]; const emailName = emailNameRaw.charAt(0).toUpperCase() + emailNameRaw.slice(1); - return `${emailName} <${customFromEmail}>`; + + // Convert to outbound domain for sending + const outboundEmail = emailNameRaw + OUTBOUND_EMAIL_DOMAIN; + + return `${emailName} <${outboundEmail}>`; } From ec3e18abdf3fe1920ddb3dc0e923d6d18f3baf1b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 7 Jan 2026 11:24:19 -0500 Subject: [PATCH 04/10] docs: add CLAUDE.md with git workflow and project guidelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Always commit/push after completing changes - Never push directly to main or test branches - Include build commands and architecture overview 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..854b4e8b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,45 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Git Workflow + +**Always commit and push changes after completing a task.** Follow these rules: + +1. After making code changes, always commit with a descriptive message +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` + +## Build Commands + +```bash +pnpm install # Install dependencies +pnpm dev # Start dev server +pnpm build # Production build +pnpm test # Run vitest +pnpm test:watch # Watch mode +pnpm lint # Fix lint issues +pnpm lint:check # Check for lint issues +pnpm format # Run prettier +pnpm format:check # Check formatting +``` + +## Architecture + +- **Next.js 16** API service with App Router +- **x402-next** middleware for crypto payments on Base network +- `app/api/` - API routes (image generation, artists, accounts, etc.) +- `lib/` - Business logic organized by domain: + - `lib/ai/` - AI/LLM integrations + - `lib/emails/` - Email handling (Resend) + - `lib/supabase/` - Database operations + - `lib/trigger/` - Trigger.dev task triggers + - `lib/x402/` - Payment middleware utilities + +## Key Patterns + +- Use constants from `lib/consts.ts` and `lib/const.ts` +- Email domains: inbound at `@mail.recoupable.com`, outbound from `@recoupable.com` +- All API routes should have JSDoc comments +- Run `pnpm lint` before committing From b20e8b016a2928d12d59bf56995b32582fca3d7f Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 7 Jan 2026 11:35:03 -0500 Subject: [PATCH 05/10] refactor: consolidate consts.ts into const.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move SUPABASE_STORAGE_BUCKET to lib/const.ts - Update import in uploadFileByKey.ts - Delete lib/consts.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/const.ts | 2 ++ lib/consts.ts | 5 ----- lib/supabase/storage/uploadFileByKey.ts | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) delete mode 100644 lib/consts.ts diff --git a/lib/const.ts b/lib/const.ts index 5e1c495a..2abe8941 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -17,3 +17,5 @@ export const RECOUP_EMAIL_DOMAIN = "@mail.recoupable.com"; /** Domain for sending outbound emails (e.g., support@recoupable.com) */ export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com"; + +export const SUPABASE_STORAGE_BUCKET = "user-files"; diff --git a/lib/consts.ts b/lib/consts.ts deleted file mode 100644 index 1045f243..00000000 --- a/lib/consts.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Shared constants for Recoup-API - */ - -export const SUPABASE_STORAGE_BUCKET = "user-files"; diff --git a/lib/supabase/storage/uploadFileByKey.ts b/lib/supabase/storage/uploadFileByKey.ts index 9c1bf579..c04f2bd3 100644 --- a/lib/supabase/storage/uploadFileByKey.ts +++ b/lib/supabase/storage/uploadFileByKey.ts @@ -1,5 +1,5 @@ import supabase from "@/lib/supabase/serverClient"; -import { SUPABASE_STORAGE_BUCKET } from "@/lib/consts"; +import { SUPABASE_STORAGE_BUCKET } from "@/lib/const"; /** * Upload file to Supabase storage by key From f7966bbc024d50a31d647e255da6b8600369c05d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 7 Jan 2026 11:36:41 -0500 Subject: [PATCH 06/10] refactor: rename RECOUP_EMAIL_DOMAIN to INBOUND_EMAIL_DOMAIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearer naming to distinguish from OUTBOUND_EMAIL_DOMAIN 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../EmailReplyAgent/createEmailReplyAgent.ts | 4 +-- lib/const.ts | 2 +- lib/emails/containsRecoupEmail.ts | 4 +-- .../__tests__/validateCcReplyExpected.test.ts | 26 +++++++++---------- lib/emails/inbound/getFromWithName.ts | 8 +++--- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts b/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts index 0779f2a5..29452b7d 100644 --- a/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts +++ b/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts @@ -1,12 +1,12 @@ import { Output, ToolLoopAgent, stepCountIs } from "ai"; import { z } from "zod"; -import { LIGHTWEIGHT_MODEL, RECOUP_EMAIL_DOMAIN } from "@/lib/const"; +import { LIGHTWEIGHT_MODEL, INBOUND_EMAIL_DOMAIN } from "@/lib/const"; const replyDecisionSchema = z.object({ shouldReply: z.boolean().describe("Whether the Recoup AI assistant should reply to this email"), }); -const instructions = `You analyze emails to determine if a Recoup AI assistant (${RECOUP_EMAIL_DOMAIN}) should reply. +const instructions = `You analyze emails to determine if a Recoup AI assistant (${INBOUND_EMAIL_DOMAIN}) should reply. Rules (check in this order): 1. FIRST check the body/subject: If the sender explicitly asks NOT to reply (e.g., "don't reply", "do not reply", "stop replying", "no response needed") → return false diff --git a/lib/const.ts b/lib/const.ts index 2abe8941..271afcf3 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -13,7 +13,7 @@ export const DEFAULT_MODEL = "openai/gpt-5-mini"; export const LIGHTWEIGHT_MODEL = "openai/gpt-4o-mini"; export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET; /** Domain for receiving inbound emails (e.g., support@mail.recoupable.com) */ -export const RECOUP_EMAIL_DOMAIN = "@mail.recoupable.com"; +export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com"; /** Domain for sending outbound emails (e.g., support@recoupable.com) */ export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com"; diff --git a/lib/emails/containsRecoupEmail.ts b/lib/emails/containsRecoupEmail.ts index 9656071c..e3c29b4c 100644 --- a/lib/emails/containsRecoupEmail.ts +++ b/lib/emails/containsRecoupEmail.ts @@ -1,4 +1,4 @@ -import { RECOUP_EMAIL_DOMAIN } from "@/lib/const"; +import { INBOUND_EMAIL_DOMAIN } from "@/lib/const"; /** * Checks if any email address in the array is a recoup email address. @@ -7,5 +7,5 @@ import { RECOUP_EMAIL_DOMAIN } from "@/lib/const"; * @returns True if any address is a recoup email, false otherwise */ export function containsRecoupEmail(addresses: string[]): boolean { - return addresses.some(addr => addr.toLowerCase().includes(RECOUP_EMAIL_DOMAIN)); + return addresses.some(addr => addr.toLowerCase().includes(INBOUND_EMAIL_DOMAIN)); } diff --git a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts index 2e832ad6..4e64228e 100644 --- a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts +++ b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { validateCcReplyExpected } from "../validateCcReplyExpected"; import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; -import { RECOUP_EMAIL_DOMAIN } from "@/lib/const"; +import { INBOUND_EMAIL_DOMAIN } from "@/lib/const"; const mockGenerate = vi.fn(); @@ -33,7 +33,7 @@ describe("validateCcReplyExpected", () => { it("skips agent call and returns null (always reply)", async () => { const emailData: ResendEmailData = { ...baseEmailData, - to: [`hi${RECOUP_EMAIL_DOMAIN}`], + to: [`hi${INBOUND_EMAIL_DOMAIN}`], cc: [], }; @@ -46,7 +46,7 @@ describe("validateCcReplyExpected", () => { it("handles multiple TO addresses with recoup email", async () => { const emailData: ResendEmailData = { ...baseEmailData, - to: ["other@example.com", `hi${RECOUP_EMAIL_DOMAIN}`], + to: ["other@example.com", `hi${INBOUND_EMAIL_DOMAIN}`], cc: [], }; @@ -64,7 +64,7 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, to: ["someone@example.com"], - cc: [`hi${RECOUP_EMAIL_DOMAIN}`], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`], }; await validateCcReplyExpected(emailData, "FYI"); @@ -78,7 +78,7 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, to: ["someone@example.com"], - cc: [`hi${RECOUP_EMAIL_DOMAIN}`], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`], }; const result = await validateCcReplyExpected(emailData, "Please review"); @@ -92,7 +92,7 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, to: ["someone@example.com"], - cc: [`hi${RECOUP_EMAIL_DOMAIN}`], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`], }; const result = await validateCcReplyExpected(emailData, "FYI"); @@ -108,8 +108,8 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, - to: [`hi${RECOUP_EMAIL_DOMAIN}`], - cc: [`hi${RECOUP_EMAIL_DOMAIN}`], + to: [`hi${INBOUND_EMAIL_DOMAIN}`], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`], }; await validateCcReplyExpected(emailData, "Hello"); @@ -122,8 +122,8 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, - to: [`hi${RECOUP_EMAIL_DOMAIN}`], - cc: [`hi${RECOUP_EMAIL_DOMAIN}`], + to: [`hi${INBOUND_EMAIL_DOMAIN}`], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`], }; const result = await validateCcReplyExpected(emailData, "Hello"); @@ -136,8 +136,8 @@ describe("validateCcReplyExpected", () => { const emailData: ResendEmailData = { ...baseEmailData, - to: [`hi${RECOUP_EMAIL_DOMAIN}`], - cc: [`hi${RECOUP_EMAIL_DOMAIN}`], + to: [`hi${INBOUND_EMAIL_DOMAIN}`], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`], }; const result = await validateCcReplyExpected(emailData, "FYI"); @@ -154,7 +154,7 @@ describe("validateCcReplyExpected", () => { ...baseEmailData, from: "test@example.com", to: ["someone@example.com"], - cc: [`hi${RECOUP_EMAIL_DOMAIN}`, "cc@example.com"], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`, "cc@example.com"], subject: "Test Subject", }; diff --git a/lib/emails/inbound/getFromWithName.ts b/lib/emails/inbound/getFromWithName.ts index 5993f462..1076f113 100644 --- a/lib/emails/inbound/getFromWithName.ts +++ b/lib/emails/inbound/getFromWithName.ts @@ -1,4 +1,4 @@ -import { OUTBOUND_EMAIL_DOMAIN, RECOUP_EMAIL_DOMAIN } from "@/lib/const"; +import { OUTBOUND_EMAIL_DOMAIN, INBOUND_EMAIL_DOMAIN } from "@/lib/const"; /** * Gets a formatted "from" email address with a human-readable name. @@ -11,15 +11,15 @@ import { OUTBOUND_EMAIL_DOMAIN, RECOUP_EMAIL_DOMAIN } from "@/lib/const"; */ export function getFromWithName(toEmails: string[], ccEmails: string[] = []): string { // Find the first email in the 'to' array that ends with the inbound domain - let inboundEmail = toEmails.find(email => email.toLowerCase().endsWith(RECOUP_EMAIL_DOMAIN)); + let inboundEmail = toEmails.find(email => email.toLowerCase().endsWith(INBOUND_EMAIL_DOMAIN)); // If not found in 'to', check the 'cc' array as fallback if (!inboundEmail) { - inboundEmail = ccEmails.find(email => email.toLowerCase().endsWith(RECOUP_EMAIL_DOMAIN)); + inboundEmail = ccEmails.find(email => email.toLowerCase().endsWith(INBOUND_EMAIL_DOMAIN)); } if (!inboundEmail) { - throw new Error(`No email found ending with ${RECOUP_EMAIL_DOMAIN} in the 'to' or 'cc' array`); + throw new Error(`No email found ending with ${INBOUND_EMAIL_DOMAIN} in the 'to' or 'cc' array`); } // Extract the name part (everything before the @ sign) for a human-readable from name From 24577a6ba59a919bc9c840fdfc9d4742ee06d36a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 7 Jan 2026 11:37:30 -0500 Subject: [PATCH 07/10] docs: update CLAUDE.md with lib/const.ts constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 854b4e8b..4300a2ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,14 @@ pnpm format:check # Check formatting ## Key Patterns -- Use constants from `lib/consts.ts` and `lib/const.ts` -- Email domains: inbound at `@mail.recoupable.com`, outbound from `@recoupable.com` - All API routes should have JSDoc comments - Run `pnpm lint` before committing + +## Constants (`lib/const.ts`) + +All shared constants live in `lib/const.ts`: + +- `INBOUND_EMAIL_DOMAIN` - `@mail.recoupable.com` (where emails are received) +- `OUTBOUND_EMAIL_DOMAIN` - `@recoupable.com` (where emails are sent from) +- `SUPABASE_STORAGE_BUCKET` - Storage bucket name +- Wallet addresses, model names, API keys From 45013629f4677dfeec4854d88bcdffe63017b763 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 7 Jan 2026 11:40:03 -0500 Subject: [PATCH 08/10] test: add unit tests for getFromWithName outbound domain conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Test inbound @mail.recoupable.com converts to outbound @recoupable.com - Test finding email in to/cc arrays - Test error handling for missing recoup email - Test name capitalization formatting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../inbound/__tests__/getFromWithName.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 lib/emails/inbound/__tests__/getFromWithName.test.ts diff --git a/lib/emails/inbound/__tests__/getFromWithName.test.ts b/lib/emails/inbound/__tests__/getFromWithName.test.ts new file mode 100644 index 00000000..aada3278 --- /dev/null +++ b/lib/emails/inbound/__tests__/getFromWithName.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { getFromWithName } from "../getFromWithName"; + +describe("getFromWithName", () => { + describe("outbound domain conversion", () => { + it("converts inbound @mail.recoupable.com to outbound @recoupable.com", () => { + const result = getFromWithName(["support@mail.recoupable.com"]); + + expect(result).toBe("Support "); + }); + + it("preserves the email name when converting domains", () => { + const result = getFromWithName(["agent@mail.recoupable.com"]); + + expect(result).toBe("Agent "); + }); + }); + + describe("finding inbound email", () => { + it("finds recoup email in to array", () => { + const result = getFromWithName(["hello@mail.recoupable.com"]); + + expect(result).toBe("Hello "); + }); + + it("finds recoup email among multiple to addresses", () => { + const result = getFromWithName([ + "other@example.com", + "support@mail.recoupable.com", + "another@example.com", + ]); + + expect(result).toBe("Support "); + }); + + it("falls back to cc array when not in to array", () => { + const result = getFromWithName( + ["other@example.com"], + ["support@mail.recoupable.com"], + ); + + expect(result).toBe("Support "); + }); + + it("prefers to array over cc array", () => { + const result = getFromWithName( + ["to-agent@mail.recoupable.com"], + ["cc-agent@mail.recoupable.com"], + ); + + expect(result).toBe("To-agent "); + }); + + it("handles case-insensitive domain matching", () => { + const result = getFromWithName(["Support@MAIL.RECOUPABLE.COM"]); + + expect(result).toBe("Support "); + }); + }); + + describe("error handling", () => { + it("throws error when no recoup email found in to or cc", () => { + expect(() => getFromWithName(["other@example.com"])).toThrow( + "No email found ending with @mail.recoupable.com", + ); + }); + + it("throws error when arrays are empty", () => { + expect(() => getFromWithName([])).toThrow( + "No email found ending with @mail.recoupable.com", + ); + }); + }); + + describe("name formatting", () => { + it("capitalizes first letter of name", () => { + const result = getFromWithName(["lowercase@mail.recoupable.com"]); + + expect(result).toBe("Lowercase "); + }); + + it("preserves rest of name casing", () => { + const result = getFromWithName(["myAgent@mail.recoupable.com"]); + + expect(result).toBe("MyAgent "); + }); + }); +}); From 29d446d60e751b9e8d75ac97552b26b5812a8dc5 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 7 Jan 2026 11:44:27 -0500 Subject: [PATCH 09/10] feat: update email display name to "[Name] by Recoup" format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changes FROM display from "Support " to "Support by Recoup " - Update tests to match new format 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../inbound/__tests__/getFromWithName.test.ts | 18 +++++++++--------- lib/emails/inbound/getFromWithName.ts | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/emails/inbound/__tests__/getFromWithName.test.ts b/lib/emails/inbound/__tests__/getFromWithName.test.ts index aada3278..bb4efd98 100644 --- a/lib/emails/inbound/__tests__/getFromWithName.test.ts +++ b/lib/emails/inbound/__tests__/getFromWithName.test.ts @@ -6,13 +6,13 @@ describe("getFromWithName", () => { it("converts inbound @mail.recoupable.com to outbound @recoupable.com", () => { const result = getFromWithName(["support@mail.recoupable.com"]); - expect(result).toBe("Support "); + expect(result).toBe("Support by Recoup "); }); it("preserves the email name when converting domains", () => { const result = getFromWithName(["agent@mail.recoupable.com"]); - expect(result).toBe("Agent "); + expect(result).toBe("Agent by Recoup "); }); }); @@ -20,7 +20,7 @@ describe("getFromWithName", () => { it("finds recoup email in to array", () => { const result = getFromWithName(["hello@mail.recoupable.com"]); - expect(result).toBe("Hello "); + expect(result).toBe("Hello by Recoup "); }); it("finds recoup email among multiple to addresses", () => { @@ -30,7 +30,7 @@ describe("getFromWithName", () => { "another@example.com", ]); - expect(result).toBe("Support "); + expect(result).toBe("Support by Recoup "); }); it("falls back to cc array when not in to array", () => { @@ -39,7 +39,7 @@ describe("getFromWithName", () => { ["support@mail.recoupable.com"], ); - expect(result).toBe("Support "); + expect(result).toBe("Support by Recoup "); }); it("prefers to array over cc array", () => { @@ -48,13 +48,13 @@ describe("getFromWithName", () => { ["cc-agent@mail.recoupable.com"], ); - expect(result).toBe("To-agent "); + expect(result).toBe("To-agent by Recoup "); }); it("handles case-insensitive domain matching", () => { const result = getFromWithName(["Support@MAIL.RECOUPABLE.COM"]); - expect(result).toBe("Support "); + expect(result).toBe("Support by Recoup "); }); }); @@ -76,13 +76,13 @@ describe("getFromWithName", () => { it("capitalizes first letter of name", () => { const result = getFromWithName(["lowercase@mail.recoupable.com"]); - expect(result).toBe("Lowercase "); + expect(result).toBe("Lowercase by Recoup "); }); it("preserves rest of name casing", () => { const result = getFromWithName(["myAgent@mail.recoupable.com"]); - expect(result).toBe("MyAgent "); + expect(result).toBe("MyAgent by Recoup "); }); }); }); diff --git a/lib/emails/inbound/getFromWithName.ts b/lib/emails/inbound/getFromWithName.ts index 1076f113..cac86361 100644 --- a/lib/emails/inbound/getFromWithName.ts +++ b/lib/emails/inbound/getFromWithName.ts @@ -6,7 +6,7 @@ import { OUTBOUND_EMAIL_DOMAIN, INBOUND_EMAIL_DOMAIN } from "@/lib/const"; * * @param toEmails - Array of email addresses from the 'to' field * @param ccEmails - Optional array of email addresses from the 'cc' field (fallback) - * @returns Formatted email address with display name (e.g., "Support ") + * @returns Formatted email address with display name (e.g., "Support by Recoup ") * @throws Error if no email ending with the inbound domain is found in either array */ export function getFromWithName(toEmails: string[], ccEmails: string[] = []): string { @@ -29,5 +29,5 @@ export function getFromWithName(toEmails: string[], ccEmails: string[] = []): st // Convert to outbound domain for sending const outboundEmail = emailNameRaw + OUTBOUND_EMAIL_DOMAIN; - return `${emailName} <${outboundEmail}>`; + return `${emailName} by Recoup <${outboundEmail}>`; } From 26d5353515678432d6c8860bf4b7e6a8ab5e53e4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 7 Jan 2026 11:49:55 -0500 Subject: [PATCH 10/10] ci: run tests on PRs to test branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da09286a..00c51ad0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: [main, test] jobs: test: