Skip to content

Commit 058c714

Browse files
authored
Merge pull request #93 from Recoupable-com/test
Test
2 parents 2f97381 + 9ff616e commit 058c714

11 files changed

Lines changed: 302 additions & 53 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
push:
55
branches: [main]
66
pull_request:
7-
branches: [main]
7+
branches: [main, test]
88

99
jobs:
1010
test:

CLAUDE.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Git Workflow
6+
7+
**Always commit and push changes after completing a task.** Follow these rules:
8+
9+
1. After making code changes, always commit with a descriptive message
10+
2. Push commits to the current feature branch
11+
3. **NEVER push directly to `main` or `test` branches** - always use feature branches and PRs
12+
4. Before pushing, verify the current branch is not `main` or `test`
13+
14+
## Build Commands
15+
16+
```bash
17+
pnpm install # Install dependencies
18+
pnpm dev # Start dev server
19+
pnpm build # Production build
20+
pnpm test # Run vitest
21+
pnpm test:watch # Watch mode
22+
pnpm lint # Fix lint issues
23+
pnpm lint:check # Check for lint issues
24+
pnpm format # Run prettier
25+
pnpm format:check # Check formatting
26+
```
27+
28+
## Architecture
29+
30+
- **Next.js 16** API service with App Router
31+
- **x402-next** middleware for crypto payments on Base network
32+
- `app/api/` - API routes (image generation, artists, accounts, etc.)
33+
- `lib/` - Business logic organized by domain:
34+
- `lib/ai/` - AI/LLM integrations
35+
- `lib/emails/` - Email handling (Resend)
36+
- `lib/supabase/` - Database operations
37+
- `lib/trigger/` - Trigger.dev task triggers
38+
- `lib/x402/` - Payment middleware utilities
39+
40+
## Key Patterns
41+
42+
- All API routes should have JSDoc comments
43+
- Run `pnpm lint` before committing
44+
45+
## Constants (`lib/const.ts`)
46+
47+
All shared constants live in `lib/const.ts`:
48+
49+
- `INBOUND_EMAIL_DOMAIN` - `@mail.recoupable.com` (where emails are received)
50+
- `OUTBOUND_EMAIL_DOMAIN` - `@recoupable.com` (where emails are sent from)
51+
- `SUPABASE_STORAGE_BUCKET` - Storage bucket name
52+
- Wallet addresses, model names, API keys

lib/agents/EmailReplyAgent/createEmailReplyAgent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Output, ToolLoopAgent, stepCountIs } from "ai";
22
import { z } from "zod";
3-
import { LIGHTWEIGHT_MODEL } from "@/lib/const";
3+
import { LIGHTWEIGHT_MODEL, INBOUND_EMAIL_DOMAIN } from "@/lib/const";
44

55
const replyDecisionSchema = z.object({
66
shouldReply: z.boolean().describe("Whether the Recoup AI assistant should reply to this email"),
77
});
88

9-
const instructions = `You analyze emails to determine if a Recoup AI assistant (@mail.recoupable.com) should reply.
9+
const instructions = `You analyze emails to determine if a Recoup AI assistant (${INBOUND_EMAIL_DOMAIN}) should reply.
1010
1111
Rules (check in this order):
1212
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

lib/const.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,10 @@ export const IMAGE_GENERATE_PRICE = "0.15";
1212
export const DEFAULT_MODEL = "openai/gpt-5-mini";
1313
export const LIGHTWEIGHT_MODEL = "openai/gpt-4o-mini";
1414
export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET;
15+
/** Domain for receiving inbound emails (e.g., support@mail.recoupable.com) */
16+
export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com";
17+
18+
/** Domain for sending outbound emails (e.g., support@recoupable.com) */
19+
export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com";
20+
21+
export const SUPABASE_STORAGE_BUCKET = "user-files";

lib/consts.ts

Lines changed: 0 additions & 6 deletions
This file was deleted.

