diff --git a/lib/chat/tools/createPromptSandboxStreamingTool.ts b/lib/chat/tools/createPromptSandboxStreamingTool.ts index f5a65eac6..03cd7b372 100644 --- a/lib/chat/tools/createPromptSandboxStreamingTool.ts +++ b/lib/chat/tools/createPromptSandboxStreamingTool.ts @@ -4,7 +4,11 @@ import { promptSandboxStreaming } from "@/lib/sandbox/promptSandboxStreaming"; export const SANDBOX_PROMPT_NOTE = "IMPORTANT: When you make changes to any files inside the orgs/ directory, " + - "always commit and push those changes directly to main so they are preserved and shared across sessions."; + "always commit and push those changes directly to main so they are preserved and shared across sessions.\n\n" + + "IMPORTANT: When a prompt includes attached file URLs (e.g. from email attachments), " + + "always download the files first using curl and save them locally before referencing them. " + + "These URLs are temporary and expire after 1 hour. Never store the download URL directly in files — " + + "download the content, save it to the appropriate location in the repo, and reference the local path instead."; const promptSandboxSchema = z.object({ prompt: z.string().min(1).describe("The prompt to send to OpenClaw running in the sandbox."), diff --git a/lib/emails/inbound/__tests__/formatAttachmentsText.test.ts b/lib/emails/inbound/__tests__/formatAttachmentsText.test.ts new file mode 100644 index 000000000..c0bc2129f --- /dev/null +++ b/lib/emails/inbound/__tests__/formatAttachmentsText.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { formatAttachmentsText } from "../formatAttachmentsText"; +import type { EmailAttachment } from "../getEmailAttachments"; + +const makeAttachment = (overrides: Partial = {}): EmailAttachment => + ({ + id: "att-1", + filename: "file.txt", + size: 1024, + content_type: "text/plain", + content_disposition: "attachment", + download_url: "https://resend.com/dl/att-1", + expires_at: "2025-01-01T01:00:00Z", + ...overrides, + }) as EmailAttachment; + +describe("formatAttachmentsText", () => { + it("returns empty string for empty array", () => { + expect(formatAttachmentsText([])).toBe(""); + }); + + it("formats a single attachment", () => { + const attachments = [ + makeAttachment({ + filename: "logo.svg", + content_type: "image/svg+xml", + download_url: "https://resend.com/dl/att-1", + }), + ]; + + const result = formatAttachmentsText(attachments); + + expect(result).toBe( + "\n\nAttached files:\n- logo.svg (image/svg+xml): https://resend.com/dl/att-1", + ); + }); + + it("formats multiple attachments", () => { + const attachments = [ + makeAttachment({ + filename: "logo.svg", + content_type: "image/svg+xml", + download_url: "https://resend.com/dl/att-1", + }), + makeAttachment({ + id: "att-2", + filename: "report.pdf", + content_type: "application/pdf", + download_url: "https://resend.com/dl/att-2", + }), + ]; + + const result = formatAttachmentsText(attachments); + + expect(result).toContain("Attached files:"); + expect(result).toContain("- logo.svg (image/svg+xml): https://resend.com/dl/att-1"); + expect(result).toContain("- report.pdf (application/pdf): https://resend.com/dl/att-2"); + }); + + it("defaults filename to 'attachment' when not provided", () => { + const attachments = [makeAttachment({ filename: undefined })]; + + const result = formatAttachmentsText(attachments); + + expect(result).toContain("- attachment (text/plain):"); + }); +}); diff --git a/lib/emails/inbound/__tests__/generateEmailResponse.test.ts b/lib/emails/inbound/__tests__/generateEmailResponse.test.ts new file mode 100644 index 000000000..faa3b8e96 --- /dev/null +++ b/lib/emails/inbound/__tests__/generateEmailResponse.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { generateEmailResponse } from "../generateEmailResponse"; +import type { ChatRequestBody } from "@/lib/chat/validateChatRequest"; + +import getGeneralAgent from "@/lib/agents/generalAgent/getGeneralAgent"; +import { getEmailRoomMessages } from "@/lib/emails/inbound/getEmailRoomMessages"; + +vi.mock("@/lib/agents/generalAgent/getGeneralAgent", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/emails/inbound/getEmailRoomMessages", () => ({ + getEmailRoomMessages: vi.fn(), +})); + +vi.mock("@/lib/emails/getEmailFooter", () => ({ + getEmailFooter: vi.fn(() => ""), +})); + +vi.mock("@/lib/supabase/rooms/selectRoomWithArtist", () => ({ + selectRoomWithArtist: vi.fn(() => ({ artist_name: "Test Artist" })), +})); + +const mockGenerate = vi.fn(); + +describe("generateEmailResponse", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(getGeneralAgent).mockResolvedValue({ + agent: { generate: mockGenerate }, + } as unknown as Awaited>); + + mockGenerate.mockResolvedValue({ text: "Hello from assistant" }); + }); + + it("throws when roomId is missing", async () => { + const body = { accountId: "acc-1", orgId: null, messages: [] } as ChatRequestBody; + + await expect(generateEmailResponse(body)).rejects.toThrow( + "roomId is required to generate email response HTML", + ); + }); + + it("generates response with text and footer", async () => { + vi.mocked(getEmailRoomMessages).mockResolvedValue([{ role: "user", content: "Hi there" }]); + + const body: ChatRequestBody = { + accountId: "acc-1", + orgId: null, + messages: [], + roomId: "room-1", + }; + + const result = await generateEmailResponse(body); + + expect(mockGenerate).toHaveBeenCalledWith({ + messages: [{ role: "user", content: "Hi there" }], + }); + expect(result.text).toBe("Hello from assistant"); + expect(result.html).toContain("Hello from assistant"); + expect(result.html).toContain("
footer
"); + }); +}); diff --git a/lib/emails/inbound/__tests__/getEmailAttachments.test.ts b/lib/emails/inbound/__tests__/getEmailAttachments.test.ts new file mode 100644 index 000000000..042001dc5 --- /dev/null +++ b/lib/emails/inbound/__tests__/getEmailAttachments.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getEmailAttachments } from "../getEmailAttachments"; + +import { getResendClient } from "@/lib/emails/client"; + +vi.mock("@/lib/emails/client", () => ({ + getResendClient: vi.fn(), +})); + +const mockList = vi.fn(); + +describe("getEmailAttachments", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getResendClient).mockReturnValue({ + emails: { + receiving: { + attachments: { + list: mockList, + }, + }, + }, + } as ReturnType); + }); + + it("returns attachments directly from Resend SDK", async () => { + mockList.mockResolvedValue({ + data: { + data: [ + { + id: "att-1", + filename: "logo.svg", + content_type: "image/svg+xml", + download_url: "https://resend.com/dl/att-1", + size: 1024, + content_disposition: "attachment", + expires_at: "2025-01-01T01:00:00Z", + }, + { + id: "att-2", + filename: "report.pdf", + content_type: "application/pdf", + download_url: "https://resend.com/dl/att-2", + size: 2048, + content_disposition: "attachment", + expires_at: "2025-01-01T01:00:00Z", + }, + ], + }, + }); + + const result = await getEmailAttachments("email-123"); + + expect(mockList).toHaveBeenCalledWith({ emailId: "email-123" }); + expect(result).toHaveLength(2); + expect(result[0].id).toBe("att-1"); + expect(result[0].download_url).toBe("https://resend.com/dl/att-1"); + expect(result[1].content_type).toBe("application/pdf"); + }); + + it("returns empty array when no attachments exist", async () => { + mockList.mockResolvedValue({ data: { data: [] } }); + + const result = await getEmailAttachments("email-123"); + + expect(result).toEqual([]); + }); + + it("returns empty array when data is null", async () => { + mockList.mockResolvedValue({ data: null }); + + const result = await getEmailAttachments("email-123"); + + expect(result).toEqual([]); + }); +}); diff --git a/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts b/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts index 911e3221d..17c164d41 100644 --- a/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts +++ b/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts @@ -3,6 +3,13 @@ import { validateNewEmailMemory } from "../validateNewEmailMemory"; import type { ResendEmailReceivedEvent } from "@/lib/emails/validateInboundEmailEvent"; import { NextResponse } from "next/server"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { getEmailContent } from "@/lib/emails/inbound/getEmailContent"; +import { getEmailRoomId } from "@/lib/emails/inbound/getEmailRoomId"; +import { setupConversation } from "@/lib/chat/setupConversation"; +import { getEmailAttachments } from "@/lib/emails/inbound/getEmailAttachments"; +import { formatAttachmentsText } from "@/lib/emails/inbound/formatAttachmentsText"; + vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ default: vi.fn(), })); @@ -35,17 +42,26 @@ vi.mock("@/lib/const", () => ({ RECOUP_API_KEY: "test-recoup-api-key", })); -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; -import { getEmailContent } from "@/lib/emails/inbound/getEmailContent"; -import { getEmailRoomId } from "@/lib/emails/inbound/getEmailRoomId"; -import { setupConversation } from "@/lib/chat/setupConversation"; +vi.mock("@/lib/emails/inbound/getEmailAttachments", () => ({ + getEmailAttachments: vi.fn(), +})); + +vi.mock("@/lib/emails/inbound/formatAttachmentsText", () => ({ + formatAttachmentsText: vi.fn(), +})); const MOCK_ACCOUNT_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; const MOCK_ROOM_ID = "11111111-2222-3333-4444-555555555555"; const MOCK_EMAIL_ID = "email-123"; const MOCK_MESSAGE_ID = "msg-456"; -function createMockEvent(overrides?: Partial): ResendEmailReceivedEvent { +/** + * + * @param overrides + */ +function createMockEvent( + overrides?: Partial, +): ResendEmailReceivedEvent { return { type: "email.received", created_at: "2024-01-01T00:00:00.000Z", @@ -77,6 +93,10 @@ describe("validateNewEmailMemory", () => { vi.mocked(getEmailRoomId).mockResolvedValue(undefined); vi.mocked(setupConversation).mockResolvedValue({ roomId: MOCK_ROOM_ID }); + + vi.mocked(getEmailAttachments).mockResolvedValue([]); + + vi.mocked(formatAttachmentsText).mockReturnValue(""); }); it("includes authToken from RECOUP_API_KEY in chatRequestBody", async () => { @@ -87,7 +107,10 @@ describe("validateNewEmailMemory", () => { // Should not be a response (duplicate) expect(result).not.toHaveProperty("response"); - const { chatRequestBody } = result as { chatRequestBody: { authToken?: string }; emailText: string }; + const { chatRequestBody } = result as { + chatRequestBody: { authToken?: string }; + emailText: string; + }; expect(chatRequestBody.authToken).toBe("test-recoup-api-key"); }); @@ -95,7 +118,10 @@ describe("validateNewEmailMemory", () => { const event = createMockEvent(); const result = await validateNewEmailMemory(event); - const { chatRequestBody } = result as { chatRequestBody: Record; emailText: string }; + const { chatRequestBody } = result as { + chatRequestBody: Record; + emailText: string; + }; expect(chatRequestBody.accountId).toBe(MOCK_ACCOUNT_ID); expect(chatRequestBody.orgId).toBeNull(); @@ -113,4 +139,42 @@ describe("validateNewEmailMemory", () => { const { response } = result as { response: NextResponse }; expect(response.status).toBe(200); }); + + it("fetches attachments and appends download URLs to emailText", async () => { + const attachmentText = + "\n\nAttached files:\n- logo.svg (image/svg+xml): https://resend.com/dl/att-1"; + vi.mocked(getEmailAttachments).mockResolvedValue([ + { + id: "att-1", + filename: "logo.svg", + size: 1024, + content_type: "image/svg+xml", + content_disposition: "attachment", + download_url: "https://resend.com/dl/att-1", + expires_at: "2025-01-01T01:00:00Z", + }, + ] as Awaited>); + vi.mocked(formatAttachmentsText).mockReturnValue(attachmentText); + + const event = createMockEvent(); + const result = await validateNewEmailMemory(event); + + expect(result).not.toHaveProperty("response"); + expect(getEmailAttachments).toHaveBeenCalledWith(MOCK_EMAIL_ID); + + const { emailText } = result as { chatRequestBody: Record; emailText: string }; + expect(emailText).toContain("Attached files:"); + expect(emailText).toContain("https://resend.com/dl/att-1"); + }); + + it("does not append text when no attachments exist", async () => { + vi.mocked(getEmailAttachments).mockResolvedValue([]); + vi.mocked(formatAttachmentsText).mockReturnValue(""); + + const event = createMockEvent(); + const result = await validateNewEmailMemory(event); + + const { emailText } = result as { chatRequestBody: Record; emailText: string }; + expect(emailText).toBe("

Hello from email

"); + }); }); diff --git a/lib/emails/inbound/formatAttachmentsText.ts b/lib/emails/inbound/formatAttachmentsText.ts new file mode 100644 index 000000000..341717be9 --- /dev/null +++ b/lib/emails/inbound/formatAttachmentsText.ts @@ -0,0 +1,18 @@ +import type { EmailAttachment } from "./getEmailAttachments"; + +/** + * Formats attachment info as text to append to the email body. + * This makes download URLs available to the LLM and any tools + * (sandbox, legacy tools, etc.) via the prompt text. + * + * @param attachments - Array of email attachments with download URLs + * @returns Formatted text block listing attachments, or empty string if none + */ +export function formatAttachmentsText(attachments: EmailAttachment[]): string { + if (!attachments.length) return ""; + + const lines = attachments.map( + att => `- ${att.filename || "attachment"} (${att.content_type}): ${att.download_url}`, + ); + return `\n\nAttached files:\n${lines.join("\n")}`; +} diff --git a/lib/emails/inbound/getEmailAttachments.ts b/lib/emails/inbound/getEmailAttachments.ts new file mode 100644 index 000000000..c2546f9c8 --- /dev/null +++ b/lib/emails/inbound/getEmailAttachments.ts @@ -0,0 +1,21 @@ +import { getResendClient } from "@/lib/emails/client"; +import type { ListAttachmentsResponseSuccess } from "resend"; + +export type EmailAttachment = ListAttachmentsResponseSuccess["data"][number]; + +/** + * Fetches attachment download URLs for a received email from Resend. + * Webhooks only include attachment metadata (id, filename, content_type), + * so this calls the Attachments API to get download URLs. + * + * @param emailId - The email ID from the Resend webhook event + * @returns Array of attachments with download URLs (empty if none) + */ +export async function getEmailAttachments(emailId: string): Promise { + const resend = getResendClient(); + const { data } = await resend.emails.receiving.attachments.list({ emailId }); + + if (!data?.data?.length) return []; + + return data.data; +} diff --git a/lib/emails/inbound/validateNewEmailMemory.ts b/lib/emails/inbound/validateNewEmailMemory.ts index b062d1b04..ed0dea756 100644 --- a/lib/emails/inbound/validateNewEmailMemory.ts +++ b/lib/emails/inbound/validateNewEmailMemory.ts @@ -9,6 +9,8 @@ import { RECOUP_API_KEY } from "@/lib/const"; import { setupConversation } from "@/lib/chat/setupConversation"; import insertMemoryEmail from "@/lib/supabase/memory_emails/insertMemoryEmail"; import { trimRepliedContext } from "@/lib/emails/inbound/trimRepliedContext"; +import { getEmailAttachments } from "./getEmailAttachments"; +import { formatAttachmentsText } from "./formatAttachmentsText"; /** * Validates and processes a new memory from an inbound email. @@ -29,7 +31,9 @@ export async function validateNewEmailMemory( const accountId = accountEmails[0].account_id; const emailContent = await getEmailContent(emailId); - const emailText = trimRepliedContext(emailContent.html || ""); + const attachments = await getEmailAttachments(emailId); + const emailText = + trimRepliedContext(emailContent.html || "") + formatAttachmentsText(attachments); const roomId = await getEmailRoomId(emailContent); const promptMessage = getMessages(emailText)[0];