Skip to content
Merged

Test #93

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, test]

jobs:
test:
Expand Down
52 changes: 52 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions lib/agents/EmailReplyAgent/createEmailReplyAgent.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 7 additions & 0 deletions lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
6 changes: 0 additions & 6 deletions lib/consts.ts

This file was deleted.

11 changes: 11 additions & 0 deletions lib/emails/containsRecoupEmail.ts
Original file line number Diff line number Diff line change
@@ -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));
}
88 changes: 88 additions & 0 deletions lib/emails/inbound/__tests__/getFromWithName.test.ts
Original file line number Diff line number Diff line change
@@ -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 <support@recoupable.com>");
});

it("preserves the email name when converting domains", () => {
const result = getFromWithName(["agent@mail.recoupable.com"]);

expect(result).toBe("Agent by Recoup <agent@recoupable.com>");
});
});

describe("finding inbound email", () => {
it("finds recoup email in to array", () => {
const result = getFromWithName(["hello@mail.recoupable.com"]);

expect(result).toBe("Hello by Recoup <hello@recoupable.com>");
});

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 <support@recoupable.com>");
});

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 <support@recoupable.com>");
});

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 <to-agent@recoupable.com>");
});

it("handles case-insensitive domain matching", () => {
const result = getFromWithName(["Support@MAIL.RECOUPABLE.COM"]);

expect(result).toBe("Support by Recoup <Support@recoupable.com>");
});
});

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 <lowercase@recoupable.com>");
});

it("preserves rest of name casing", () => {
const result = getFromWithName(["myAgent@mail.recoupable.com"]);

expect(result).toBe("MyAgent by Recoup <myAgent@recoupable.com>");
});
});
});
136 changes: 106 additions & 30 deletions lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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",
};

Expand Down
Loading