lib/emails/containsRecoupEmail.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { INBOUND_EMAIL_DOMAIN } from "@/lib/const";
2+
3+
/**
4+
* Checks if any email address in the array is a recoup email address.
5+
*
6+
* @param addresses - The array of email addresses to check
7+
* @returns True if any address is a recoup email, false otherwise
8+
*/
9+
export function containsRecoupEmail(addresses: string[]): boolean {
10+
return addresses.some(addr => addr.toLowerCase().includes(INBOUND_EMAIL_DOMAIN));
11+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, it, expect } from "vitest";
2+
import { getFromWithName } from "../getFromWithName";
3+
4+
describe("getFromWithName", () => {
5+
describe("outbound domain conversion", () => {
6+
it("converts inbound @mail.recoupable.com to outbound @recoupable.com", () => {
7+
const result = getFromWithName(["support@mail.recoupable.com"]);
8+
9+
expect(result).toBe("Support by Recoup <support@recoupable.com>");
10+
});
11+
12+
it("preserves the email name when converting domains", () => {
13+
const result = getFromWithName(["agent@mail.recoupable.com"]);
14+
15+
expect(result).toBe("Agent by Recoup <agent@recoupable.com>");
16+
});
17+
});
18+
19+
describe("finding inbound email", () => {
20+
it("finds recoup email in to array", () => {
21+
const result = getFromWithName(["hello@mail.recoupable.com"]);
22+
23+
expect(result).toBe("Hello by Recoup <hello@recoupable.com>");
24+
});
25+
26+
it("finds recoup email among multiple to addresses", () => {
27+
const result = getFromWithName([
28+
"other@example.com",
29+
"support@mail.recoupable.com",
30+
"another@example.com",
31+
]);
32+
33+
expect(result).toBe("Support by Recoup <support@recoupable.com>");
34+
});
35+
36+
it("falls back to cc array when not in to array", () => {
37+
const result = getFromWithName(
38+
["other@example.com"],
39+
["support@mail.recoupable.com"],
40+
);
41+
42+
expect(result).toBe("Support by Recoup <support@recoupable.com>");
43+
});
44+
45+
it("prefers to array over cc array", () => {
46+
const result = getFromWithName(
47+
["to-agent@mail.recoupable.com"],
48+
["cc-agent@mail.recoupable.com"],
49+
);
50+
51+
expect(result).toBe("To-agent by Recoup <to-agent@recoupable.com>");
52+
});
53+
54+
it("handles case-insensitive domain matching", () => {
55+
const result = getFromWithName(["Support@MAIL.RECOUPABLE.COM"]);
56+
57+
expect(result).toBe("Support by Recoup <Support@recoupable.com>");
58+
});
59+
});
60+
61+
describe("error handling", () => {
62+
it("throws error when no recoup email found in to or cc", () => {
63+
expect(() => getFromWithName(["other@example.com"])).toThrow(
64+
"No email found ending with @mail.recoupable.com",
65+
);
66+
});
67+
68+
it("throws error when arrays are empty", () => {
69+
expect(() => getFromWithName([])).toThrow(
70+
"No email found ending with @mail.recoupable.com",
71+
);
72+
});
73+
});
74+
75+
describe("name formatting", () => {
76+
it("capitalizes first letter of name", () => {
77+
const result = getFromWithName(["lowercase@mail.recoupable.com"]);
78+
79+
expect(result).toBe("Lowercase by Recoup <lowercase@recoupable.com>");
80+
});
81+
82+
it("preserves rest of name casing", () => {
83+
const result = getFromWithName(["myAgent@mail.recoupable.com"]);
84+
85+
expect(result).toBe("MyAgent by Recoup <myAgent@recoupable.com>");
86+
});
87+
});
88+
});

lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts

Lines changed: 106 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
22
import { validateCcReplyExpected } from "../validateCcReplyExpected";
33
import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent";
4+
import { INBOUND_EMAIL_DOMAIN } from "@/lib/const";
45

56
const mockGenerate = vi.fn();
67

@@ -28,47 +29,122 @@ describe("validateCcReplyExpected", () => {
2829
vi.clearAllMocks();
2930
});
3031

