Skip to content
6 changes: 5 additions & 1 deletion lib/chat/tools/createPromptSandboxStreamingTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down
67 changes: 67 additions & 0 deletions lib/emails/inbound/__tests__/formatAttachmentsText.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { formatAttachmentsText } from "../formatAttachmentsText";
import type { EmailAttachment } from "../getEmailAttachments";

const makeAttachment = (overrides: Partial<EmailAttachment> = {}): 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):");
});
});
64 changes: 64 additions & 0 deletions lib/emails/inbound/__tests__/generateEmailResponse.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => "<footer>footer</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<ReturnType<typeof getGeneralAgent>>);

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>footer</footer>");
});
});
76 changes: 76 additions & 0 deletions lib/emails/inbound/__tests__/getEmailAttachments.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getResendClient>);
});

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([]);
});
});
78 changes: 71 additions & 7 deletions lib/emails/inbound/__tests__/validateNewEmailMemory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down Expand Up @@ -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["data"]>): ResendEmailReceivedEvent {
/**
*
* @param overrides
*/
function createMockEvent(
overrides?: Partial<ResendEmailReceivedEvent["data"]>,
): ResendEmailReceivedEvent {
return {
type: "email.received",
created_at: "2024-01-01T00:00:00.000Z",
Expand Down Expand Up @@ -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 () => {
Expand All @@ -87,15 +107,21 @@ 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");
});

it("returns chatRequestBody with correct accountId, orgId, messages, and roomId", async () => {
const event = createMockEvent();

const result = await validateNewEmailMemory(event);
const { chatRequestBody } = result as { chatRequestBody: Record<string, unknown>; emailText: string };
const { chatRequestBody } = result as {
chatRequestBody: Record<string, unknown>;
emailText: string;
};

expect(chatRequestBody.accountId).toBe(MOCK_ACCOUNT_ID);
expect(chatRequestBody.orgId).toBeNull();
Expand All @@ -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<ReturnType<typeof getEmailAttachments>>);
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<string, unknown>; 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<string, unknown>; emailText: string };
expect(emailText).toBe("<p>Hello from email</p>");
});
});
18 changes: 18 additions & 0 deletions lib/emails/inbound/formatAttachmentsText.ts
Original file line number Diff line number Diff line change
@@ -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")}`;
}
21 changes: 21 additions & 0 deletions lib/emails/inbound/getEmailAttachments.ts
Original file line number Diff line number Diff line change
@@ -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<EmailAttachment[]> {
const resend = getResendClient();
const { data } = await resend.emails.receiving.attachments.list({ emailId });

if (!data?.data?.length) return [];
Comment thread
sweetmantech marked this conversation as resolved.

return data.data;
}
Loading