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: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..4300a2ce --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# 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 + +- 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 diff --git a/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts b/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts index f3949295..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 } 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 (@mail.recoupable.com) 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 622d20c3..271afcf3 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -12,3 +12,10 @@ 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; +/** 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"; + +export const SUPABASE_STORAGE_BUCKET = "user-files"; diff --git a/lib/consts.ts b/lib/consts.ts deleted file mode 100644 index dc1ee155..00000000 --- a/lib/consts.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Shared constants for Recoup-API - */ - -export const SUPABASE_STORAGE_BUCKET = "user-files"; - diff --git a/lib/emails/containsRecoupEmail.ts b/lib/emails/containsRecoupEmail.ts new file mode 100644 index 00000000..e3c29b4c --- /dev/null +++ b/lib/emails/containsRecoupEmail.ts @@ -0,0 +1,11 @@ +import { INBOUND_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(INBOUND_EMAIL_DOMAIN)); +} diff --git a/lib/emails/inbound/__tests__/getFromWithName.test.ts b/lib/emails/inbound/__tests__/getFromWithName.test.ts new file mode 100644 index 00000000..bb4efd98 --- /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 by Recoup "); + }); + + it("preserves the email name when converting domains", () => { + const result = getFromWithName(["agent@mail.recoupable.com"]); + + expect(result).toBe("Agent by Recoup "); + }); + }); + + describe("finding inbound email", () => { + it("finds recoup email in to array", () => { + const result = getFromWithName(["hello@mail.recoupable.com"]); + + expect(result).toBe("Hello by Recoup "); + }); + + 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 by Recoup "); + }); + + 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 by Recoup "); + }); + + 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 by Recoup "); + }); + + it("handles case-insensitive domain matching", () => { + const result = getFromWithName(["Support@MAIL.RECOUPABLE.COM"]); + + expect(result).toBe("Support by Recoup "); + }); + }); + + 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 by Recoup "); + }); + + it("preserves rest of name casing", () => { + const result = getFromWithName(["myAgent@mail.recoupable.com"]); + + expect(result).toBe("MyAgent by Recoup "); + }); + }); +}); diff --git a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts index 08eaacd8..4e64228e 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 { INBOUND_EMAIL_DOMAIN } from "@/lib/const"; const mockGenerate = vi.fn(); @@ -28,47 +29,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${INBOUND_EMAIL_DOMAIN}`], + cc: [], + }; - const emailData: ResendEmailData = { - ...baseEmailData, - to: ["hi@mail.recoupable.com"], - cc: [], - }; + const result = await validateCcReplyExpected(emailData, "Hello"); - await validateCcReplyExpected(emailData, "Hello"); + expect(mockGenerate).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); - expect(mockGenerate).toHaveBeenCalledTimes(1); + it("handles multiple TO addresses with recoup email", async () => { + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["other@example.com", `hi${INBOUND_EMAIL_DOMAIN}`], + cc: [], + }; + + const result = await validateCcReplyExpected(emailData, "Hello"); + + expect(mockGenerate).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); }); - it("returns null when agent returns shouldReply: true", async () => { - mockGenerate.mockResolvedValue({ output: { shouldReply: true } }); + describe("when recoup email is only in CC", () => { + it("calls agent to determine if reply is expected", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: true } }); - const emailData: ResendEmailData = { - ...baseEmailData, - to: ["hi@mail.recoupable.com"], - cc: [], - }; + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["someone@example.com"], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`], + }; + + await validateCcReplyExpected(emailData, "FYI"); + + expect(mockGenerate).toHaveBeenCalledTimes(1); + }); + + it("returns null when agent returns shouldReply: true", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: true } }); + + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["someone@example.com"], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`], + }; + + const result = await validateCcReplyExpected(emailData, "Please review"); + + expect(result).toBeNull(); + }); - const result = await validateCcReplyExpected(emailData, "Hello"); + it("returns response when agent returns shouldReply: false", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: false } }); - expect(result).toBeNull(); + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["someone@example.com"], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`], + }; + + 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${INBOUND_EMAIL_DOMAIN}`], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`], + }; + + 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${INBOUND_EMAIL_DOMAIN}`], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`], + }; + + 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${INBOUND_EMAIL_DOMAIN}`], + cc: [`hi${INBOUND_EMAIL_DOMAIN}`], + }; + + 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 +153,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${INBOUND_EMAIL_DOMAIN}`, "cc@example.com"], subject: "Test Subject", }; diff --git a/lib/emails/inbound/getFromWithName.ts b/lib/emails/inbound/getFromWithName.ts index 5e227dad..cac86361 100644 --- a/lib/emails/inbound/getFromWithName.ts +++ b/lib/emails/inbound/getFromWithName.ts @@ -1,28 +1,33 @@ +import { OUTBOUND_EMAIL_DOMAIN, INBOUND_EMAIL_DOMAIN } from "@/lib/const"; + /** * 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 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 { - // 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} by Recoup <${outboundEmail}>`; } diff --git a/lib/emails/inbound/validateCcReplyExpected.ts b/lib/emails/inbound/validateCcReplyExpected.ts index 25410b0a..ad33f258 100644 --- a/lib/emails/inbound/validateCcReplyExpected.ts +++ b/lib/emails/inbound/validateCcReplyExpected.ts @@ -1,9 +1,15 @@ import { NextResponse } from "next/server"; import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; import { shouldReplyToCcEmail } from "@/lib/emails/inbound/shouldReplyToCcEmail"; +import { containsRecoupEmail } from "@/lib/emails/containsRecoupEmail"; /** - * 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 +19,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, 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