From 4ee7ae7d329c25b16b283f3b9dd53a4e217c7315 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sat, 28 Feb 2026 11:27:18 -0500 Subject: [PATCH 1/7] feat: include email attachments in agent.generate and tool calls Fetch attachment download URLs from Resend, append them to email text so sandbox/tools can download files, and inject image parts into agent messages so the LLM can visually process images. Co-Authored-By: Claude Opus 4.6 --- lib/chat/validateChatRequest.ts | 2 + .../__tests__/formatAttachmentsText.test.ts | 49 ++++++ .../__tests__/generateEmailResponse.test.ts | 165 ++++++++++++++++++ .../__tests__/getEmailAttachments.test.ts | 108 ++++++++++++ .../__tests__/validateNewEmailMemory.test.ts | 101 ++++++++++- lib/emails/inbound/formatAttachmentsText.ts | 18 ++ lib/emails/inbound/generateEmailResponse.ts | 30 +++- lib/emails/inbound/getEmailAttachments.ts | 30 ++++ lib/emails/inbound/validateNewEmailMemory.ts | 7 +- 9 files changed, 500 insertions(+), 10 deletions(-) create mode 100644 lib/emails/inbound/__tests__/formatAttachmentsText.test.ts create mode 100644 lib/emails/inbound/__tests__/generateEmailResponse.test.ts create mode 100644 lib/emails/inbound/__tests__/getEmailAttachments.test.ts create mode 100644 lib/emails/inbound/formatAttachmentsText.ts create mode 100644 lib/emails/inbound/getEmailAttachments.ts diff --git a/lib/chat/validateChatRequest.ts b/lib/chat/validateChatRequest.ts index 92219e6d5..c2c516be3 100644 --- a/lib/chat/validateChatRequest.ts +++ b/lib/chat/validateChatRequest.ts @@ -11,6 +11,7 @@ import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; import { setupConversation } from "@/lib/chat/setupConversation"; import { validateMessages } from "@/lib/chat/validateMessages"; +import type { EmailAttachment } from "@/lib/emails/inbound/getEmailAttachments"; export const chatRequestSchema = z .object({ @@ -50,6 +51,7 @@ export type ChatRequestBody = BaseChatRequestBody & { accountId: string; orgId: string | null; authToken?: string; + attachments?: EmailAttachment[]; }; /** diff --git a/lib/emails/inbound/__tests__/formatAttachmentsText.test.ts b/lib/emails/inbound/__tests__/formatAttachmentsText.test.ts new file mode 100644 index 000000000..820a3247c --- /dev/null +++ b/lib/emails/inbound/__tests__/formatAttachmentsText.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { formatAttachmentsText } from "../formatAttachmentsText"; +import type { EmailAttachment } from "../getEmailAttachments"; + +describe("formatAttachmentsText", () => { + it("returns empty string for empty array", () => { + expect(formatAttachmentsText([])).toBe(""); + }); + + it("formats a single attachment", () => { + const attachments: EmailAttachment[] = [ + { + id: "att-1", + filename: "logo.svg", + contentType: "image/svg+xml", + downloadUrl: "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: EmailAttachment[] = [ + { + id: "att-1", + filename: "logo.svg", + contentType: "image/svg+xml", + downloadUrl: "https://resend.com/dl/att-1", + }, + { + id: "att-2", + filename: "report.pdf", + contentType: "application/pdf", + downloadUrl: "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"); + }); +}); diff --git a/lib/emails/inbound/__tests__/generateEmailResponse.test.ts b/lib/emails/inbound/__tests__/generateEmailResponse.test.ts new file mode 100644 index 000000000..ed5216ddd --- /dev/null +++ b/lib/emails/inbound/__tests__/generateEmailResponse.test.ts @@ -0,0 +1,165 @@ +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(() => "
footer
"), +})); + +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 without attachments", 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
"); + }); + + it("appends image parts to last user message when image attachments exist", async () => { + vi.mocked(getEmailRoomMessages).mockResolvedValue([ + { role: "user", content: "Check this image" }, + ]); + + const body: ChatRequestBody = { + accountId: "acc-1", + orgId: null, + messages: [], + roomId: "room-1", + attachments: [ + { + id: "att-1", + filename: "logo.png", + contentType: "image/png", + downloadUrl: "https://resend.com/dl/att-1", + }, + ], + }; + + await generateEmailResponse(body); + + const callArgs = mockGenerate.mock.calls[0][0]; + const lastUserMsg = callArgs.messages[0]; + + // Should have been converted to parts array with text + image + expect(Array.isArray(lastUserMsg.content)).toBe(true); + expect(lastUserMsg.content[0]).toEqual({ type: "text", text: "Check this image" }); + expect(lastUserMsg.content[1]).toMatchObject({ + type: "image", + mimeType: "image/png", + }); + expect(lastUserMsg.content[1].image).toBeInstanceOf(URL); + expect(lastUserMsg.content[1].image.href).toBe("https://resend.com/dl/att-1"); + }); + + it("does not modify messages when only non-image attachments exist", async () => { + vi.mocked(getEmailRoomMessages).mockResolvedValue([ + { role: "user", content: "Check this file" }, + ]); + + const body: ChatRequestBody = { + accountId: "acc-1", + orgId: null, + messages: [], + roomId: "room-1", + attachments: [ + { + id: "att-1", + filename: "report.pdf", + contentType: "application/pdf", + downloadUrl: "https://resend.com/dl/att-1", + }, + ], + }; + + await generateEmailResponse(body); + + const callArgs = mockGenerate.mock.calls[0][0]; + const lastUserMsg = callArgs.messages[0]; + + // Should remain as plain string (no image parts to add) + expect(lastUserMsg.content).toBe("Check this file"); + }); + + it("appends image parts to the last user message in multi-message conversations", async () => { + vi.mocked(getEmailRoomMessages).mockResolvedValue([ + { role: "user", content: "First message" }, + { role: "assistant", content: "Reply" }, + { role: "user", content: "Here is the image" }, + ]); + + const body: ChatRequestBody = { + accountId: "acc-1", + orgId: null, + messages: [], + roomId: "room-1", + attachments: [ + { + id: "att-1", + filename: "photo.jpg", + contentType: "image/jpeg", + downloadUrl: "https://resend.com/dl/att-1", + }, + ], + }; + + await generateEmailResponse(body); + + const callArgs = mockGenerate.mock.calls[0][0]; + // First user message should be unchanged + expect(callArgs.messages[0].content).toBe("First message"); + // Last user message should have image parts + expect(Array.isArray(callArgs.messages[2].content)).toBe(true); + expect(callArgs.messages[2].content[0]).toEqual({ + type: "text", + text: "Here is the image", + }); + expect(callArgs.messages[2].content[1].type).toBe("image"); + }); +}); diff --git a/lib/emails/inbound/__tests__/getEmailAttachments.test.ts b/lib/emails/inbound/__tests__/getEmailAttachments.test.ts new file mode 100644 index 000000000..537b4dfce --- /dev/null +++ b/lib/emails/inbound/__tests__/getEmailAttachments.test.ts @@ -0,0 +1,108 @@ +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 mapped attachments when Resend returns data", 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).toEqual([ + { + id: "att-1", + filename: "logo.svg", + contentType: "image/svg+xml", + downloadUrl: "https://resend.com/dl/att-1", + }, + { + id: "att-2", + filename: "report.pdf", + contentType: "application/pdf", + downloadUrl: "https://resend.com/dl/att-2", + }, + ]); + }); + + 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([]); + }); + + it("defaults filename to 'attachment' when not provided", async () => { + mockList.mockResolvedValue({ + data: { + data: [ + { + id: "att-3", + filename: undefined, + content_type: "application/octet-stream", + download_url: "https://resend.com/dl/att-3", + size: 512, + content_disposition: "attachment", + expires_at: "2025-01-01T01:00:00Z", + }, + ], + }, + }); + + const result = await getEmailAttachments("email-123"); + + expect(result[0].filename).toBe("attachment"); + }); +}); diff --git a/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts b/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts index 911e3221d..663ad8ecc 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,65 @@ describe("validateNewEmailMemory", () => { const { response } = result as { response: NextResponse }; expect(response.status).toBe(200); }); + + it("fetches attachments and includes them in chatRequestBody", async () => { + const mockAttachments = [ + { + id: "att-1", + filename: "logo.svg", + contentType: "image/svg+xml", + downloadUrl: "https://resend.com/dl/att-1", + }, + ]; + vi.mocked(getEmailAttachments).mockResolvedValue(mockAttachments); + vi.mocked(formatAttachmentsText).mockReturnValue( + "\n\nAttached files:\n- logo.svg (image/svg+xml): https://resend.com/dl/att-1", + ); + + const event = createMockEvent(); + const result = await validateNewEmailMemory(event); + + expect(result).not.toHaveProperty("response"); + const { chatRequestBody } = result as { + chatRequestBody: Record; + emailText: string; + }; + expect(chatRequestBody.attachments).toEqual(mockAttachments); + expect(getEmailAttachments).toHaveBeenCalledWith(MOCK_EMAIL_ID); + }); + + it("appends attachment 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", + contentType: "image/svg+xml", + downloadUrl: "https://resend.com/dl/att-1", + }, + ]); + vi.mocked(formatAttachmentsText).mockReturnValue(attachmentText); + + const event = createMockEvent(); + const result = await validateNewEmailMemory(event); + + const { emailText } = result as { chatRequestBody: Record; emailText: string }; + expect(emailText).toContain("Attached files:"); + expect(emailText).toContain("https://resend.com/dl/att-1"); + }); + + it("includes empty attachments array when no attachments exist", async () => { + vi.mocked(getEmailAttachments).mockResolvedValue([]); + vi.mocked(formatAttachmentsText).mockReturnValue(""); + + const event = createMockEvent(); + const result = await validateNewEmailMemory(event); + + const { chatRequestBody } = result as { + chatRequestBody: Record; + emailText: string; + }; + expect(chatRequestBody.attachments).toEqual([]); + }); }); diff --git a/lib/emails/inbound/formatAttachmentsText.ts b/lib/emails/inbound/formatAttachmentsText.ts new file mode 100644 index 000000000..9674fcb06 --- /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} (${att.contentType}): ${att.downloadUrl}`, + ); + return `\n\nAttached files:\n${lines.join("\n")}`; +} diff --git a/lib/emails/inbound/generateEmailResponse.ts b/lib/emails/inbound/generateEmailResponse.ts index 59edf811c..1c9c318f3 100644 --- a/lib/emails/inbound/generateEmailResponse.ts +++ b/lib/emails/inbound/generateEmailResponse.ts @@ -1,3 +1,4 @@ +import type { ModelMessage } from "ai"; import { marked } from "marked"; import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; import getGeneralAgent from "@/lib/agents/generalAgent/getGeneralAgent"; @@ -8,6 +9,7 @@ import { selectRoomWithArtist } from "@/lib/supabase/rooms/selectRoomWithArtist" /** * Generates the assistant response HTML for an email, including: * - Running the general agent to generate a reply for the given room + * - Appending image attachments as visual content parts to the last user message * - Fetching the room messages * - Appending a standardized footer with reply and link instructions * @@ -17,7 +19,7 @@ import { selectRoomWithArtist } from "@/lib/supabase/rooms/selectRoomWithArtist" export async function generateEmailResponse( body: ChatRequestBody, ): Promise<{ text: string; html: string }> { - const { roomId } = body; + const { roomId, attachments } = body; if (!roomId) { throw new Error("roomId is required to generate email response HTML"); } @@ -25,7 +27,31 @@ export async function generateEmailResponse( const decision = await getGeneralAgent(body); const agent = decision.agent; - const messages = await getEmailRoomMessages(roomId); + const messages: ModelMessage[] = await getEmailRoomMessages(roomId); + + // Append image attachments as visual content parts to the last user message + // so the LLM can visually process images (in addition to having download URLs in text) + if (attachments?.length) { + const imageAttachments = attachments.filter(a => a.contentType.startsWith("image/")); + if (imageAttachments.length) { + const lastUserIdx = messages.findLastIndex(m => m.role === "user"); + if (lastUserIdx >= 0) { + const msg = messages[lastUserIdx]; + const textContent = typeof msg.content === "string" ? msg.content : ""; + const parts: Array<{ type: string; text?: string; image?: URL; mimeType?: string }> = [ + { type: "text", text: textContent }, + ]; + for (const att of imageAttachments) { + parts.push({ + type: "image", + image: new URL(att.downloadUrl), + mimeType: att.contentType, + }); + } + messages[lastUserIdx] = { ...msg, content: parts as ModelMessage["content"] }; + } + } + } const chatResponse = await agent.generate({ messages }); const text = chatResponse.text; diff --git a/lib/emails/inbound/getEmailAttachments.ts b/lib/emails/inbound/getEmailAttachments.ts new file mode 100644 index 000000000..fac16d328 --- /dev/null +++ b/lib/emails/inbound/getEmailAttachments.ts @@ -0,0 +1,30 @@ +import { getResendClient } from "@/lib/emails/client"; + +export interface EmailAttachment { + id: string; + filename: string; + contentType: string; + downloadUrl: string; +} + +/** + * 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.map(att => ({ + id: att.id, + filename: att.filename || "attachment", + contentType: att.content_type, + downloadUrl: att.download_url, + })); +} diff --git a/lib/emails/inbound/validateNewEmailMemory.ts b/lib/emails/inbound/validateNewEmailMemory.ts index b062d1b04..ffa91d9a3 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]; @@ -72,6 +76,7 @@ export async function validateNewEmailMemory( messages: getMessages(emailText), roomId: finalRoomId, authToken: RECOUP_API_KEY, + attachments, }; return { chatRequestBody, emailText }; From a9574966cc3f47a22fc0c65cb4fbc5f8fbe19356 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sat, 28 Feb 2026 11:35:18 -0500 Subject: [PATCH 2/7] fix: resolve TypeScript type error in generateEmailResponse Construct a proper UserModelMessage instead of spreading a generic ModelMessage, fixing the discriminated union type incompatibility. Co-Authored-By: Claude Opus 4.6 --- lib/emails/inbound/generateEmailResponse.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/emails/inbound/generateEmailResponse.ts b/lib/emails/inbound/generateEmailResponse.ts index 1c9c318f3..87c3fe63b 100644 --- a/lib/emails/inbound/generateEmailResponse.ts +++ b/lib/emails/inbound/generateEmailResponse.ts @@ -1,4 +1,4 @@ -import type { ModelMessage } from "ai"; +import type { ModelMessage, UserModelMessage } from "ai"; import { marked } from "marked"; import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; import getGeneralAgent from "@/lib/agents/generalAgent/getGeneralAgent"; @@ -38,17 +38,15 @@ export async function generateEmailResponse( if (lastUserIdx >= 0) { const msg = messages[lastUserIdx]; const textContent = typeof msg.content === "string" ? msg.content : ""; - const parts: Array<{ type: string; text?: string; image?: URL; mimeType?: string }> = [ + const parts: UserModelMessage["content"] = [ { type: "text", text: textContent }, - ]; - for (const att of imageAttachments) { - parts.push({ - type: "image", + ...imageAttachments.map(att => ({ + type: "image" as const, image: new URL(att.downloadUrl), mimeType: att.contentType, - }); - } - messages[lastUserIdx] = { ...msg, content: parts as ModelMessage["content"] }; + })), + ]; + messages[lastUserIdx] = { role: "user" as const, content: parts }; } } } From 0a5fb33bc1beb778db2343bc231fb640dba81cc3 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sat, 28 Feb 2026 11:59:18 -0500 Subject: [PATCH 3/7] refactor: remove image parts, rely on download URLs for all file types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove image part injection into agent messages. Attachment download URLs in the email text are sufficient — OpenClaw in the sandbox can fetch any file type directly. This avoids signed URL access issues and unsupported format errors (e.g. SVG) with LLM vision APIs. Co-Authored-By: Claude Opus 4.6 --- lib/chat/validateChatRequest.ts | 2 - .../__tests__/generateEmailResponse.test.ts | 103 +----------------- .../__tests__/validateNewEmailMemory.test.ts | 40 ++----- lib/emails/inbound/generateEmailResponse.ts | 28 +---- lib/emails/inbound/validateNewEmailMemory.ts | 1 - 5 files changed, 10 insertions(+), 164 deletions(-) diff --git a/lib/chat/validateChatRequest.ts b/lib/chat/validateChatRequest.ts index c2c516be3..92219e6d5 100644 --- a/lib/chat/validateChatRequest.ts +++ b/lib/chat/validateChatRequest.ts @@ -11,7 +11,6 @@ import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; import { setupConversation } from "@/lib/chat/setupConversation"; import { validateMessages } from "@/lib/chat/validateMessages"; -import type { EmailAttachment } from "@/lib/emails/inbound/getEmailAttachments"; export const chatRequestSchema = z .object({ @@ -51,7 +50,6 @@ export type ChatRequestBody = BaseChatRequestBody & { accountId: string; orgId: string | null; authToken?: string; - attachments?: EmailAttachment[]; }; /** diff --git a/lib/emails/inbound/__tests__/generateEmailResponse.test.ts b/lib/emails/inbound/__tests__/generateEmailResponse.test.ts index ed5216ddd..faa3b8e96 100644 --- a/lib/emails/inbound/__tests__/generateEmailResponse.test.ts +++ b/lib/emails/inbound/__tests__/generateEmailResponse.test.ts @@ -42,7 +42,7 @@ describe("generateEmailResponse", () => { ); }); - it("generates response without attachments", async () => { + it("generates response with text and footer", async () => { vi.mocked(getEmailRoomMessages).mockResolvedValue([{ role: "user", content: "Hi there" }]); const body: ChatRequestBody = { @@ -61,105 +61,4 @@ describe("generateEmailResponse", () => { expect(result.html).toContain("Hello from assistant"); expect(result.html).toContain("
footer
"); }); - - it("appends image parts to last user message when image attachments exist", async () => { - vi.mocked(getEmailRoomMessages).mockResolvedValue([ - { role: "user", content: "Check this image" }, - ]); - - const body: ChatRequestBody = { - accountId: "acc-1", - orgId: null, - messages: [], - roomId: "room-1", - attachments: [ - { - id: "att-1", - filename: "logo.png", - contentType: "image/png", - downloadUrl: "https://resend.com/dl/att-1", - }, - ], - }; - - await generateEmailResponse(body); - - const callArgs = mockGenerate.mock.calls[0][0]; - const lastUserMsg = callArgs.messages[0]; - - // Should have been converted to parts array with text + image - expect(Array.isArray(lastUserMsg.content)).toBe(true); - expect(lastUserMsg.content[0]).toEqual({ type: "text", text: "Check this image" }); - expect(lastUserMsg.content[1]).toMatchObject({ - type: "image", - mimeType: "image/png", - }); - expect(lastUserMsg.content[1].image).toBeInstanceOf(URL); - expect(lastUserMsg.content[1].image.href).toBe("https://resend.com/dl/att-1"); - }); - - it("does not modify messages when only non-image attachments exist", async () => { - vi.mocked(getEmailRoomMessages).mockResolvedValue([ - { role: "user", content: "Check this file" }, - ]); - - const body: ChatRequestBody = { - accountId: "acc-1", - orgId: null, - messages: [], - roomId: "room-1", - attachments: [ - { - id: "att-1", - filename: "report.pdf", - contentType: "application/pdf", - downloadUrl: "https://resend.com/dl/att-1", - }, - ], - }; - - await generateEmailResponse(body); - - const callArgs = mockGenerate.mock.calls[0][0]; - const lastUserMsg = callArgs.messages[0]; - - // Should remain as plain string (no image parts to add) - expect(lastUserMsg.content).toBe("Check this file"); - }); - - it("appends image parts to the last user message in multi-message conversations", async () => { - vi.mocked(getEmailRoomMessages).mockResolvedValue([ - { role: "user", content: "First message" }, - { role: "assistant", content: "Reply" }, - { role: "user", content: "Here is the image" }, - ]); - - const body: ChatRequestBody = { - accountId: "acc-1", - orgId: null, - messages: [], - roomId: "room-1", - attachments: [ - { - id: "att-1", - filename: "photo.jpg", - contentType: "image/jpeg", - downloadUrl: "https://resend.com/dl/att-1", - }, - ], - }; - - await generateEmailResponse(body); - - const callArgs = mockGenerate.mock.calls[0][0]; - // First user message should be unchanged - expect(callArgs.messages[0].content).toBe("First message"); - // Last user message should have image parts - expect(Array.isArray(callArgs.messages[2].content)).toBe(true); - expect(callArgs.messages[2].content[0]).toEqual({ - type: "text", - text: "Here is the image", - }); - expect(callArgs.messages[2].content[1].type).toBe("image"); - }); }); diff --git a/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts b/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts index 663ad8ecc..3b0ebafd2 100644 --- a/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts +++ b/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts @@ -140,33 +140,7 @@ describe("validateNewEmailMemory", () => { expect(response.status).toBe(200); }); - it("fetches attachments and includes them in chatRequestBody", async () => { - const mockAttachments = [ - { - id: "att-1", - filename: "logo.svg", - contentType: "image/svg+xml", - downloadUrl: "https://resend.com/dl/att-1", - }, - ]; - vi.mocked(getEmailAttachments).mockResolvedValue(mockAttachments); - vi.mocked(formatAttachmentsText).mockReturnValue( - "\n\nAttached files:\n- logo.svg (image/svg+xml): https://resend.com/dl/att-1", - ); - - const event = createMockEvent(); - const result = await validateNewEmailMemory(event); - - expect(result).not.toHaveProperty("response"); - const { chatRequestBody } = result as { - chatRequestBody: Record; - emailText: string; - }; - expect(chatRequestBody.attachments).toEqual(mockAttachments); - expect(getEmailAttachments).toHaveBeenCalledWith(MOCK_EMAIL_ID); - }); - - it("appends attachment URLs to emailText", async () => { + 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([ @@ -182,22 +156,22 @@ describe("validateNewEmailMemory", () => { 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("includes empty attachments array when no attachments exist", async () => { + 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 { chatRequestBody } = result as { - chatRequestBody: Record; - emailText: string; - }; - expect(chatRequestBody.attachments).toEqual([]); + const { emailText } = result as { chatRequestBody: Record; emailText: string }; + expect(emailText).toBe("

Hello from email

"); }); }); diff --git a/lib/emails/inbound/generateEmailResponse.ts b/lib/emails/inbound/generateEmailResponse.ts index 87c3fe63b..59edf811c 100644 --- a/lib/emails/inbound/generateEmailResponse.ts +++ b/lib/emails/inbound/generateEmailResponse.ts @@ -1,4 +1,3 @@ -import type { ModelMessage, UserModelMessage } from "ai"; import { marked } from "marked"; import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; import getGeneralAgent from "@/lib/agents/generalAgent/getGeneralAgent"; @@ -9,7 +8,6 @@ import { selectRoomWithArtist } from "@/lib/supabase/rooms/selectRoomWithArtist" /** * Generates the assistant response HTML for an email, including: * - Running the general agent to generate a reply for the given room - * - Appending image attachments as visual content parts to the last user message * - Fetching the room messages * - Appending a standardized footer with reply and link instructions * @@ -19,7 +17,7 @@ import { selectRoomWithArtist } from "@/lib/supabase/rooms/selectRoomWithArtist" export async function generateEmailResponse( body: ChatRequestBody, ): Promise<{ text: string; html: string }> { - const { roomId, attachments } = body; + const { roomId } = body; if (!roomId) { throw new Error("roomId is required to generate email response HTML"); } @@ -27,29 +25,7 @@ export async function generateEmailResponse( const decision = await getGeneralAgent(body); const agent = decision.agent; - const messages: ModelMessage[] = await getEmailRoomMessages(roomId); - - // Append image attachments as visual content parts to the last user message - // so the LLM can visually process images (in addition to having download URLs in text) - if (attachments?.length) { - const imageAttachments = attachments.filter(a => a.contentType.startsWith("image/")); - if (imageAttachments.length) { - const lastUserIdx = messages.findLastIndex(m => m.role === "user"); - if (lastUserIdx >= 0) { - const msg = messages[lastUserIdx]; - const textContent = typeof msg.content === "string" ? msg.content : ""; - const parts: UserModelMessage["content"] = [ - { type: "text", text: textContent }, - ...imageAttachments.map(att => ({ - type: "image" as const, - image: new URL(att.downloadUrl), - mimeType: att.contentType, - })), - ]; - messages[lastUserIdx] = { role: "user" as const, content: parts }; - } - } - } + const messages = await getEmailRoomMessages(roomId); const chatResponse = await agent.generate({ messages }); const text = chatResponse.text; diff --git a/lib/emails/inbound/validateNewEmailMemory.ts b/lib/emails/inbound/validateNewEmailMemory.ts index ffa91d9a3..ed0dea756 100644 --- a/lib/emails/inbound/validateNewEmailMemory.ts +++ b/lib/emails/inbound/validateNewEmailMemory.ts @@ -76,7 +76,6 @@ export async function validateNewEmailMemory( messages: getMessages(emailText), roomId: finalRoomId, authToken: RECOUP_API_KEY, - attachments, }; return { chatRequestBody, emailText }; From 1241c869ded5f40b9867285b34d8e2139dceed74 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sat, 28 Feb 2026 12:18:55 -0500 Subject: [PATCH 4/7] debug: add logging to respondToInboundEmail flow Logs at each step to identify where the email response pipeline stops: memory validation, CC check, response generation, and send. Co-Authored-By: Claude Opus 4.6 --- lib/emails/inbound/respondToInboundEmail.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/emails/inbound/respondToInboundEmail.ts b/lib/emails/inbound/respondToInboundEmail.ts index ea75fb3b3..454e973ed 100644 --- a/lib/emails/inbound/respondToInboundEmail.ts +++ b/lib/emails/inbound/respondToInboundEmail.ts @@ -19,6 +19,7 @@ export async function respondToInboundEmail( ): Promise { try { const original = event.data; + const emailId = original.email_id; const subject = original.subject ? `Re: ${original.subject}` : "Re: Your email"; const messageId = original.message_id; const to = original.from; @@ -26,23 +27,30 @@ export async function respondToInboundEmail( const from = getFromWithName(original.to, original.cc); const cc = original.cc?.length ? original.cc : undefined; + console.log(`[respondToInboundEmail] Processing email ${emailId} from ${to}`); + // Validate new memory and get chat request body (or early return if duplicate) const validationResult = await validateNewEmailMemory(event); if ("response" in validationResult) { + console.log(`[respondToInboundEmail] Email ${emailId} - early return from validateNewEmailMemory`); return validationResult.response; } const { chatRequestBody, emailText } = validationResult; + console.log(`[respondToInboundEmail] Email ${emailId} - memory validated, roomId=${chatRequestBody.roomId}, emailText length=${emailText.length}`); // Check if Recoup is only CC'd - use LLM to determine if reply is expected const ccValidation = await validateCcReplyExpected(original, emailText); if (ccValidation) { + console.log(`[respondToInboundEmail] Email ${emailId} - CC validation returned early (not a direct reply)`); return ccValidation.response; } const { roomId } = chatRequestBody; + console.log(`[respondToInboundEmail] Email ${emailId} - generating response...`); const { text, html } = await generateEmailResponse(chatRequestBody); + console.log(`[respondToInboundEmail] Email ${emailId} - response generated, text length=${text.length}`); const payload = { from, @@ -55,15 +63,18 @@ export async function respondToInboundEmail( }, }; + console.log(`[respondToInboundEmail] Email ${emailId} - sending reply to ${to} from ${from}`); const result = await sendEmailWithResend(payload); // Save the assistant response message await saveChatCompletion({ text, roomId }); if (result instanceof NextResponse) { + console.log(`[respondToInboundEmail] Email ${emailId} - sendEmailWithResend returned error response`); return result; } + console.log(`[respondToInboundEmail] Email ${emailId} - reply sent successfully`); return NextResponse.json(result); } catch (error) { console.error("[respondToInboundEmail] Failed to respond to inbound email", error); From 8b958edd2b16bc44e0510a6d40c6b99b109703da Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sat, 28 Feb 2026 12:30:39 -0500 Subject: [PATCH 5/7] feat: instruct OpenClaw to download attachment URLs before storing Attachment download URLs from email are temporary (expire in 1 hour). Add sandbox prompt note telling OpenClaw to always download files locally via curl instead of storing the raw URL in repo files. Co-Authored-By: Claude Opus 4.6 --- lib/chat/tools/createPromptSandboxStreamingTool.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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."), From 35b480b3d279bb0b404c47fb2ff077e4dfa922da Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sat, 28 Feb 2026 12:46:41 -0500 Subject: [PATCH 6/7] refactor: use Resend SDK type for EmailAttachment instead of custom interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the custom EmailAttachment interface with ListAttachmentsResponseSuccess["data"][number] from the Resend SDK. Remove the camelCase remapping layer — return SDK objects directly. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/formatAttachmentsText.test.ts | 50 +++++++++++++------ .../__tests__/getEmailAttachments.test.ts | 42 ++-------------- .../__tests__/validateNewEmailMemory.test.ts | 9 ++-- lib/emails/inbound/formatAttachmentsText.ts | 2 +- lib/emails/inbound/getEmailAttachments.ts | 15 ++---- 5 files changed, 49 insertions(+), 69 deletions(-) diff --git a/lib/emails/inbound/__tests__/formatAttachmentsText.test.ts b/lib/emails/inbound/__tests__/formatAttachmentsText.test.ts index 820a3247c..c0bc2129f 100644 --- a/lib/emails/inbound/__tests__/formatAttachmentsText.test.ts +++ b/lib/emails/inbound/__tests__/formatAttachmentsText.test.ts @@ -2,19 +2,30 @@ 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: EmailAttachment[] = [ - { - id: "att-1", + const attachments = [ + makeAttachment({ filename: "logo.svg", - contentType: "image/svg+xml", - downloadUrl: "https://resend.com/dl/att-1", - }, + content_type: "image/svg+xml", + download_url: "https://resend.com/dl/att-1", + }), ]; const result = formatAttachmentsText(attachments); @@ -25,19 +36,18 @@ describe("formatAttachmentsText", () => { }); it("formats multiple attachments", () => { - const attachments: EmailAttachment[] = [ - { - id: "att-1", + const attachments = [ + makeAttachment({ filename: "logo.svg", - contentType: "image/svg+xml", - downloadUrl: "https://resend.com/dl/att-1", - }, - { + content_type: "image/svg+xml", + download_url: "https://resend.com/dl/att-1", + }), + makeAttachment({ id: "att-2", filename: "report.pdf", - contentType: "application/pdf", - downloadUrl: "https://resend.com/dl/att-2", - }, + content_type: "application/pdf", + download_url: "https://resend.com/dl/att-2", + }), ]; const result = formatAttachmentsText(attachments); @@ -46,4 +56,12 @@ describe("formatAttachmentsText", () => { 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__/getEmailAttachments.test.ts b/lib/emails/inbound/__tests__/getEmailAttachments.test.ts index 537b4dfce..042001dc5 100644 --- a/lib/emails/inbound/__tests__/getEmailAttachments.test.ts +++ b/lib/emails/inbound/__tests__/getEmailAttachments.test.ts @@ -23,7 +23,7 @@ describe("getEmailAttachments", () => { } as ReturnType); }); - it("returns mapped attachments when Resend returns data", async () => { + it("returns attachments directly from Resend SDK", async () => { mockList.mockResolvedValue({ data: { data: [ @@ -52,20 +52,10 @@ describe("getEmailAttachments", () => { const result = await getEmailAttachments("email-123"); expect(mockList).toHaveBeenCalledWith({ emailId: "email-123" }); - expect(result).toEqual([ - { - id: "att-1", - filename: "logo.svg", - contentType: "image/svg+xml", - downloadUrl: "https://resend.com/dl/att-1", - }, - { - id: "att-2", - filename: "report.pdf", - contentType: "application/pdf", - downloadUrl: "https://resend.com/dl/att-2", - }, - ]); + 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 () => { @@ -83,26 +73,4 @@ describe("getEmailAttachments", () => { expect(result).toEqual([]); }); - - it("defaults filename to 'attachment' when not provided", async () => { - mockList.mockResolvedValue({ - data: { - data: [ - { - id: "att-3", - filename: undefined, - content_type: "application/octet-stream", - download_url: "https://resend.com/dl/att-3", - size: 512, - content_disposition: "attachment", - expires_at: "2025-01-01T01:00:00Z", - }, - ], - }, - }); - - const result = await getEmailAttachments("email-123"); - - expect(result[0].filename).toBe("attachment"); - }); }); diff --git a/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts b/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts index 3b0ebafd2..17c164d41 100644 --- a/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts +++ b/lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts @@ -147,10 +147,13 @@ describe("validateNewEmailMemory", () => { { id: "att-1", filename: "logo.svg", - contentType: "image/svg+xml", - downloadUrl: "https://resend.com/dl/att-1", + 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(); diff --git a/lib/emails/inbound/formatAttachmentsText.ts b/lib/emails/inbound/formatAttachmentsText.ts index 9674fcb06..341717be9 100644 --- a/lib/emails/inbound/formatAttachmentsText.ts +++ b/lib/emails/inbound/formatAttachmentsText.ts @@ -12,7 +12,7 @@ export function formatAttachmentsText(attachments: EmailAttachment[]): string { if (!attachments.length) return ""; const lines = attachments.map( - att => `- ${att.filename} (${att.contentType}): ${att.downloadUrl}`, + 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 index fac16d328..c2546f9c8 100644 --- a/lib/emails/inbound/getEmailAttachments.ts +++ b/lib/emails/inbound/getEmailAttachments.ts @@ -1,11 +1,7 @@ import { getResendClient } from "@/lib/emails/client"; +import type { ListAttachmentsResponseSuccess } from "resend"; -export interface EmailAttachment { - id: string; - filename: string; - contentType: string; - downloadUrl: string; -} +export type EmailAttachment = ListAttachmentsResponseSuccess["data"][number]; /** * Fetches attachment download URLs for a received email from Resend. @@ -21,10 +17,5 @@ export async function getEmailAttachments(emailId: string): Promise ({ - id: att.id, - filename: att.filename || "attachment", - contentType: att.content_type, - downloadUrl: att.download_url, - })); + return data.data; } From d5c0df6d42f7506e8feaa784fbb8ada1d0489f92 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sat, 28 Feb 2026 12:47:01 -0500 Subject: [PATCH 7/7] chore: remove debug logging from respondToInboundEmail Co-Authored-By: Claude Opus 4.6 --- lib/emails/inbound/respondToInboundEmail.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lib/emails/inbound/respondToInboundEmail.ts b/lib/emails/inbound/respondToInboundEmail.ts index 454e973ed..ea75fb3b3 100644 --- a/lib/emails/inbound/respondToInboundEmail.ts +++ b/lib/emails/inbound/respondToInboundEmail.ts @@ -19,7 +19,6 @@ export async function respondToInboundEmail( ): Promise { try { const original = event.data; - const emailId = original.email_id; const subject = original.subject ? `Re: ${original.subject}` : "Re: Your email"; const messageId = original.message_id; const to = original.from; @@ -27,30 +26,23 @@ export async function respondToInboundEmail( const from = getFromWithName(original.to, original.cc); const cc = original.cc?.length ? original.cc : undefined; - console.log(`[respondToInboundEmail] Processing email ${emailId} from ${to}`); - // Validate new memory and get chat request body (or early return if duplicate) const validationResult = await validateNewEmailMemory(event); if ("response" in validationResult) { - console.log(`[respondToInboundEmail] Email ${emailId} - early return from validateNewEmailMemory`); return validationResult.response; } const { chatRequestBody, emailText } = validationResult; - console.log(`[respondToInboundEmail] Email ${emailId} - memory validated, roomId=${chatRequestBody.roomId}, emailText length=${emailText.length}`); // Check if Recoup is only CC'd - use LLM to determine if reply is expected const ccValidation = await validateCcReplyExpected(original, emailText); if (ccValidation) { - console.log(`[respondToInboundEmail] Email ${emailId} - CC validation returned early (not a direct reply)`); return ccValidation.response; } const { roomId } = chatRequestBody; - console.log(`[respondToInboundEmail] Email ${emailId} - generating response...`); const { text, html } = await generateEmailResponse(chatRequestBody); - console.log(`[respondToInboundEmail] Email ${emailId} - response generated, text length=${text.length}`); const payload = { from, @@ -63,18 +55,15 @@ export async function respondToInboundEmail( }, }; - console.log(`[respondToInboundEmail] Email ${emailId} - sending reply to ${to} from ${from}`); const result = await sendEmailWithResend(payload); // Save the assistant response message await saveChatCompletion({ text, roomId }); if (result instanceof NextResponse) { - console.log(`[respondToInboundEmail] Email ${emailId} - sendEmailWithResend returned error response`); return result; } - console.log(`[respondToInboundEmail] Email ${emailId} - reply sent successfully`); return NextResponse.json(result); } catch (error) { console.error("[respondToInboundEmail] Failed to respond to inbound email", error);