Skip to content
Merged
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, 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
Expand Down
8 changes: 7 additions & 1 deletion lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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., [email protected]) */
export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com";

/** Domain for sending outbound emails (e.g., [email protected]) */
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.

4 changes: 2 additions & 2 deletions lib/emails/containsRecoupEmail.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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));
}
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(["[email protected]"]);

expect(result).toBe("Support by Recoup <[email protected]>");
});

it("preserves the email name when converting domains", () => {
const result = getFromWithName(["[email protected]"]);

expect(result).toBe("Agent by Recoup <[email protected]>");
});
});

describe("finding inbound email", () => {
it("finds recoup email in to array", () => {
const result = getFromWithName(["[email protected]"]);

expect(result).toBe("Hello by Recoup <[email protected]>");
});

it("finds recoup email among multiple to addresses", () => {
const result = getFromWithName([
"[email protected]",
"[email protected]",
"[email protected]",
]);

expect(result).toBe("Support by Recoup <[email protected]>");
});

it("falls back to cc array when not in to array", () => {
const result = getFromWithName(
["[email protected]"],
["[email protected]"],
);

expect(result).toBe("Support by Recoup <[email protected]>");
});

it("prefers to array over cc array", () => {
const result = getFromWithName(
["[email protected]"],
["[email protected]"],
);

expect(result).toBe("To-agent by Recoup <[email protected]>");
});

it("handles case-insensitive domain matching", () => {
const result = getFromWithName(["[email protected]"]);

expect(result).toBe("Support by Recoup <[email protected]>");
});
});

describe("error handling", () => {
it("throws error when no recoup email found in to or cc", () => {
expect(() => getFromWithName(["[email protected]"])).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(["[email protected]"]);

expect(result).toBe("Lowercase by Recoup <[email protected]>");
});

it("preserves rest of name casing", () => {
const result = getFromWithName(["[email protected]"]);

expect(result).toBe("MyAgent by Recoup <[email protected]>");
});
});
});
26 changes: 13 additions & 13 deletions lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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: [],
};

Expand All @@ -46,7 +46,7 @@ describe("validateCcReplyExpected", () => {
it("handles multiple TO addresses with recoup email", async () => {
const emailData: ResendEmailData = {
...baseEmailData,
to: ["[email protected]", `hi${RECOUP_EMAIL_DOMAIN}`],
to: ["[email protected]", `hi${INBOUND_EMAIL_DOMAIN}`],
cc: [],
};

Expand All @@ -64,7 +64,7 @@ describe("validateCcReplyExpected", () => {
const emailData: ResendEmailData = {
...baseEmailData,
to: ["[email protected]"],
cc: [`hi${RECOUP_EMAIL_DOMAIN}`],
cc: [`hi${INBOUND_EMAIL_DOMAIN}`],
};

await validateCcReplyExpected(emailData, "FYI");
Expand All @@ -78,7 +78,7 @@ describe("validateCcReplyExpected", () => {
const emailData: ResendEmailData = {
...baseEmailData,
to: ["[email protected]"],
cc: [`hi${RECOUP_EMAIL_DOMAIN}`],
cc: [`hi${INBOUND_EMAIL_DOMAIN}`],
};

const result = await validateCcReplyExpected(emailData, "Please review");
Expand All @@ -92,7 +92,7 @@ describe("validateCcReplyExpected", () => {
const emailData: ResendEmailData = {
...baseEmailData,
to: ["[email protected]"],
cc: [`hi${RECOUP_EMAIL_DOMAIN}`],
cc: [`hi${INBOUND_EMAIL_DOMAIN}`],
};

const result = await validateCcReplyExpected(emailData, "FYI");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -154,7 +154,7 @@ describe("validateCcReplyExpected", () => {
...baseEmailData,
from: "[email protected]",
to: ["[email protected]"],
cc: [`hi${RECOUP_EMAIL_DOMAIN}`, "[email protected]"],
cc: [`hi${INBOUND_EMAIL_DOMAIN}`, "[email protected]"],
subject: "Test Subject",
};

Expand Down
27 changes: 16 additions & 11 deletions lib/emails/inbound/getFromWithName.ts
Original file line number Diff line number Diff line change
@@ -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 <support@mail.recoupable.com>")
* @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 <[email protected]>")
* @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}>`;
}
2 changes: 1 addition & 1 deletion lib/supabase/storage/uploadFileByKey.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down