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 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 9ca94fc2..271afcf3 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -12,4 +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; -export const RECOUP_EMAIL_DOMAIN = "@mail.recoupable.com"; +/** 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 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__/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 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 496efe2b..cac86361 100644 --- a/lib/emails/inbound/getFromWithName.ts +++ b/lib/emails/inbound/getFromWithName.ts @@ -1,28 +1,33 @@ -import { 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. + * 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(RECOUP_EMAIL_DOMAIN)); + // 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(RECOUP_EMAIL_DOMAIN)); + if (!inboundEmail) { + inboundEmail = ccEmails.find(email => email.toLowerCase().endsWith(INBOUND_EMAIL_DOMAIN)); } - if (!customFromEmail) { - throw new Error(`No email found ending with ${RECOUP_EMAIL_DOMAIN} 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/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