diff --git a/apps/web/__tests__/gmail-operations.test.ts b/apps/web/__tests__/gmail-operations.test.ts index 021720ea4a..05d270eb4f 100644 --- a/apps/web/__tests__/gmail-operations.test.ts +++ b/apps/web/__tests__/gmail-operations.test.ts @@ -6,9 +6,8 @@ * 2. Set TEST_GMAIL_MESSAGE_ID with a real messageId from your logs * * Usage: - * TEST_GMAIL_EMAIL=your@gmail.com pnpm test gmail-operations - * TEST_GMAIL_EMAIL=your@gmail.com TEST_GMAIL_MESSAGE_ID=xxx pnpm test gmail-operations - * pnpm test gmail-operations -t "webhook" # Run specific test + * pnpm test-e2e gmail-operations + * pnpm test-e2e gmail-operations -t "webhook" # Run specific test */ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; @@ -20,6 +19,7 @@ import type { GmailProvider } from "@/utils/email/google"; // ============================================ // TEST DATA - SET VIA ENVIRONMENT VARIABLES // ============================================ +const RUN_E2E_TESTS = process.env.RUN_E2E_TESTS; const TEST_GMAIL_EMAIL = process.env.TEST_GMAIL_EMAIL; const TEST_GMAIL_MESSAGE_ID = process.env.TEST_GMAIL_MESSAGE_ID || "199c055aa113c499"; @@ -48,7 +48,7 @@ vi.mock("next/server", async () => { // ============================================ // WEBHOOK PAYLOAD TESTS // ============================================ -describe.skipIf(!TEST_GMAIL_EMAIL)("Gmail Webhook Payload", () => { +describe.skipIf(!RUN_E2E_TESTS)("Gmail Webhook Payload", () => { let emailAccountId: string; let originalLastSyncedHistoryId: string | null; diff --git a/apps/web/__tests__/outlook-operations.test.ts b/apps/web/__tests__/outlook-operations.test.ts index 33e5fc3fb5..10abfd8f4d 100644 --- a/apps/web/__tests__/outlook-operations.test.ts +++ b/apps/web/__tests__/outlook-operations.test.ts @@ -8,9 +8,8 @@ * 4. Set TEST_CATEGORY_NAME for category/label testing (optional, defaults to "To Reply") * * Usage: - * TEST_OUTLOOK_EMAIL=your@email.com pnpm test outlook-operations - * TEST_OUTLOOK_EMAIL=your@email.com TEST_OUTLOOK_MESSAGE_ID=xxx pnpm test outlook-operations - * pnpm test outlook-operations -t "getThread" # Run specific test + * TEST_OUTLOOK_EMAIL=your@email.com pnpm test-e2e outlook-operations + * pnpm test-e2e outlook-operations -t "getThread" # Run specific test */ import { describe, test, expect, beforeAll, vi } from "vitest"; @@ -23,6 +22,7 @@ import { webhookBodySchema } from "@/app/api/outlook/webhook/types"; // ============================================ // TEST DATA - SET VIA ENVIRONMENT VARIABLES // ============================================ +const RUN_E2E_TESTS = process.env.RUN_E2E_TESTS; const TEST_OUTLOOK_EMAIL = process.env.TEST_OUTLOOK_EMAIL; const TEST_CONVERSATION_ID = process.env.TEST_CONVERSATION_ID || @@ -38,258 +38,264 @@ vi.mock("@/utils/redis/message-processing", () => ({ markMessageAsProcessing: vi.fn().mockResolvedValue(true), })); -describe.skipIf(!TEST_OUTLOOK_EMAIL)( - "Outlook Operations Integration Tests", - () => { - let provider: OutlookProvider; +describe.skipIf(!RUN_E2E_TESTS)("Outlook Operations Integration Tests", () => { + let provider: OutlookProvider; - beforeAll(async () => { - const testEmail = TEST_OUTLOOK_EMAIL; + beforeAll(async () => { + const testEmail = TEST_OUTLOOK_EMAIL; - if (!testEmail) { - console.warn("\n⚠️ Set TEST_OUTLOOK_EMAIL env var to run these tests"); - console.warn( - " Example: TEST_OUTLOOK_EMAIL=your@email.com pnpm test outlook-operations\n", - ); - return; - } + if (!testEmail) { + console.warn("\n⚠️ Set TEST_OUTLOOK_EMAIL env var to run these tests"); + console.warn( + " Example: TEST_OUTLOOK_EMAIL=your@email.com pnpm test-e2e outlook-operations\n", + ); + return; + } - // Load account from DB - const emailAccount = await prisma.emailAccount.findFirst({ - where: { - email: testEmail, - account: { - provider: "microsoft", - }, - }, - include: { - account: true, + // Load account from DB + const emailAccount = await prisma.emailAccount.findFirst({ + where: { + email: testEmail, + account: { + provider: "microsoft", }, - }); + }, + include: { + account: true, + }, + }); + + if (!emailAccount) { + throw new Error(`No Outlook account found for ${testEmail}`); + } + + provider = (await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: "microsoft", + })) as OutlookProvider; + + console.log(`\n✅ Using account: ${emailAccount.email}`); + console.log(` Account ID: ${emailAccount.id}`); + console.log(` Test conversation ID: ${TEST_CONVERSATION_ID}\n`); + }); + + describe("getThread", () => { + test("should fetch messages by conversationId", async () => { + const messages = await provider.getThreadMessages(TEST_CONVERSATION_ID); + + expect(messages).toBeDefined(); + expect(Array.isArray(messages)).toBe(true); - if (!emailAccount) { - throw new Error(`No Outlook account found for ${testEmail}`); + if (messages.length > 0) { + console.log(` ✅ Got ${messages.length} messages`); + console.log( + ` First message: ${messages[0].subject || "(no subject)"}`, + ); + expect(messages[0]).toHaveProperty("id"); + expect(messages[0]).toHaveProperty("subject"); + } else { + console.log( + " ℹ️ No messages found (may be expected if conversationId is old)", + ); } + }); - provider = (await createEmailProvider({ - emailAccountId: emailAccount.id, - provider: "microsoft", - })) as OutlookProvider; + test("should handle conversationId with special characters", async () => { + // Conversation IDs can contain base64-like characters including -, _, and sometimes = + // Test that these don't cause URL encoding issues + const messages = await provider.getThreadMessages(TEST_CONVERSATION_ID); - console.log(`\n✅ Using account: ${emailAccount.email}`); - console.log(` Account ID: ${emailAccount.id}`); - console.log(` Test conversation ID: ${TEST_CONVERSATION_ID}\n`); + expect(messages).toBeDefined(); + expect(Array.isArray(messages)).toBe(true); + console.log( + ` ✅ Handled conversationId with special characters (${TEST_CONVERSATION_ID.slice(0, 20)}...)`, + ); }); + }); - describe("getThread", () => { - test("should fetch messages by conversationId", async () => { - const messages = await provider.getThreadMessages(TEST_CONVERSATION_ID); - - expect(messages).toBeDefined(); - expect(Array.isArray(messages)).toBe(true); - - if (messages.length > 0) { - console.log(` ✅ Got ${messages.length} messages`); - console.log( - ` First message: ${messages[0].subject || "(no subject)"}`, - ); - expect(messages[0]).toHaveProperty("id"); - expect(messages[0]).toHaveProperty("subject"); - } else { - console.log( - " ℹ️ No messages found (may be expected if conversationId is old)", - ); - } - }); + describe("Sender queries", () => { + test("getMessagesFromSender should resolve without error (current bug: fails)", async () => { + const sender = "aibreakfast@mail.beehiiv.com"; + await expect( + provider.getMessagesFromSender({ senderEmail: sender, maxResults: 5 }), + ).resolves.toHaveProperty("messages"); + }, 30_000); + }); - test("should handle conversationId with special characters", async () => { - // Conversation IDs can contain base64-like characters including -, _, and sometimes = - // Test that these don't cause URL encoding issues - const messages = await provider.getThreadMessages(TEST_CONVERSATION_ID); + describe("removeThreadLabel", () => { + test("should add and remove category from thread messages", async () => { + // Get or create the category + let label = await provider.getLabelByName(TEST_CATEGORY_NAME); - expect(messages).toBeDefined(); - expect(Array.isArray(messages)).toBe(true); + if (!label) { console.log( - ` ✅ Handled conversationId with special characters (${TEST_CONVERSATION_ID.slice(0, 20)}...)`, + ` 📝 Category "${TEST_CATEGORY_NAME}" doesn't exist, creating it`, ); - }); - }); + label = await provider.createLabel(TEST_CATEGORY_NAME); + } - describe("removeThreadLabel", () => { - test("should add and remove category from thread messages", async () => { - // Get or create the category - let label = await provider.getLabelByName(TEST_CATEGORY_NAME); - - if (!label) { - console.log( - ` 📝 Category "${TEST_CATEGORY_NAME}" doesn't exist, creating it`, - ); - label = await provider.createLabel(TEST_CATEGORY_NAME); - } - - console.log(` 📝 Using category: ${label.name} (ID: ${label.id})`); - - // Get the thread messages - const messages = await provider.getThreadMessages(TEST_CONVERSATION_ID); - if (messages.length === 0) { - console.log(" ⚠️ No messages in thread, skipping test"); - return; - } - - const firstMessage = messages[0]; - - // Add the category to the message - await provider.labelMessage({ - messageId: firstMessage.id, - labelId: label.id, - }); - console.log(" ✅ Added category to message"); + console.log(` 📝 Using category: ${label.name} (ID: ${label.id})`); - // Now remove the category from the thread - await provider.removeThreadLabel(TEST_CONVERSATION_ID, label.id); - console.log(" ✅ Removed category from thread"); - }); + // Get the thread messages + const messages = await provider.getThreadMessages(TEST_CONVERSATION_ID); + if (messages.length === 0) { + console.log(" ⚠️ No messages in thread, skipping test"); + return; + } - test("should handle empty category name gracefully", async () => { - await expect( - provider.removeThreadLabel(TEST_CONVERSATION_ID, ""), - ).resolves.not.toThrow(); + const firstMessage = messages[0]; - console.log(" ✅ Handled empty category name"); + // Add the category to the message + await provider.labelMessage({ + messageId: firstMessage.id, + labelId: label.id, }); + console.log(" ✅ Added category to message"); + + // Now remove the category from the thread + await provider.removeThreadLabel(TEST_CONVERSATION_ID, label.id); + console.log(" ✅ Removed category from thread"); + }); + + test("should handle empty category name gracefully", async () => { + await expect( + provider.removeThreadLabel(TEST_CONVERSATION_ID, ""), + ).resolves.not.toThrow(); + + console.log(" ✅ Handled empty category name"); }); + }); - describe("Label operations", () => { - test("should list all categories", async () => { - const labels = await provider.getLabels(); + describe("Label operations", () => { + test("should list all categories", async () => { + const labels = await provider.getLabels(); - expect(labels).toBeDefined(); - expect(Array.isArray(labels)).toBe(true); - expect(labels.length).toBeGreaterThan(0); + expect(labels).toBeDefined(); + expect(Array.isArray(labels)).toBe(true); + expect(labels.length).toBeGreaterThan(0); - console.log(` ✅ Found ${labels.length} categories`); - labels.slice(0, 3).forEach((label) => { - console.log(` - ${label.name}`); - }); + console.log(` ✅ Found ${labels.length} categories`); + labels.slice(0, 3).forEach((label) => { + console.log(` - ${label.name}`); }); + }); - test("should create a new label", async () => { - const testLabelName = `Test Label ${Date.now()}`; - const newLabel = await provider.createLabel(testLabelName); + test("should create a new label", async () => { + const testLabelName = `Test Label ${Date.now()}`; + const newLabel = await provider.createLabel(testLabelName); - expect(newLabel).toBeDefined(); - expect(newLabel.id).toBeDefined(); - expect(newLabel.name).toBe(testLabelName); + expect(newLabel).toBeDefined(); + expect(newLabel.id).toBeDefined(); + expect(newLabel.name).toBe(testLabelName); - console.log(` ✅ Created label: ${testLabelName}`); - console.log(` ID: ${newLabel.id}`); - console.log(" (You may want to delete this test label manually)"); - }); + console.log(` ✅ Created label: ${testLabelName}`); + console.log(` ID: ${newLabel.id}`); + console.log(" (You may want to delete this test label manually)"); + }); - test("should get label by name", async () => { - const label = await provider.getLabelByName(TEST_CATEGORY_NAME); - - if (label) { - expect(label).toBeDefined(); - expect(label.name).toBe(TEST_CATEGORY_NAME); - expect(label.id).toBeDefined(); - console.log(` ✅ Found label: ${label.name} (ID: ${label.id})`); - } else { - console.log(` ℹ️ Label "${TEST_CATEGORY_NAME}" not found`); - } - }); + test("should get label by name", async () => { + const label = await provider.getLabelByName(TEST_CATEGORY_NAME); + + if (label) { + expect(label).toBeDefined(); + expect(label.name).toBe(TEST_CATEGORY_NAME); + expect(label.id).toBeDefined(); + console.log(` ✅ Found label: ${label.name} (ID: ${label.id})`); + } else { + console.log(` ℹ️ Label "${TEST_CATEGORY_NAME}" not found`); + } }); + }); - describe("Thread messages", () => { - test("should get thread messages", async () => { - const messages = await provider.getThreadMessages(TEST_CONVERSATION_ID); + describe("Thread messages", () => { + test("should get thread messages", async () => { + const messages = await provider.getThreadMessages(TEST_CONVERSATION_ID); - expect(messages).toBeDefined(); - expect(Array.isArray(messages)).toBe(true); + expect(messages).toBeDefined(); + expect(Array.isArray(messages)).toBe(true); - if (messages.length > 0) { - console.log(` ✅ Got ${messages.length} messages`); - expect(messages[0]).toHaveProperty("threadId"); - expect(messages[0].threadId).toBe(TEST_CONVERSATION_ID); - } - }); + if (messages.length > 0) { + console.log(` ✅ Got ${messages.length} messages`); + expect(messages[0]).toHaveProperty("threadId"); + expect(messages[0].threadId).toBe(TEST_CONVERSATION_ID); + } }); + }); - describe("Search queries", () => { - test("should handle search queries with colons", async () => { - // Known issue: Outlook search doesn't support "field:" syntax like Gmail - // The query "subject:lunch tomorrow?" causes: - // "Syntax error: character ':' is not valid at position 7" - // Instead, Outlook uses KQL syntax or plain text search + describe("Search queries", () => { + test("should handle search queries with colons", async () => { + // Known issue: Outlook search doesn't support "field:" syntax like Gmail + // The query "subject:lunch tomorrow?" causes: + // "Syntax error: character ':' is not valid at position 7" + // Instead, Outlook uses KQL syntax or plain text search - const invalidQuery = "subject:lunch tomorrow?"; - const validQuery = "lunch tomorrow"; // Plain text search + const invalidQuery = "subject:lunch tomorrow?"; + const validQuery = "lunch tomorrow"; // Plain text search - // Test that invalid query throws an error - await expect( - provider.getMessagesWithPagination({ - query: invalidQuery, - maxResults: 10, - }), - ).rejects.toThrow(); + // Test that invalid query throws an error + await expect( + provider.getMessagesWithPagination({ + query: invalidQuery, + maxResults: 10, + }), + ).rejects.toThrow(); + + // Test that valid query works + const result = await provider.getMessagesWithPagination({ + query: validQuery, + maxResults: 10, + }); + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + console.log( + ` ✅ Plain text search returned ${result.messages.length} messages`, + ); + }); - // Test that valid query works + test("should handle special characters in search queries", async () => { + // Test various special characters + // Note: Outlook KQL has restrictions - some chars like ? and : cause syntax errors + const validQueries = [ + "lunch tomorrow", // Plain text (should work) + "test example", // Multiple words (should work) + ]; + + const invalidQueries = [ + "meeting?", // Question mark causes syntax error + "test:query", // Colon causes syntax error + ]; + + // Test valid queries + for (const query of validQueries) { const result = await provider.getMessagesWithPagination({ - query: validQuery, - maxResults: 10, + query, + maxResults: 5, }); expect(result.messages).toBeDefined(); expect(Array.isArray(result.messages)).toBe(true); console.log( - ` ✅ Plain text search returned ${result.messages.length} messages`, + ` ✅ Query "${query}" returned ${result.messages.length} messages`, ); - }); + } - test("should handle special characters in search queries", async () => { - // Test various special characters - // Note: Outlook KQL has restrictions - some chars like ? and : cause syntax errors - const validQueries = [ - "lunch tomorrow", // Plain text (should work) - "test example", // Multiple words (should work) - ]; - - const invalidQueries = [ - "meeting?", // Question mark causes syntax error - "test:query", // Colon causes syntax error - ]; - - // Test valid queries - for (const query of validQueries) { - const result = await provider.getMessagesWithPagination({ + // Test that invalid queries throw errors + for (const query of invalidQueries) { + await expect( + provider.getMessagesWithPagination({ query, maxResults: 5, - }); - expect(result.messages).toBeDefined(); - expect(Array.isArray(result.messages)).toBe(true); - console.log( - ` ✅ Query "${query}" returned ${result.messages.length} messages`, - ); - } - - // Test that invalid queries throw errors - for (const query of invalidQueries) { - await expect( - provider.getMessagesWithPagination({ - query, - maxResults: 5, - }), - ).rejects.toThrow(); - console.log(` ✅ Query "${query}" correctly threw an error`); - } - }); + }), + ).rejects.toThrow(); + console.log(` ✅ Query "${query}" correctly threw an error`); + } }); - }, -); + }); +}); // ============================================ // WEBHOOK PAYLOAD TESTS // ============================================ -describe.skipIf(!TEST_OUTLOOK_EMAIL)("Outlook Webhook Payload", () => { +describe.skipIf(!RUN_E2E_TESTS)("Outlook Webhook Payload", () => { test("should validate real webhook payload structure", () => { const realWebhookPayload = { value: [ diff --git a/apps/web/package.json b/apps/web/package.json index 98dcb0705a..4b5f9ea7ae 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,7 @@ "lint": "next lint", "test": "cross-env RUN_AI_TESTS=false vitest", "test-ai": "cross-env RUN_AI_TESTS=true vitest --run", - "test-outlook": "vitest outlook-operations", + "test-e2e": "cross-env RUN_E2E_TESTS=true vitest --run", "preinstall": "npx only-allow pnpm", "postinstall": "prisma generate" }, diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 76f82203cd..66a3a6bb7d 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -45,6 +45,7 @@ import { deleteFilter, createAutoArchiveFilter, } from "@/utils/outlook/filter"; +import { queryMessagesWithFilters } from "@/utils/outlook/message"; import { processHistoryForUser } from "@/app/api/outlook/webhook/process-history"; import type { EmailProvider, @@ -674,13 +675,23 @@ export class OutlookProvider implements EmailProvider { messages: ParsedMessage[]; nextPageToken?: string; }> { - const senderFilter = `from/emailAddress/address eq '${escapeODataString(options.senderEmail)}'`; - return this.getMessagesWithPagination({ - query: senderFilter, + const filters: string[] = [ + `from/emailAddress/address eq '${escapeODataString(options.senderEmail)}'`, + ]; + + const dateFilters: string[] = []; + if (options.before) { + dateFilters.push(`receivedDateTime lt ${options.before.toISOString()}`); + } + if (options.after) { + dateFilters.push(`receivedDateTime gt ${options.after.toISOString()}`); + } + + return queryMessagesWithFilters(this.client, { + filters, + dateFilters, maxResults: options.maxResults, pageToken: options.pageToken, - before: options.before, - after: options.after, }); } diff --git a/apps/web/utils/outlook/message.ts b/apps/web/utils/outlook/message.ts index cb0dad7157..9b7308f22d 100644 --- a/apps/web/utils/outlook/message.ts +++ b/apps/web/utils/outlook/message.ts @@ -315,6 +315,92 @@ export async function queryBatchMessages( } } +export async function queryMessagesWithFilters( + client: OutlookClient, + options: { + filters?: string[]; // OData filter expressions to AND together + dateFilters?: string[]; // additional date filters like receivedDateTime gt/lt + maxResults?: number; + pageToken?: string; + folderId?: string; // if omitted, defaults to inbox OR archive + }, +) { + const { filters = [], dateFilters = [], pageToken, folderId } = options; + + const MAX_RESULTS = 20; + const maxResults = Math.min(options.maxResults || MAX_RESULTS, MAX_RESULTS); + if (options.maxResults && options.maxResults > MAX_RESULTS) { + logger.warn( + "Max results is greater than 20, which will cause rate limiting", + { + maxResults: options.maxResults, + }, + ); + } + + const folderIds = await getFolderIds(client); + const inboxFolderId = folderIds.inbox; + const archiveFolderId = folderIds.archive; + + // Build base request + let request = client + .getClient() + .api("/me/messages") + .select( + "id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId", + ) + .top(maxResults); + + // Build folder filter safely (avoid empty IDs) + let folderFilter: string | undefined; + if (folderId) { + folderFilter = `parentFolderId eq '${escapeODataString(folderId)}'`; + } else { + const folderClauses: string[] = []; + if (inboxFolderId) { + folderClauses.push( + `parentFolderId eq '${escapeODataString(inboxFolderId)}'`, + ); + } + if (archiveFolderId) { + folderClauses.push( + `parentFolderId eq '${escapeODataString(archiveFolderId)}'`, + ); + } + if (folderClauses.length === 1) { + folderFilter = folderClauses[0]; + } else if (folderClauses.length > 1) { + folderFilter = `(${folderClauses.join(" or ")})`; + } else { + folderFilter = undefined; // omit folder clause entirely if none present + } + } + + const combinedFilters = [ + ...(folderFilter ? [folderFilter] : []), + ...dateFilters, + ...filters, + ].filter(Boolean); + const combinedFilter = combinedFilters.join(" and "); + + request = request.filter(combinedFilter); + + if (pageToken) { + request = request.skipToken(pageToken); + } + + const response: { value: Message[]; "@odata.nextLink"?: string } = + await request.get(); + + const messages = await convertMessages(response.value, folderIds); + const nextPageToken = response["@odata.nextLink"] + ? new URL(response["@odata.nextLink"]).searchParams.get("$skiptoken") || + undefined + : undefined; + + return { messages, nextPageToken }; +} + // Helper function to convert messages async function convertMessages( messages: Message[], diff --git a/version.txt b/version.txt index 095157819d..2d5c81e41b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.15.4 +v2.15.5