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
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"ralph-wiggum@claude-plugins-official": true
}
}
31 changes: 31 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Test

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Install dependencies
run: pnpm install

- name: Run tests
run: pnpm test
29 changes: 29 additions & 0 deletions lib/agents/EmailReplyAgent/createEmailReplyAgent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Output, ToolLoopAgent, stepCountIs } from "ai";
import { z } from "zod";
import { LIGHTWEIGHT_MODEL } 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.

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
2. If Recoup is in TO and the email asks a question or requests help → return true
3. If Recoup is ONLY in CC: return true only if directly addressed, otherwise return false
4. When in doubt, return false`;

/**
* Creates a ToolLoopAgent configured for email reply decisions.
*
* @returns A configured ToolLoopAgent instance for determining if a reply is needed.
*/
export function createEmailReplyAgent() {
return new ToolLoopAgent({
model: LIGHTWEIGHT_MODEL,
instructions,
output: Output.object({ schema: replyDecisionSchema }),
stopWhen: stepCountIs(1),
});
}
1 change: 1 addition & 0 deletions lib/agents/EmailReplyAgent/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createEmailReplyAgent } from "./createEmailReplyAgent";
98 changes: 98 additions & 0 deletions lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { validateCcReplyExpected } from "../validateCcReplyExpected";
import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent";

const mockGenerate = vi.fn();

// Mock the EmailReplyAgent
vi.mock("@/lib/agents/EmailReplyAgent", () => ({
createEmailReplyAgent: vi.fn().mockImplementation(() => ({
generate: mockGenerate,
})),
}));

describe("validateCcReplyExpected", () => {
const baseEmailData: ResendEmailData = {
email_id: "test-123",
created_at: "2024-01-01T00:00:00Z",
from: "sender@example.com",
to: [],
cc: [],
bcc: [],
message_id: "<test@example.com>",
subject: "Test Subject",
attachments: [],
};

beforeEach(() => {
vi.clearAllMocks();
});

it("always calls agent.generate regardless of TO/CC", async () => {
mockGenerate.mockResolvedValue({ output: { shouldReply: true } });

const emailData: ResendEmailData = {
...baseEmailData,
to: ["hi@mail.recoupable.com"],
cc: [],
};

await validateCcReplyExpected(emailData, "Hello");

expect(mockGenerate).toHaveBeenCalledTimes(1);
});

it("returns null when agent returns shouldReply: true", async () => {
mockGenerate.mockResolvedValue({ output: { shouldReply: true } });

const emailData: ResendEmailData = {
...baseEmailData,
to: ["hi@mail.recoupable.com"],
cc: [],
};

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: ["someone@example.com"],
cc: ["hi@mail.recoupable.com"],
};

const result = await validateCcReplyExpected(emailData, "FYI");

expect(result).not.toBeNull();
expect(result?.response).toBeDefined();
});

it("passes email context in prompt to agent.generate", async () => {
mockGenerate.mockResolvedValue({ output: { shouldReply: true } });

const emailData: ResendEmailData = {
...baseEmailData,
from: "test@example.com",
to: ["hi@mail.recoupable.com"],
cc: ["cc@example.com"],
subject: "Test Subject",
};

await validateCcReplyExpected(emailData, "Email body");

expect(mockGenerate).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining("test@example.com"),
}),
);
expect(mockGenerate).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining("Email body"),
}),
);
});
});
14 changes: 10 additions & 4 deletions lib/emails/inbound/getFromWithName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@
* Gets a formatted "from" email address with a human-readable name.
*
* @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
* @throws Error if no email ending with "@mail.recoupable.com" is found in either array
*/
export function getFromWithName(toEmails: string[]): string {
export function getFromWithName(toEmails: string[], ccEmails: string[] = []): string {
// Find the first email in the 'to' array that ends with "@mail.recoupable.com"
const customFromEmail = toEmails.find(email =>
let customFromEmail = toEmails.find(email =>
email.toLowerCase().endsWith("@mail.recoupable.com"),
);

// If not found in 'to', check the 'cc' array as fallback
if (!customFromEmail) {
throw new Error("No email found ending with @mail.recoupable.com in the 'to' array");
customFromEmail = ccEmails.find(email => email.toLowerCase().endsWith("@mail.recoupable.com"));
}

if (!customFromEmail) {
throw new Error("No email found ending with @mail.recoupable.com in the 'to' or 'cc' array");
}

// Extract the name part (everything before the @ sign) for a human-readable from name
Expand Down
12 changes: 10 additions & 2 deletions lib/emails/inbound/respondToInboundEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import insertMemories from "@/lib/supabase/memories/insertMemories";
import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories";
import { validateNewEmailMemory } from "@/lib/emails/inbound/validateNewEmailMemory";
import { generateEmailResponse } from "@/lib/emails/inbound/generateEmailResponse";
import { validateCcReplyExpected } from "@/lib/emails/inbound/validateCcReplyExpected";

/**
* Responds to an inbound email by sending a hard-coded reply in the same thread.
Expand All @@ -24,15 +25,22 @@ export async function respondToInboundEmail(
const messageId = original.message_id;
const to = original.from;
const toArray = [to];
const from = getFromWithName(original.to);
const from = getFromWithName(original.to, original.cc);

// Validate new memory and get chat request body (or early return if duplicate)
const validationResult = await validateNewEmailMemory(event);
if ("response" in validationResult) {
return validationResult.response;
}

const { chatRequestBody } = validationResult;
const { chatRequestBody, emailText } = validationResult;

// Check if Recoup is only CC'd - use LLM to determine if reply is expected
const ccValidation = await validateCcReplyExpected(original, emailText);
if (ccValidation) {
return ccValidation.response;
}

const { roomId } = chatRequestBody;

const { text, html } = await generateEmailResponse(chatRequestBody);
Expand Down
27 changes: 27 additions & 0 deletions lib/emails/inbound/shouldReplyToCcEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createEmailReplyAgent } from "@/lib/agents/EmailReplyAgent";
import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent";

type EmailContext = Pick<ResendEmailData, "from" | "to" | "cc" | "subject"> & { body: string };

/**
* Uses an agent to determine if a reply is expected from the Recoup AI assistant.
*
* @param context - The email context including from, to, cc, subject, and body
* @returns true if a reply is expected, false otherwise
*/
export async function shouldReplyToCcEmail(context: EmailContext): Promise<boolean> {
const { from, to, cc, subject, body } = context;

const agent = createEmailReplyAgent();

const prompt = `Analyze this email:
- From: ${from}
- To: ${to.join(", ")}
- CC: ${cc.join(", ")}
- Subject: ${subject}
- Body: ${body}`;

const { output } = await agent.generate({ prompt });

return output.shouldReply;
}
32 changes: 32 additions & 0 deletions lib/emails/inbound/validateCcReplyExpected.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent";
import { shouldReplyToCcEmail } from "@/lib/emails/inbound/shouldReplyToCcEmail";

/**
* Validates whether a reply should be sent by delegating to shouldReplyToCcEmail.
*
* @param original - The original email data from the Resend webhook
* @param emailText - The parsed email body text
* @returns Either a NextResponse to early return (no reply needed) or null to continue
*/
export async function validateCcReplyExpected(
original: ResendEmailData,
emailText: string,
): Promise<{ response: NextResponse } | null> {
const shouldReply = await shouldReplyToCcEmail({
from: original.from,
to: original.to,
cc: original.cc,
subject: original.subject,
body: emailText,
});

if (!shouldReply) {
console.log("[validateCcReplyExpected] No reply expected, skipping");
return {
response: NextResponse.json({ message: "No reply expected" }, { status: 200 }),
};
}

return null;
}
1 change: 1 addition & 0 deletions lib/emails/validateInboundEmailEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const resendEmailReceivedEventSchema = z.object({
});

export type ResendEmailReceivedEvent = z.infer<typeof resendEmailReceivedEventSchema>;
export type ResendEmailData = z.infer<typeof resendEmailDataSchema>;

/**
* Validates the inbound Resend email webhook event against the expected schema.
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
"format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"",
"format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"",
"lint": "eslint . --ext .ts --fix",
"lint:check": "eslint . --ext .ts"
"lint:check": "eslint . --ext .ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@ai-sdk/mcp": "^0.0.12",
Expand Down Expand Up @@ -56,6 +58,7 @@
"postcss": "^8",
"prettier": "3.5.2",
"tailwindcss": "^3.4.1",
"typescript": "^5"
"typescript": "^5",
"vitest": "^3.2.4"
}
}
Loading