diff --git a/apps/web/__tests__/outlook-operations.test.ts b/apps/web/__tests__/outlook-operations.test.ts new file mode 100644 index 0000000000..e05ee38ba5 --- /dev/null +++ b/apps/web/__tests__/outlook-operations.test.ts @@ -0,0 +1,277 @@ +/** + * Manual integration tests for Outlook operations + * + * Setup: + * 1. Set TEST_OUTLOOK_EMAIL env var to your Outlook email + * 2. Set TEST_CONVERSATION_ID with a real conversationId from your logs (optional) + * 3. 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_CONVERSATION_ID=AAMk... pnpm test outlook-operations + * pnpm test outlook-operations -t "getThread" # Run specific test + */ + +import { describe, test, expect, beforeAll, vi } from "vitest"; +import prisma from "@/utils/prisma"; +import { createEmailProvider } from "@/utils/email/provider"; +import type { OutlookProvider } from "@/utils/email/microsoft"; + +// ============================================ +// TEST DATA - SET VIA ENVIRONMENT VARIABLES +// ============================================ +const TEST_OUTLOOK_EMAIL = process.env.TEST_OUTLOOK_EMAIL; +const TEST_CONVERSATION_ID = + process.env.TEST_CONVERSATION_ID || + "AQQkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoAEABuo-fmt9KvQ4u55KlWB32H"; // Real conversation ID from demoinboxzero@outlook.com +const TEST_CATEGORY_NAME = process.env.TEST_CATEGORY_NAME || "To Reply"; + +vi.mock("server-only", () => ({})); + +describe.skipIf(!TEST_OUTLOOK_EMAIL)( + "Outlook Operations Integration Tests", + () => { + let provider: OutlookProvider; + + 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; + } + + // 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 (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)", + ); + } + }); + + 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); + + expect(messages).toBeDefined(); + expect(Array.isArray(messages)).toBe(true); + console.log( + ` ✅ Handled conversationId with special characters (${TEST_CONVERSATION_ID.slice(0, 20)}...)`, + ); + }); + }); + + 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"); + + // 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(); + + 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}`); + }); + }); + + 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); + + 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`); + } + }); + }); + + 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); + + 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 + + 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 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("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, + 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`); + } + }); + }); + }, +); diff --git a/apps/web/package.json b/apps/web/package.json index fce73ff38d..98dcb0705a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,6 +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", "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 a5378c0818..76f82203cd 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -429,9 +429,12 @@ export class OutlookProvider implements EmailProvider { const client = this.client.getClient(); try { + const escapedThreadId = escapeODataString(threadId); const response = await client .api("/me/messages") - .filter(`conversationId eq '${threadId}' and parentFolderId eq 'inbox'`) + .filter( + `conversationId eq '${escapedThreadId}' and parentFolderId eq 'inbox'`, + ) .select( "id,conversationId,subject,bodyPreview,receivedDateTime,from,toRecipients,body,isDraft,categories,parentFolderId", ) @@ -473,16 +476,32 @@ export class OutlookProvider implements EmailProvider { } async removeThreadLabel(threadId: string, labelId: string): Promise { - // TODO: this can be more efficient by using the label name directly // Get the label to convert ID to name (Outlook uses names) - const label = await getLabelById({ client: this.client, id: labelId }); - const categoryName = label.displayName || ""; + // NOTE: if we have name already, we can skip this step. But because we let users use custom ids and we're not storing the custom category name, we need to first fetch the name. + try { + const label = await getLabelById({ client: this.client, id: labelId }); + const categoryName = label.displayName || ""; - await removeThreadLabel({ - client: this.client, - threadId, - categoryName, - }); + await removeThreadLabel({ + client: this.client, + threadId, + categoryName, + }); + } catch (error) { + // If label doesn't exist (404), that's okay - nothing to remove + if ( + (error as { statusCode?: number; code?: string }).statusCode === 404 || + (error as { statusCode?: number; code?: string }).code === + "CategoryNotFound" + ) { + logger.info("Label not found, skipping removal", { + threadId, + labelId, + }); + return; + } + throw error; + } } async createLabel(name: string): Promise { diff --git a/apps/web/utils/label/resolve-label.ts b/apps/web/utils/label/resolve-label.ts index 8ba60c6288..9db73fbf1d 100644 --- a/apps/web/utils/label/resolve-label.ts +++ b/apps/web/utils/label/resolve-label.ts @@ -1,8 +1,11 @@ import type { EmailProvider } from "@/utils/email/types"; +import { createScopedLogger } from "@/utils/logger"; + +const logger = createScopedLogger("resolve-label"); /** * Resolves label name and ID pairing for a label action. - * - If only label name is provided, looks up the labelId + * - If only label name is provided, looks up the labelId (creates if not found) * - If only labelId is provided, looks up the label name * - If both are provided, returns both * - Returns null for both if lookup fails @@ -21,15 +24,25 @@ export async function resolveLabelNameAndId({ return { label, labelId }; } - // If we have label name, look up the ID + // If we have label name, look up the ID (or create if not found) if (label) { try { const foundLabel = await emailProvider.getLabelByName(label); - return { - label, - labelId: foundLabel?.id ?? null, - }; - } catch { + + if (foundLabel) { + return { + label, + labelId: foundLabel.id, + }; + } + + logger.info("Label not found during rule creation, creating it", { + labelName: label, + }); + const createdLabel = await emailProvider.createLabel(label); + return { label, labelId: createdLabel.id }; + } catch (error) { + logger.error("Error resolving label", { labelName: label, error }); return { label, labelId: null }; } } diff --git a/apps/web/utils/outlook/label.ts b/apps/web/utils/outlook/label.ts index b10cb29881..4273c48776 100644 --- a/apps/web/utils/outlook/label.ts +++ b/apps/web/utils/outlook/label.ts @@ -103,7 +103,10 @@ export async function createLabel({ } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - if (errorMessage.includes("already exists")) { + if ( + errorMessage.includes("already exists") || + errorMessage.includes("conflict with the current state") + ) { logger.warn("Label already exists", { name }); const label = await getLabel({ client, name }); if (label) return label; diff --git a/apps/web/utils/outlook/odata-escape.ts b/apps/web/utils/outlook/odata-escape.ts index 6d05edb6bf..19d3633496 100644 --- a/apps/web/utils/outlook/odata-escape.ts +++ b/apps/web/utils/outlook/odata-escape.ts @@ -1,6 +1,7 @@ /** * Escapes a string value for safe use in OData filter expressions. * Single quotes in OData string literals must be escaped by doubling them. + * Additionally handles special characters that may cause issues in filters. * * @param value The string value to escape * @returns The escaped string safe for OData filter interpolation @@ -14,5 +15,6 @@ export function escapeODataString(value: string): string { return ""; } // Replace single quotes with doubled single quotes + // Note: equals signs and other special chars are valid in OData string literals return value.replace(/'/g, "''"); } diff --git a/apps/web/utils/reply-tracker/consts.ts b/apps/web/utils/reply-tracker/consts.ts index 804a3e0802..c595fe59ac 100644 --- a/apps/web/utils/reply-tracker/consts.ts +++ b/apps/web/utils/reply-tracker/consts.ts @@ -5,8 +5,10 @@ export const defaultReplyTrackerInstructions = `Apply this to emails needing my - All automated notifications (LinkedIn, Facebook, GitHub, social media, marketing) - System emails (order confirmations, calendar invites) -Only flag when someone: +Match only when someone: - Asks me a direct question - Requests information or action - Needs my specific input -- Follows up on a conversation`; +- Follows up on a conversation + +When an email matches both this rule and others, prioritize this one.`; diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index 9e25e595a5..b4072ab665 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -15,7 +15,6 @@ import { SafeError } from "@/utils/error"; import { createRuleHistory } from "@/utils/rule/rule-history"; import { isMicrosoftProvider } from "@/utils/email/provider-types"; import { createEmailProvider } from "@/utils/email/provider"; -import type { EmailProvider } from "@/utils/email/types"; import { resolveLabelNameAndId } from "@/utils/label/resolve-label"; const logger = createScopedLogger("rule"); diff --git a/version.txt b/version.txt index 9119877a8c..c52cb6dbc8 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.14.1 +v2.14.2