31-
it("always calls agent.generate regardless of TO/CC", async () => {
32-
mockGenerate.mockResolvedValue({ output: { shouldReply: true } });
32+
describe("when recoup email is only in TO (not CC)", () => {
33+
it("skips agent call and returns null (always reply)", async () => {
34+
const emailData: ResendEmailData = {
35+
...baseEmailData,
36+
to: [`hi${INBOUND_EMAIL_DOMAIN}`],
37+
cc: [],
38+
};
3339

34-
const emailData: ResendEmailData = {
35-
...baseEmailData,
36-
to: ["hi@mail.recoupable.com"],
37-
cc: [],
38-
};
40+
const result = await validateCcReplyExpected(emailData, "Hello");
3941

40-
await validateCcReplyExpected(emailData, "Hello");
42+
expect(mockGenerate).not.toHaveBeenCalled();
43+
expect(result).toBeNull();
44+
});
4145

42-
expect(mockGenerate).toHaveBeenCalledTimes(1);
46+
it("handles multiple TO addresses with recoup email", async () => {
47+
const emailData: ResendEmailData = {
48+
...baseEmailData,
49+
to: ["other@example.com", `hi${INBOUND_EMAIL_DOMAIN}`],
50+
cc: [],
51+
};
52+
53+
const result = await validateCcReplyExpected(emailData, "Hello");
54+
55+
expect(mockGenerate).not.toHaveBeenCalled();
56+
expect(result).toBeNull();
57+
});
4358
});
4459

45-
it("returns null when agent returns shouldReply: true", async () => {
46-
mockGenerate.mockResolvedValue({ output: { shouldReply: true } });
60+
describe("when recoup email is only in CC", () => {
61+
it("calls agent to determine if reply is expected", async () => {
62+
mockGenerate.mockResolvedValue({ output: { shouldReply: true } });
4763

48-
const emailData: ResendEmailData = {
49-
...baseEmailData,
50-
to: ["hi@mail.recoupable.com"],
51-
cc: [],
52-
};
64+
const emailData: ResendEmailData = {
65+
...baseEmailData,
66+
to: ["someone@example.com"],
67+
cc: [`hi${INBOUND_EMAIL_DOMAIN}`],
68+
};
69+
70+
await validateCcReplyExpected(emailData, "FYI");
71+
72+
expect(mockGenerate).toHaveBeenCalledTimes(1);
73+
});
74+
75+
it("returns null when agent returns shouldReply: true", async () => {
76+
mockGenerate.mockResolvedValue({ output: { shouldReply: true } });
77+
78+
const emailData: ResendEmailData = {
79+
...baseEmailData,
80+
to: ["someone@example.com"],
81+
cc: [`hi${INBOUND_EMAIL_DOMAIN}`],
82+
};
83+
84+
const result = await validateCcReplyExpected(emailData, "Please review");
85+
86+
expect(result).toBeNull();
87+
});
5388

54-
const result = await validateCcReplyExpected(emailData, "Hello");
89+
it("returns response when agent returns shouldReply: false", async () => {
90+
mockGenerate.mockResolvedValue({ output: { shouldReply: false } });
5591

56-
expect(result).toBeNull();
92+
const emailData: ResendEmailData = {
93+
...baseEmailData,
94+
to: ["someone@example.com"],
95+
cc: [`hi${INBOUND_EMAIL_DOMAIN}`],
96+
};
97+
98+
const result = await validateCcReplyExpected(emailData, "FYI");
99+
100+
expect(result).not.toBeNull();
101+
expect(result?.response).toBeDefined();
102+
});
57103
});
58104

59-
it("returns response when agent returns shouldReply: false", async () => {
60-
mockGenerate.mockResolvedValue({ output: { shouldReply: false } });
105+
describe("when recoup email is in both TO and CC", () => {
106+
it("treats as CC and calls agent", async () => {
107+
mockGenerate.mockResolvedValue({ output: { shouldReply: true } });
61108

62-
const emailData: ResendEmailData = {
63-
...baseEmailData,
64-
to: ["someone@example.com"],
65-
cc: ["hi@mail.recoupable.com"],
66-
};
109+
const emailData: ResendEmailData = {
110+
...baseEmailData,
111+
to: [`hi${INBOUND_EMAIL_DOMAIN}`],
112+
cc: [`hi${INBOUND_EMAIL_DOMAIN}`],
113+
};
114+
115+
await validateCcReplyExpected(emailData, "Hello");
116+
117+
expect(mockGenerate).toHaveBeenCalledTimes(1);
118+
});
67119

68-
const result = await validateCcReplyExpected(emailData, "FYI");
120+
it("returns null when agent returns shouldReply: true", async () => {
121+
mockGenerate.mockResolvedValue({ output: { shouldReply: true } });
69122

70-
expect(result).not.toBeNull();
71-
expect(result?.response).toBeDefined();
123+
const emailData: ResendEmailData = {
124+
...baseEmailData,
125+
to: [`hi${INBOUND_EMAIL_DOMAIN}`],
126+
cc: [`hi${INBOUND_EMAIL_DOMAIN}`],
127+
};
128+
129+
const result = await validateCcReplyExpected(emailData, "Hello");
130+
131+
expect(result).toBeNull();
132+
});
133+
134+
it("returns response when agent returns shouldReply: false", async () => {
135+
mockGenerate.mockResolvedValue({ output: { shouldReply: false } });
136+
137+
const emailData: ResendEmailData = {
138+
...baseEmailData,
139+
to: [`hi${INBOUND_EMAIL_DOMAIN}`],
140+
cc: [`hi${INBOUND_EMAIL_DOMAIN}`],
141+
};
142+
143+
const result = await validateCcReplyExpected(emailData, "FYI");
144+
145+
expect(result).not.toBeNull();
146+
expect(result?.response).toBeDefined();
147+
});
72148
});
73149

74150
it("passes email context in prompt to agent.generate", async () => {
@@ -77,8 +153,8 @@ describe("validateCcReplyExpected", () => {
77153
const emailData: ResendEmailData = {
78154
...baseEmailData,
79155
from: "test@example.com",
80-
to: ["hi@mail.recoupable.com"],
81-
cc: ["cc@example.com"],
156+
to: ["someone@example.com"],
157+
cc: [`hi${INBOUND_EMAIL_DOMAIN}`, "cc@example.com"],
82158
subject: "Test Subject",
83159
};
84160

0 commit comments

Comments
 (0)