diff --git a/apps/web/__tests__/e2e/cold-email/google-cold-email.test.ts b/apps/web/__tests__/e2e/cold-email/google-cold-email.test.ts new file mode 100644 index 0000000000..2befae8a41 --- /dev/null +++ b/apps/web/__tests__/e2e/cold-email/google-cold-email.test.ts @@ -0,0 +1,156 @@ +/** + * E2E tests for cold email detection - Google (Gmail) + * + * Tests hasPreviousCommunicationsWithSenderOrDomain which determines if + * we've communicated with a sender before (used to skip AI checks for known contacts). + * + * Usage: + * pnpm test-e2e cold-email/google + * + * Required env vars: + * - RUN_E2E_TESTS=true + * - TEST_GMAIL_EMAIL= + */ + +import { describe, test, expect, beforeAll, vi } from "vitest"; +import prisma from "@/utils/prisma"; +import { createEmailProvider } from "@/utils/email/provider"; +import { extractEmailAddress, extractDomainFromEmail } from "@/utils/email"; +import type { EmailProvider } from "@/utils/email/types"; +import type { ParsedMessage } from "@/utils/types"; + +const RUN_E2E_TESTS = process.env.RUN_E2E_TESTS; +const TEST_GMAIL_EMAIL = process.env.TEST_GMAIL_EMAIL; + +vi.mock("server-only", () => ({})); + +describe.skipIf(!RUN_E2E_TESTS || !TEST_GMAIL_EMAIL)( + "Cold Email Detection - Google", + { timeout: 30_000 }, + () => { + let provider: EmailProvider; + let userEmail: string; + let realMessages: ParsedMessage[]; + let knownSenderEmail: string; + let companyDomain: string | undefined; + + beforeAll(async () => { + const emailAccount = await prisma.emailAccount.findFirst({ + where: { + email: TEST_GMAIL_EMAIL, + account: { provider: "google" }, + }, + include: { account: true }, + }); + + if (!emailAccount) { + throw new Error(`No Gmail account found for ${TEST_GMAIL_EMAIL}`); + } + + provider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: "google", + }); + + userEmail = emailAccount.email; + + const { messages } = await provider.getMessagesWithPagination({ + maxResults: 20, + }); + realMessages = messages; + + // Find an external sender + const externalMessage = realMessages.find((m) => { + const from = extractEmailAddress(m.headers.from); + return from && from.toLowerCase() !== userEmail.toLowerCase(); + }); + + if (!externalMessage) { + throw new Error("No external sender found in inbox - cannot run tests"); + } + + knownSenderEmail = + extractEmailAddress(externalMessage.headers.from) || + externalMessage.headers.from; + + // Find a company domain sender + const publicDomains = [ + "gmail.com", + "yahoo.com", + "hotmail.com", + "outlook.com", + "icloud.com", + ]; + const companyMessage = realMessages.find((m) => { + const from = extractEmailAddress(m.headers.from); + if (!from) return false; + const domain = extractDomainFromEmail(from); + return domain && !publicDomains.includes(domain.toLowerCase()); + }); + + if (companyMessage) { + const senderEmail = extractEmailAddress(companyMessage.headers.from)!; + companyDomain = extractDomainFromEmail(senderEmail) || undefined; + } + }, 30_000); + + describe("hasPreviousCommunicationsWithSenderOrDomain", () => { + test("returns TRUE for a sender we have received email from", async () => { + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: knownSenderEmail, + date: new Date(), + messageId: "fake-new-message-id", + }); + + expect(result).toBe(true); + }); + + test("returns FALSE for random unknown sender", async () => { + const randomEmail = `unknown-${Date.now()}@random-domain-xyz-${Date.now()}.com`; + + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: randomEmail, + date: new Date(), + messageId: "fake-message-id", + }); + + expect(result).toBe(false); + }); + + test("returns FALSE when checking before any emails existed", async () => { + const veryOldDate = new Date("2000-01-01"); + + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: knownSenderEmail, + date: veryOldDate, + messageId: "fake-message-id", + }); + + expect(result).toBe(false); + }); + + test("returns TRUE for colleague at same company domain", async ({ + skip, + }) => { + if (!companyDomain) { + skip(); + return; + } + + const fakeColleague = `different-person-${Date.now()}@${companyDomain}`; + + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: fakeColleague, + date: new Date(), + messageId: "fake-message-id", + }); + + expect(result).toBe(true); + }); + }); + }, +); diff --git a/apps/web/__tests__/e2e/cold-email/microsoft-cold-email.test.ts b/apps/web/__tests__/e2e/cold-email/microsoft-cold-email.test.ts new file mode 100644 index 0000000000..745a3eb512 --- /dev/null +++ b/apps/web/__tests__/e2e/cold-email/microsoft-cold-email.test.ts @@ -0,0 +1,308 @@ +/** + * E2E tests for cold email detection - Microsoft (Outlook) + * + * Tests hasPreviousCommunicationsWithSenderOrDomain which determines if + * we've communicated with a sender before (used to skip AI checks for known contacts). + * + * Usage: + * pnpm test-e2e cold-email/microsoft + * + * Required env vars: + * - RUN_E2E_TESTS=true + * - TEST_OUTLOOK_EMAIL= + */ + +import { describe, test, expect, beforeAll, vi } from "vitest"; +import prisma from "@/utils/prisma"; +import { createEmailProvider } from "@/utils/email/provider"; +import { extractEmailAddress, extractDomainFromEmail } from "@/utils/email"; +import type { EmailProvider } from "@/utils/email/types"; +import type { ParsedMessage } from "@/utils/types"; + +const RUN_E2E_TESTS = process.env.RUN_E2E_TESTS; +const TEST_OUTLOOK_EMAIL = process.env.TEST_OUTLOOK_EMAIL; + +vi.mock("server-only", () => ({})); + +const PUBLIC_DOMAINS = [ + "gmail.com", + "yahoo.com", + "hotmail.com", + "outlook.com", + "icloud.com", + "live.com", + "msn.com", + "aol.com", +]; + +describe.skipIf(!RUN_E2E_TESTS || !TEST_OUTLOOK_EMAIL)( + "Cold Email Detection - Microsoft", + { timeout: 60_000 }, + () => { + let provider: EmailProvider; + let userEmail: string; + let realMessages: ParsedMessage[]; + let sentMessages: ParsedMessage[]; + let knownSenderEmail: string; + let companyDomain: string | undefined; + let companySenderEmail: string | undefined; + let sentToEmail: string | undefined; + let sentToCompanyDomain: string | undefined; + + beforeAll(async () => { + const emailAccount = await prisma.emailAccount.findFirst({ + where: { + email: TEST_OUTLOOK_EMAIL, + account: { provider: "microsoft" }, + }, + include: { account: true }, + }); + + if (!emailAccount) { + throw new Error(`No Outlook account found for ${TEST_OUTLOOK_EMAIL}`); + } + + provider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: "microsoft", + }); + + userEmail = emailAccount.email; + + // Fetch received and sent messages + const [receivedResult, sentResult] = await Promise.all([ + provider.getMessagesWithPagination({ maxResults: 30 }), + provider.getSentMessages(20), + ]); + realMessages = receivedResult.messages; + sentMessages = sentResult; + + // Find an external sender (received email) + const externalMessage = realMessages.find((m) => { + const from = extractEmailAddress(m.headers.from); + return from && from.toLowerCase() !== userEmail.toLowerCase(); + }); + + if (!externalMessage) { + throw new Error("No external sender found in inbox - cannot run tests"); + } + + knownSenderEmail = + extractEmailAddress(externalMessage.headers.from) || + externalMessage.headers.from; + + // Find a company domain sender (non-public domain) from received emails + const companyMessage = realMessages.find((m) => { + const from = extractEmailAddress(m.headers.from); + if (!from) return false; + const domain = extractDomainFromEmail(from); + return domain && !PUBLIC_DOMAINS.includes(domain.toLowerCase()); + }); + + if (companyMessage) { + companySenderEmail = + extractEmailAddress(companyMessage.headers.from) || undefined; + companyDomain = companySenderEmail + ? extractDomainFromEmail(companySenderEmail) + : undefined; + } + + // Find a sent email recipient for sent detection tests + const sentMessage = sentMessages.find((m) => { + const to = extractEmailAddress(m.headers.to); + return to && to.toLowerCase() !== userEmail.toLowerCase(); + }); + + if (sentMessage) { + sentToEmail = extractEmailAddress(sentMessage.headers.to) || undefined; + // Check if sent to a company domain + if (sentToEmail) { + const domain = extractDomainFromEmail(sentToEmail); + if (domain && !PUBLIC_DOMAINS.includes(domain.toLowerCase())) { + sentToCompanyDomain = domain; + } + } + } + + // Log test data availability for debugging + console.log("Test data summary:", { + knownSenderEmail, + companyDomain, + companySenderEmail, + sentToEmail, + sentToCompanyDomain, + receivedCount: realMessages.length, + sentCount: sentMessages.length, + }); + }, 60_000); + + describe("hasPreviousCommunicationsWithSenderOrDomain", () => { + describe("received email detection", () => { + test("returns TRUE for a sender we have received email from", async () => { + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: knownSenderEmail, + date: new Date(), + messageId: "fake-new-message-id", + }); + + expect(result).toBe(true); + }); + + test("returns FALSE for random unknown sender at public domain", async () => { + const randomEmail = `unknown-${Date.now()}@gmail.com`; + + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: randomEmail, + date: new Date(), + messageId: "fake-message-id", + }); + + expect(result).toBe(false); + }); + + test("returns FALSE when checking before any emails existed", async () => { + const veryOldDate = new Date("2000-01-01"); + + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: knownSenderEmail, + date: veryOldDate, + messageId: "fake-message-id", + }); + + expect(result).toBe(false); + }); + }); + + describe("domain-based detection (company domains)", () => { + test("returns TRUE for exact sender at company domain we received from", async ({ + skip, + }) => { + if (!companySenderEmail || !companyDomain) { + console.warn( + "SKIPPED: No company domain emails found. Ensure inbox has emails from non-public domains.", + ); + skip(); + return; + } + + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: companySenderEmail, + date: new Date(), + messageId: "fake-message-id", + }); + + expect(result).toBe(true); + }); + + test("returns TRUE for fake colleague at same company domain (domain-based search)", async ({ + skip, + }) => { + if (!companyDomain) { + console.warn( + "SKIPPED: No company domain found. Ensure inbox has emails from non-public domains.", + ); + skip(); + return; + } + + // We've never received email from this person, but we have from their domain + const fakeColleague = `different-person-${Date.now()}@${companyDomain}`; + + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: fakeColleague, + date: new Date(), + messageId: "fake-message-id", + }); + + expect(result).toBe(true); + }); + + test("returns FALSE for unknown company domain", async () => { + const unknownCompanyEmail = `someone@unknown-company-${Date.now()}.io`; + + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: unknownCompanyEmail, + date: new Date(), + messageId: "fake-message-id", + }); + + expect(result).toBe(false); + }); + + test("returns FALSE for company domain when date is before communications", async ({ + skip, + }) => { + if (!companyDomain) { + skip(); + return; + } + + const fakeColleague = `someone@${companyDomain}`; + const veryOldDate = new Date("2000-01-01"); + + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: fakeColleague, + date: veryOldDate, + messageId: "fake-message-id", + }); + + expect(result).toBe(false); + }); + }); + + describe("sent email detection", () => { + test("returns TRUE for someone we have sent email TO", async ({ + skip, + }) => { + if (!sentToEmail) { + console.warn( + "SKIPPED: No sent emails found. Ensure account has sent emails.", + ); + skip(); + return; + } + + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: sentToEmail, + date: new Date(), + messageId: "fake-message-id", + }); + + expect(result).toBe(true); + }); + + test("returns TRUE for fake colleague at domain we have sent email TO (domain-based)", async ({ + skip, + }) => { + if (!sentToCompanyDomain) { + console.warn( + "SKIPPED: No sent emails to company domains found. Ensure account has sent emails to non-public domains.", + ); + skip(); + return; + } + + // We've sent to someone@domain, check if we detect a different person at same domain + const fakeColleague = `different-person-${Date.now()}@${sentToCompanyDomain}`; + + const result = + await provider.hasPreviousCommunicationsWithSenderOrDomain({ + from: fakeColleague, + date: new Date(), + messageId: "fake-message-id", + }); + + expect(result).toBe(true); + }); + }); + }); + }, +); diff --git a/apps/web/__tests__/e2e/gmail-operations.test.ts b/apps/web/__tests__/e2e/gmail-operations.test.ts index 239f61c7b7..95ebb4532e 100644 --- a/apps/web/__tests__/e2e/gmail-operations.test.ts +++ b/apps/web/__tests__/e2e/gmail-operations.test.ts @@ -15,7 +15,11 @@ import { NextRequest } from "next/server"; import prisma from "@/utils/prisma"; import { createEmailProvider } from "@/utils/email/provider"; import type { GmailProvider } from "@/utils/email/google"; -import { findOldMessage } from "@/__tests__/e2e/helpers"; +import { + ensureCatchAllTestRule, + ensureTestPremiumAccount, + findOldMessage, +} from "@/__tests__/e2e/helpers"; // ============================================ // TEST DATA - SET VIA ENVIRONMENT VARIABLES @@ -94,6 +98,9 @@ describe.skipIf(!RUN_E2E_TESTS)("Gmail Webhook Payload", () => { where: { email: TEST_GMAIL_EMAIL }, }); + await ensureTestPremiumAccount(emailAccount.userId); + await ensureCatchAllTestRule(emailAccount.id); + await prisma.executedRule.deleteMany({ where: { emailAccountId: emailAccount.id, diff --git a/apps/web/__tests__/e2e/helpers.ts b/apps/web/__tests__/e2e/helpers.ts index fa1aa3197e..970d9299e4 100644 --- a/apps/web/__tests__/e2e/helpers.ts +++ b/apps/web/__tests__/e2e/helpers.ts @@ -2,6 +2,7 @@ * Shared helpers for E2E tests */ +import prisma from "@/utils/prisma"; import type { EmailProvider } from "@/utils/email/types"; export async function findOldMessage( @@ -11,18 +12,109 @@ export async function findOldMessage( const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysOld); - const response = await provider.getMessagesWithPagination({ - maxResults: 1, - before: cutoffDate, + // Get messages from INBOX to ensure they have proper folder labels for processing + const inboxMessages = await provider.getInboxMessages(20); + + // First try to find an old message (preferred to avoid interfering with recent activity) + let selectedMessage = inboxMessages.find((msg) => { + const messageDate = msg.headers.date ? new Date(msg.headers.date) : null; + return messageDate && messageDate < cutoffDate; }); - const message = response.messages[0]; - if (!message?.id || !message?.threadId) { - throw new Error("No old message found for testing"); + // If no old message found, fall back to any inbox message (pick the oldest available) + if (!selectedMessage && inboxMessages.length > 0) { + // Sort by date ascending (oldest first) and pick the oldest + const sortedByDate = [...inboxMessages].sort((a, b) => { + const dateA = a.headers.date ? new Date(a.headers.date).getTime() : 0; + const dateB = b.headers.date ? new Date(b.headers.date).getTime() : 0; + return dateA - dateB; + }); + selectedMessage = sortedByDate[0]; + } + + if (!selectedMessage?.id || !selectedMessage?.threadId) { + throw new Error("No message found in inbox for testing"); } return { - threadId: message.threadId, - messageId: message.id, + threadId: selectedMessage.threadId, + messageId: selectedMessage.id, }; } + +/** + * Ensures the user has premium status for testing AI features. + * Creates or updates premium to BUSINESS_MONTHLY with active subscription. + * Also clears any custom aiApiKey to use env defaults. + */ +export async function ensureTestPremiumAccount(userId: string): Promise { + const user = await prisma.user.findUniqueOrThrow({ + where: { id: userId }, + include: { premium: true }, + }); + + // Clear any existing aiApiKey to use env defaults + await prisma.user.update({ + where: { id: user.id }, + data: { aiApiKey: null }, + }); + + if (!user.premium) { + const premium = await prisma.premium.create({ + data: { + tier: "BUSINESS_MONTHLY", + stripeSubscriptionStatus: "active", + }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { premiumId: premium.id }, + }); + } else { + await prisma.premium.update({ + where: { id: user.premium.id }, + data: { + stripeSubscriptionStatus: "active", + tier: "BUSINESS_MONTHLY", + }, + }); + } +} + +/** + * Ensures the email account has at least one enabled rule for automation testing. + * Creates a catch-all test rule with DRAFT_EMAIL action if none exists. + * + * Note: This creates a rule that matches ALL emails - use only in test accounts! + */ +export async function ensureCatchAllTestRule( + emailAccountId: string, +): Promise { + const existingRule = await prisma.rule.findFirst({ + where: { + emailAccountId, + enabled: true, + name: "E2E Test Catch-All Rule", + }, + }); + + if (!existingRule) { + await prisma.rule.create({ + data: { + name: "E2E Test Catch-All Rule", + emailAccountId, + enabled: true, + automate: true, + instructions: + "This is a test rule that should match all emails. Draft a brief acknowledgment reply.", + actions: { + create: { + type: "DRAFT_EMAIL", + content: "Test acknowledgment", + }, + }, + }, + }); + } +} diff --git a/apps/web/__tests__/e2e/labeling/google-labeling.test.ts b/apps/web/__tests__/e2e/labeling/google-labeling.test.ts index df70e41d50..57a99cfc40 100644 --- a/apps/web/__tests__/e2e/labeling/google-labeling.test.ts +++ b/apps/web/__tests__/e2e/labeling/google-labeling.test.ts @@ -101,7 +101,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { console.log(` Account ID: ${emailAccount.id}`); console.log(` Test thread ID: ${getTestThreadId()}`); console.log(` Test message ID: ${getTestMessageId()}\n`); - }); + }, 30_000); afterAll(async () => { // Clean up all test labels created during the test suite @@ -134,7 +134,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { describe("Label Creation and Retrieval", () => { test("should create a new label and retrieve it by name", async () => { - const testLabelName = `E2E Test ${Date.now()}`; + const testLabelName = `Gmail-Label Test ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); // Create the label @@ -158,7 +158,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { }); test("should retrieve label by ID", async () => { - const testLabelName = `E2E Test ID ${Date.now()}`; + const testLabelName = `Gmail-Label ID ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); // Create the label @@ -201,7 +201,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { }); test("should handle duplicate label creation gracefully", async () => { - const testLabelName = `E2E Duplicate ${Date.now()}`; + const testLabelName = `Gmail-Label Dup ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); // Create the label first time @@ -220,7 +220,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { }); test("should create nested labels with parent/child hierarchy", async () => { - const parentName = `E2E Parent ${Date.now()}`; + const parentName = `Gmail-Label Parent ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const nestedLabelName = `${parentName}/Child`; createdTestLabels.push(parentName, nestedLabelName); @@ -256,7 +256,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { }); test("should create deeply nested labels", async () => { - const level1 = `E2E Deep ${Date.now()}`; + const level1 = `Gmail-Label Deep ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const level2 = `${level1}/Level2`; const level3 = `${level2}/Level3`; createdTestLabels.push(level1, level2, level3); @@ -287,7 +287,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { describe("Label Application to Messages", () => { test("should apply label to a single message", async () => { - const testLabelName = `E2E Apply ${Date.now()}`; + const testLabelName = `Gmail-Label Apply ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); // Create the label @@ -318,8 +318,8 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { }); test("should apply multiple labels to a message", async () => { - const testLabel1Name = `E2E Multi 1 ${Date.now()}`; - const testLabel2Name = `E2E Multi 2 ${Date.now()}`; + const testLabel1Name = `Gmail-Label Multi1 ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const testLabel2Name = `Gmail-Label Multi2 ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabel1Name, testLabel2Name); // Create two labels @@ -363,7 +363,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { }); test("should handle applying label to non-existent message", async () => { - const testLabelName = `E2E Invalid ${Date.now()}`; + const testLabelName = `Gmail-Label Invalid ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); const label = await provider.createLabel(testLabelName); @@ -384,7 +384,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { describe("Label Removal from Threads", () => { test("should remove label from all messages in a thread", async () => { - const testLabelName = `E2E Remove ${Date.now()}`; + const testLabelName = `Gmail-Label Remove ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); // Create and apply label @@ -426,7 +426,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { }); test("should handle removing label from thread with multiple messages", async () => { - const testLabelName = `E2E Thread ${Date.now()}`; + const testLabelName = `Gmail-Label Thread ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); // Create label @@ -478,7 +478,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { describe("Complete Label Lifecycle", () => { test("should complete full label lifecycle: create, apply, verify, remove, verify", async () => { - const testLabelName = `E2E Lifecycle ${Date.now()}`; + const testLabelName = `Gmail-Label Lifecycle ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); console.log(`\n šŸ”„ Starting full lifecycle test for: ${testLabelName}`); @@ -531,8 +531,8 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { describe("Label State Consistency", () => { test("should maintain label state across multiple operations", async () => { - const label1Name = `E2E State 1 ${Date.now()}`; - const label2Name = `E2E State 2 ${Date.now()}`; + const label1Name = `Gmail-Label State1 ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const label2Name = `Gmail-Label State2 ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(label1Name, label2Name); // Create two labels @@ -552,7 +552,6 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { let message = await provider.getMessage(getTestMessageId()); expect(message.labelIds).toContain(label1.id); expect(message.labelIds).not.toContain(label2.id); - console.log(" āœ… State check 1: Only label1 present"); // Apply label2 await provider.labelMessage({ @@ -565,7 +564,6 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { message = await provider.getMessage(getTestMessageId()); expect(message.labelIds).toContain(label1.id); expect(message.labelIds).toContain(label2.id); - console.log(" āœ… State check 2: Both labels present"); // Remove label1 await provider.removeThreadLabel(message.threadId, label1.id); @@ -574,7 +572,6 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { message = await provider.getMessage(getTestMessageId()); expect(message.labelIds).not.toContain(label1.id); expect(message.labelIds).toContain(label2.id); - console.log(" āœ… State check 3: Only label2 present"); // Remove label2 await provider.removeThreadLabel(message.threadId, label2.id); @@ -583,7 +580,6 @@ describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { message = await provider.getMessage(getTestMessageId()); expect(message.labelIds).not.toContain(label1.id); expect(message.labelIds).not.toContain(label2.id); - console.log(" āœ… State check 4: No test labels present"); console.log(" āœ… Label state consistency maintained!"); }); diff --git a/apps/web/__tests__/e2e/labeling/microsoft-labeling.test.ts b/apps/web/__tests__/e2e/labeling/microsoft-labeling.test.ts index c98c20ab16..44ef00321f 100644 --- a/apps/web/__tests__/e2e/labeling/microsoft-labeling.test.ts +++ b/apps/web/__tests__/e2e/labeling/microsoft-labeling.test.ts @@ -93,7 +93,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Microsoft Outlook Labeling E2E Tests", () => { console.log(` Account ID: ${emailAccount.id}`); console.log(` Test conversation ID: ${TEST_CONVERSATION_ID}`); console.log(` Test message ID: ${TEST_OUTLOOK_MESSAGE_ID}\n`); - }); + }, 30_000); afterAll(async () => { // Clean up all test labels created during the test suite @@ -126,7 +126,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Microsoft Outlook Labeling E2E Tests", () => { describe("Label Creation and Retrieval", () => { test("should create a new label and retrieve it by name", async () => { - const testLabelName = `E2E Test ${Date.now()}`; + const testLabelName = `MS-Label Test ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); // Create the label @@ -150,7 +150,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Microsoft Outlook Labeling E2E Tests", () => { }); test("should retrieve label by ID", async () => { - const testLabelName = `E2E Test ID ${Date.now()}`; + const testLabelName = `MS-Label ID ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); // Create the label @@ -193,7 +193,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Microsoft Outlook Labeling E2E Tests", () => { }); test("should handle duplicate label creation gracefully", async () => { - const testLabelName = `E2E Duplicate ${Date.now()}`; + const testLabelName = `MS-Label Dup ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); // Create the label first time @@ -218,7 +218,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Microsoft Outlook Labeling E2E Tests", () => { describe("Label Application to Messages", () => { test("should apply label to a single message", async () => { - const testLabelName = `E2E Apply ${Date.now()}`; + const testLabelName = `MS-Label Apply ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); // Create the label @@ -249,8 +249,8 @@ describe.skipIf(!RUN_E2E_TESTS)("Microsoft Outlook Labeling E2E Tests", () => { }); test("should apply multiple labels to a message", async () => { - const testLabel1Name = `E2E Multi 1 ${Date.now()}`; - const testLabel2Name = `E2E Multi 2 ${Date.now()}`; + const testLabel1Name = `MS-Label Multi1 ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const testLabel2Name = `MS-Label Multi2 ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabel1Name, testLabel2Name); // Create two labels @@ -294,7 +294,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Microsoft Outlook Labeling E2E Tests", () => { }); test("should handle applying label to non-existent message", async () => { - const testLabelName = `E2E Invalid ${Date.now()}`; + const testLabelName = `MS-Label Invalid ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); const label = await provider.createLabel(testLabelName); @@ -315,7 +315,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Microsoft Outlook Labeling E2E Tests", () => { describe("Label Removal from Threads", () => { test("should remove label from all messages in a thread", async () => { - const testLabelName = `E2E Remove ${Date.now()}`; + const testLabelName = `MS-Label Remove ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); // Create and apply label @@ -357,7 +357,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Microsoft Outlook Labeling E2E Tests", () => { }); test("should handle removing label from thread with multiple messages", async () => { - const testLabelName = `E2E Thread ${Date.now()}`; + const testLabelName = `MS-Label Thread ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); // Create label @@ -408,7 +408,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Microsoft Outlook Labeling E2E Tests", () => { describe("Complete Label Lifecycle", () => { test("should complete full label lifecycle: create, apply, verify, remove, verify", async () => { - const testLabelName = `E2E Lifecycle ${Date.now()}`; + const testLabelName = `MS-Label Lifecycle ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(testLabelName); console.log(`\n šŸ”„ Starting full lifecycle test for: ${testLabelName}`); @@ -465,8 +465,8 @@ describe.skipIf(!RUN_E2E_TESTS)("Microsoft Outlook Labeling E2E Tests", () => { describe("Label State Consistency", () => { test("should maintain label state across multiple operations", async () => { - const label1Name = `E2E State 1 ${Date.now()}`; - const label2Name = `E2E State 2 ${Date.now()}`; + const label1Name = `MS-Label State1 ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const label2Name = `MS-Label State2 ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createdTestLabels.push(label1Name, label2Name); // Create two labels diff --git a/apps/web/__tests__/e2e/outlook-operations.test.ts b/apps/web/__tests__/e2e/outlook-operations.test.ts index ed1a45013d..bbe18897cc 100644 --- a/apps/web/__tests__/e2e/outlook-operations.test.ts +++ b/apps/web/__tests__/e2e/outlook-operations.test.ts @@ -17,7 +17,11 @@ import { NextRequest } from "next/server"; import prisma from "@/utils/prisma"; import { createEmailProvider } from "@/utils/email/provider"; import { webhookBodySchema } from "@/app/api/outlook/webhook/types"; -import { findOldMessage } from "@/__tests__/e2e/helpers"; +import { + ensureCatchAllTestRule, + ensureTestPremiumAccount, + findOldMessage, +} from "@/__tests__/e2e/helpers"; import { sleep } from "@/utils/sleep"; import type { EmailProvider } from "@/utils/email/types"; @@ -109,7 +113,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Outlook Operations Integration Tests", () => { " ā„¹ļø No messages found (may be expected if conversationId is old)", ); } - }); + }, 30_000); test("should handle conversationId with special characters", async () => { // Conversation IDs can contain base64-like characters including -, _, and sometimes = @@ -193,7 +197,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Outlook Operations Integration Tests", () => { }); test("should create a new label", async () => { - const testLabelName = `Test Label ${Date.now()}`; + const testLabelName = `Outlook-Ops Label ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const newLabel = await provider.createLabel(testLabelName); expect(newLabel).toBeDefined(); @@ -236,23 +240,20 @@ describe.skipIf(!RUN_E2E_TESTS)("Outlook Operations Integration Tests", () => { 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?"; + // getMessagesWithPagination strips Gmail-style prefixes and uses plain $search + // subject:lunch -> "lunch" for $search (searches subject and body) + const queryWithPrefix = "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 query with prefix works (prefix gets stripped) + const resultWithPrefix = await provider.getMessagesWithPagination({ + query: queryWithPrefix, + maxResults: 10, + }); + expect(resultWithPrefix.messages).toBeDefined(); + expect(Array.isArray(resultWithPrefix.messages)).toBe(true); - // Test that valid query works + // Test that plain query works const result = await provider.getMessagesWithPagination({ query: validQuery, maxResults: 10, @@ -266,15 +267,12 @@ describe.skipIf(!RUN_E2E_TESTS)("Outlook Operations Integration Tests", () => { test("should handle special characters in search queries", async () => { // Test various special characters - // Note: Outlook KQL has restrictions - some chars like : cause syntax errors + // Note: getMessagesWithPagination strips Gmail-style prefixes for $search const validQueries = [ "lunch tomorrow", // Plain text (should work) "test example", // Multiple words (should work) "can we meet tomorrow?", // Question mark should be sanitized - ]; - - const invalidQueries = [ - "test:query", // Colon causes syntax error + "subject:test query", // Gmail prefix gets stripped, searches "test query" ]; // Test valid queries @@ -289,17 +287,6 @@ describe.skipIf(!RUN_E2E_TESTS)("Outlook Operations Integration Tests", () => { ` āœ… 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`); - } }); }); }); @@ -355,39 +342,9 @@ describe.skipIf(!RUN_E2E_TESTS)("Outlook Webhook Payload", () => { data: { watchEmailsSubscriptionId: MOCK_SUBSCRIPTION_ID }, }); - // Make the account premium for testing - const user = await prisma.user.findUniqueOrThrow({ - where: { id: emailAccount.userId }, - include: { premium: true }, - }); - - // Clear any existing aiApiKey to use env defaults - await prisma.user.update({ - where: { id: user.id }, - data: { aiApiKey: null }, - }); - - if (!user.premium) { - const premium = await prisma.premium.create({ - data: { - tier: "BUSINESS_MONTHLY", - stripeSubscriptionStatus: "active", - }, - }); - - await prisma.user.update({ - where: { id: user.id }, - data: { premiumId: premium.id }, - }); - } else { - await prisma.premium.update({ - where: { id: user.premium.id }, - data: { - stripeSubscriptionStatus: "active", - tier: "BUSINESS_MONTHLY", - }, - }); - } + // Set up premium and test rule + await ensureTestPremiumAccount(emailAccount.userId); + await ensureCatchAllTestRule(emailAccount.id); await prisma.executedRule.deleteMany({ where: { @@ -396,32 +353,6 @@ describe.skipIf(!RUN_E2E_TESTS)("Outlook Webhook Payload", () => { }, }); - // Ensure the user has at least one enabled rule for automation - const existingRule = await prisma.rule.findFirst({ - where: { - emailAccountId: emailAccount.id, - enabled: true, - }, - }); - - if (!existingRule) { - await prisma.rule.create({ - data: { - name: "Test Rule for Webhook", - emailAccountId: emailAccount.id, - enabled: true, - automate: true, - instructions: "Reply to emails about testing", - actions: { - create: { - type: "DRAFT_EMAIL", - content: "Test reply", - }, - }, - }, - }); - } - // This test requires a real Outlook account const { POST } = await import("@/app/api/outlook/webhook/route"); @@ -543,7 +474,7 @@ describe.skipIf(!RUN_E2E_TESTS)("Outlook Webhook Payload", () => { } else { console.log(" ā„¹ļø No draft action found"); } - }, 30_000); + }, 60_000); test("should verify draft ID can be fetched immediately after creation", async () => { const emailAccount = await prisma.emailAccount.findUniqueOrThrow({ diff --git a/apps/web/__tests__/e2e/outlook-query-parsing.test.ts b/apps/web/__tests__/e2e/outlook-query-parsing.test.ts new file mode 100644 index 0000000000..e10cfa39d3 --- /dev/null +++ b/apps/web/__tests__/e2e/outlook-query-parsing.test.ts @@ -0,0 +1,174 @@ +/** + * E2E tests for Outlook Gmail-style query handling + * + * Tests that Gmail-style queries (subject:, from:, to:) are handled correctly + * by stripping prefixes and using plain text search with Microsoft Graph. + * + * Usage: + * pnpm test-e2e outlook-query-parsing + * + * Required env vars: + * - RUN_E2E_TESTS=true + * - TEST_OUTLOOK_EMAIL= + */ + +import { describe, test, expect, beforeAll, vi } from "vitest"; +import { subMonths } from "date-fns/subMonths"; +import prisma from "@/utils/prisma"; +import { createEmailProvider } from "@/utils/email/provider"; +import type { EmailProvider } from "@/utils/email/types"; + +const RUN_E2E_TESTS = process.env.RUN_E2E_TESTS; +const TEST_OUTLOOK_EMAIL = process.env.TEST_OUTLOOK_EMAIL; + +vi.mock("server-only", () => ({})); + +describe.skipIf(!RUN_E2E_TESTS)( + "Outlook Query Parsing E2E", + { timeout: 15_000 }, + () => { + let provider: EmailProvider; + + beforeAll(async () => { + if (!TEST_OUTLOOK_EMAIL) { + console.warn("\nāš ļø Set TEST_OUTLOOK_EMAIL env var to run these tests"); + return; + } + + const emailAccount = await prisma.emailAccount.findFirst({ + where: { + email: TEST_OUTLOOK_EMAIL, + account: { + provider: "microsoft", + }, + }, + include: { + account: true, + }, + }); + + if (!emailAccount) { + throw new Error(`No Outlook account found for ${TEST_OUTLOOK_EMAIL}`); + } + + provider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: "microsoft", + }); + + console.log(`\nāœ… Using account: ${emailAccount.email}\n`); + }); + + describe("getMessagesWithPagination handles Gmail-style queries", () => { + test("should handle subject: prefix by stripping and searching", async () => { + // subject:test gets stripped to just "test" for $search + const result = await provider.getMessagesWithPagination({ + query: "subject:test", + maxResults: 5, + }); + + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + console.log( + ` āœ… subject:test returned ${result.messages.length} messages`, + ); + }); + + test('should handle subject:"quoted term" by stripping prefix', async () => { + // subject:"meeting" gets stripped to just "meeting" + const result = await provider.getMessagesWithPagination({ + query: 'subject:"meeting"', + maxResults: 5, + }); + + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + console.log( + ` āœ… subject:"meeting" returned ${result.messages.length} messages`, + ); + }); + + test("should handle from: prefix by stripping and searching", async () => { + // from:email gets stripped to just the email for $search + const result = await provider.getMessagesWithPagination({ + query: `from:${TEST_OUTLOOK_EMAIL}`, + maxResults: 5, + }); + + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + console.log( + ` āœ… from:${TEST_OUTLOOK_EMAIL} returned ${result.messages.length} messages`, + ); + }); + + test("should handle plain text query directly", async () => { + const result = await provider.getMessagesWithPagination({ + query: "order status", + maxResults: 5, + }); + + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + console.log( + ` āœ… Plain "order status" returned ${result.messages.length} messages`, + ); + }); + + test("should handle OR queries", async () => { + const result = await provider.getMessagesWithPagination({ + query: '"order" OR "shipment"', + maxResults: 5, + }); + + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + console.log( + ` āœ… OR query returned ${result.messages.length} messages`, + ); + }); + + test("should strip label: prefix", async () => { + // label:inbox gets stripped, leaving just "meeting" + const result = await provider.getMessagesWithPagination({ + query: "label:inbox meeting", + maxResults: 5, + }); + + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + console.log( + ` āœ… label:inbox meeting returned ${result.messages.length} messages`, + ); + }); + + test("should handle query with date filters", async () => { + const oneMonthAgo = subMonths(new Date(), 1); + + const result = await provider.getMessagesWithPagination({ + query: "test", + maxResults: 5, + after: oneMonthAgo, + }); + + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + console.log( + ` āœ… Query with date filter returned ${result.messages.length} messages`, + ); + }); + + test("should handle empty query", async () => { + const result = await provider.getMessagesWithPagination({ + maxResults: 5, + }); + + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + console.log( + ` āœ… Empty query returned ${result.messages.length} messages`, + ); + }); + }); + }, +); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ResultDisplay.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ResultDisplay.tsx index 7602e21f49..198793228a 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ResultDisplay.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ResultDisplay.tsx @@ -213,7 +213,10 @@ function Actions({ {fields.length > 0 && (
{fields.map((field) => ( -
+
{field.key}:{" "} {field.value}
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/group/[groupId]/examples/page.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/group/[groupId]/examples/page.tsx deleted file mode 100644 index 0835c7d83f..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/assistant/group/[groupId]/examples/page.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import useSWR from "swr"; -import { use } from "react"; -import groupBy from "lodash/groupBy"; -import { TopSection } from "@/components/TopSection"; -import { ExampleList } from "@/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/example-list"; -import type { GroupEmailsResponse } from "@/app/api/user/group/[groupId]/messages/controller"; -import { LoadingContent } from "@/components/LoadingContent"; - -export const dynamic = "force-dynamic"; - -export default function RuleExamplesPage(props: { - params: Promise<{ groupId: string }>; -}) { - const params = use(props.params); - const { data, isLoading, error } = useSWR( - `/api/user/group/${params.groupId}/messages`, - ); - - const threads = groupBy(data?.messages, (m) => m.threadId); - const groupedBySenders = groupBy(threads, (t) => t[0]?.headers.from); - - const hasExamples = Object.keys(groupedBySenders).length > 0; - - return ( -
- Loading...

- ) : hasExamples ? ( -

Here are examples of emails that match.

- ) : ( -

We did not find any examples to show you that match.

- ) - } - /> - - - -
- ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/example-list.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/example-list.tsx deleted file mode 100644 index 4a3540d3b5..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/example-list.tsx +++ /dev/null @@ -1,75 +0,0 @@ -"use client"; - -import { useState } from "react"; -import clsx from "clsx"; -import type { Dictionary } from "lodash"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { deleteGroupItemAction } from "@/utils/actions/group"; -import type { MessageWithGroupItem } from "@/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/types"; -import { toastError } from "@/components/Toast"; -import { useAccount } from "@/providers/EmailAccountProvider"; - -export function ExampleList({ - groupedBySenders, -}: { - groupedBySenders: Dictionary; -}) { - const [removed, setRemoved] = useState([]); - const { emailAccountId } = useAccount(); - - return ( -
- {Object.entries(groupedBySenders).map(([from, threads]) => { - const matchingGroupItem = threads[0]?.[0]?.matchingGroupItem; - - const firstThreadId = threads[0]?.[0]?.id; - - if (removed.includes(firstThreadId)) return null; - - return ( - - - - {from} - - - -
    1 && "list-inside list-disc")} - > - {threads.map((t) => ( -
  • {t[0]?.headers.subject}
  • - ))} -
- {!!matchingGroupItem && ( - - )} -
-
- ); - })} -
- ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/page.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/page.tsx deleted file mode 100644 index c46cc39a7a..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { use } from "react"; -import Link from "next/link"; -import useSWR from "swr"; -import groupBy from "lodash/groupBy"; -import { TopSection } from "@/components/TopSection"; -import { Button } from "@/components/ui/button"; -import { ExampleList } from "@/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/example-list"; -import type { ExamplesResponse } from "@/app/api/user/rules/[id]/example/route"; -import { LoadingContent } from "@/components/LoadingContent"; -import { useAccount } from "@/providers/EmailAccountProvider"; -import { prefixPath } from "@/utils/path"; - -export default function RuleExamplesPage(props: { - params: Promise<{ ruleId: string }>; -}) { - const params = use(props.params); - const { data, isLoading, error } = useSWR( - `/api/user/rules/${params.ruleId}/example`, - ); - const { emailAccountId } = useAccount(); - const threads = groupBy(data, (m) => m.threadId); - const groupedBySenders = groupBy(threads, (t) => t[0]?.headers.from); - - const hasExamples = Object.keys(groupedBySenders).length > 0; - - return ( -
- - {hasExamples ? ( -

- Here are some examples of previous emails that match this rule. -

- ) : ( -

- We did not find any examples to show you that match this rule. -

- )} - - - } - /> - - - -
- ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/types.ts b/apps/web/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/types.ts deleted file mode 100644 index 48ec186493..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ParsedMessage } from "@/utils/types"; -import type { GroupItem, Prisma } from "@/generated/prisma/client"; - -export type RuleWithGroup = Prisma.RuleGetPayload<{ - include: { group: { include: { items: true } } }; -}>; - -export type MessageWithGroupItem = ParsedMessage & { - matchingGroupItem?: GroupItem | null; -}; diff --git a/apps/web/app/(app)/[emailAccountId]/debug/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/page.tsx index 2dfba75499..22a39db1ca 100644 --- a/apps/web/app/(app)/[emailAccountId]/debug/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/debug/page.tsx @@ -14,6 +14,9 @@ export default async function DebugPage(props: { Debug
+ diff --git a/apps/web/app/(app)/[emailAccountId]/debug/rules/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/rules/page.tsx new file mode 100644 index 0000000000..cc5e58de64 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/debug/rules/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useCallback, useState } from "react"; +import useSWR from "swr"; +import { useAction } from "next-safe-action/hooks"; +import { CopyIcon, CheckIcon } from "lucide-react"; +import { PageHeading } from "@/components/Typography"; +import { PageWrapper } from "@/components/PageWrapper"; +import { LoadingContent } from "@/components/LoadingContent"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { toastSuccess, toastError } from "@/components/Toast"; +import { toggleAllRulesAction } from "@/utils/actions/rule"; +import type { DebugRulesResponse } from "@/app/api/user/debug/rules/route"; +import { useAccount } from "@/providers/EmailAccountProvider"; + +export default function DebugRulesPage() { + const { emailAccountId } = useAccount(); + const { data, isLoading, error, mutate } = useSWR( + "/api/user/debug/rules", + ); + const [copied, setCopied] = useState(false); + const allRulesEnabled = data?.every((rule) => rule.enabled) ?? false; + const someRulesEnabled = data?.some((rule) => rule.enabled) ?? false; + const { execute, isExecuting } = useAction( + toggleAllRulesAction.bind(null, emailAccountId), + { + onSuccess: () => { + toastSuccess({ description: "Rules updated successfully" }); + mutate(); + }, + onError: (result) => { + toastError({ + title: "Failed to update rules", + description: result.error.serverError || "Unknown error", + }); + }, + }, + ); + + const handleCopy = useCallback(() => { + if (!data) return; + navigator.clipboard.writeText(JSON.stringify(data, null, 2)); + setCopied(true); + toastSuccess({ description: "Copied to clipboard" }); + setTimeout(() => setCopied(false), 2000); + }, [data]); + + return ( + + Rules + + +
+
+
+ execute({ enabled })} + disabled={isExecuting} + /> + +
+ +
+ +
+
+              {data ? JSON.stringify(data, null, 2) : "Loading..."}
+            
+
+
+
+
+ ); +} diff --git a/apps/web/app/api/user/debug/rules/route.ts b/apps/web/app/api/user/debug/rules/route.ts new file mode 100644 index 0000000000..26afffd98a --- /dev/null +++ b/apps/web/app/api/user/debug/rules/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { withEmailAccount } from "@/utils/middleware"; +import prisma from "@/utils/prisma"; + +export type DebugRulesResponse = Awaited>; + +export const GET = withEmailAccount("user/debug/rules", async (request) => { + const emailAccountId = request.auth.emailAccountId; + const result = await getDebugRules({ emailAccountId }); + return NextResponse.json(result); +}); + +async function getDebugRules({ emailAccountId }: { emailAccountId: string }) { + const rules = await prisma.rule.findMany({ + where: { emailAccountId }, + include: { + actions: true, + group: { + select: { + id: true, + name: true, + _count: { + select: { items: true }, + }, + }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + + return rules.map((rule) => ({ + id: rule.id, + name: rule.name, + enabled: rule.enabled, + automate: rule.automate, + runOnThreads: rule.runOnThreads, + conditionalOperator: rule.conditionalOperator, + systemType: rule.systemType, + instructions: rule.instructions, + from: rule.from, + to: rule.to, + subject: rule.subject, + body: rule.body, + promptText: rule.promptText, + actions: rule.actions.map((action) => ({ + id: action.id, + type: action.type, + label: action.label, + labelId: action.labelId, + subject: action.subject, + content: action.content, + to: action.to, + cc: action.cc, + bcc: action.bcc, + url: action.url, + folderName: action.folderName, + folderId: action.folderId, + delayInMinutes: action.delayInMinutes, + })), + learnedPatternsCount: rule.group?._count.items ?? 0, + createdAt: rule.createdAt, + updatedAt: rule.updatedAt, + })); +} diff --git a/apps/web/app/api/user/group/[groupId]/messages/controller.ts b/apps/web/app/api/user/group/[groupId]/messages/controller.ts deleted file mode 100644 index 63fd77c03f..0000000000 --- a/apps/web/app/api/user/group/[groupId]/messages/controller.ts +++ /dev/null @@ -1,272 +0,0 @@ -import prisma from "@/utils/prisma"; -import { createHash } from "node:crypto"; -import groupBy from "lodash/groupBy"; -import { findMatchingGroupItem } from "@/utils/group/find-matching-group"; -import { extractEmailAddress } from "@/utils/email"; -import { GroupItemType } from "@/generated/prisma/enums"; -import type { GroupItem } from "@/generated/prisma/client"; -import type { MessageWithGroupItem } from "@/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/types"; -import { SafeError } from "@/utils/error"; -import { createEmailProvider } from "@/utils/email/provider"; -import type { EmailProvider } from "@/utils/email/types"; - -const PAGE_SIZE = 20; - -interface InternalPaginationState { - type: GroupItemType; - chunkIndex: number; - pageToken?: string; - groupItemsHash: string; -} - -export type GroupEmailsResponse = Awaited>; - -export async function getGroupEmails({ - provider, - groupId, - emailAccountId, - from, - to, - pageToken, -}: { - provider: string; - groupId: string; - emailAccountId: string; - from?: Date; - to?: Date; - pageToken?: string; -}) { - const group = await prisma.group.findUnique({ - where: { id: groupId, emailAccountId }, - include: { items: true }, - }); - - if (!group) throw new SafeError("Group not found"); - - const emailProvider = await createEmailProvider({ - emailAccountId, - provider, - }); - - const { messages, nextPageToken } = await fetchPaginatedMessages({ - emailProvider, - groupItems: group.items, - from, - to, - pageToken, - }); - - return { messages, nextPageToken }; -} - -export async function fetchPaginatedMessages({ - emailProvider, - groupItems, - from, - to, - pageToken, -}: { - emailProvider: EmailProvider; - groupItems: GroupItem[]; - from?: Date; - to?: Date; - pageToken?: string; -}) { - const groupItemsHash = createGroupItemsHash(groupItems); - let paginationState: InternalPaginationState; - - const defaultPaginationState = { - type: GroupItemType.FROM, - chunkIndex: 0, - groupItemsHash, - }; - - if (pageToken) { - try { - const decodedState = JSON.parse( - Buffer.from(pageToken, "base64").toString("utf-8"), - ); - if (decodedState.groupItemsHash === groupItemsHash) { - paginationState = decodedState; - } else { - // Group items have changed, start from the beginning - paginationState = defaultPaginationState; - } - } catch { - // Invalid pageToken, start from the beginning - paginationState = defaultPaginationState; - } - } else { - paginationState = defaultPaginationState; - } - - const { messages, nextPaginationState } = await fetchPaginatedGroupMessages( - groupItems, - emailProvider, - from, - to, - paginationState, - ); - - const nextPageToken = nextPaginationState - ? Buffer.from(JSON.stringify(nextPaginationState)).toString("base64") - : undefined; - - return { messages, nextPageToken }; -} - -// used for pagination -// if the group items change, we start from the beginning -function createGroupItemsHash( - groupItems: { type: string; value: string }[], -): string { - const itemsString = JSON.stringify( - groupItems.map((item) => ({ type: item.type, value: item.value })), - ); - return createHash("md5").update(itemsString).digest("hex"); -} - -// we set up our own pagination -// as we have to paginate through multiple types -// and for each type, through multiple chunks -async function fetchPaginatedGroupMessages( - groupItems: GroupItem[], - emailProvider: EmailProvider, - from: Date | undefined, - to: Date | undefined, - paginationState: InternalPaginationState, -): Promise<{ - messages: MessageWithGroupItem[]; - nextPaginationState?: InternalPaginationState; -}> { - const CHUNK_SIZE = PAGE_SIZE; - - const groupItemTypes: GroupItemType[] = [ - GroupItemType.FROM, - GroupItemType.SUBJECT, - ]; - const groupItemsByType = groupBy(groupItems, (item) => item.type); - - let messages: MessageWithGroupItem[] = []; - let nextPaginationState: InternalPaginationState | undefined; - - const processChunk = async (type: GroupItemType) => { - const items = groupItemsByType[type] || []; - while (paginationState.type === type && messages.length < PAGE_SIZE) { - const chunk = items.slice( - paginationState.chunkIndex * CHUNK_SIZE, - (paginationState.chunkIndex + 1) * CHUNK_SIZE, - ); - if (chunk.length === 0) break; - - const result = await fetchGroupMessages( - type, - chunk, - emailProvider, - PAGE_SIZE - messages.length, - from, - to, - paginationState.pageToken, - ); - messages = [...messages, ...result.messages]; - - if (result.nextPageToken) { - nextPaginationState = { - type, - chunkIndex: paginationState.chunkIndex, - pageToken: result.nextPageToken, - groupItemsHash: paginationState.groupItemsHash, - }; - break; - } - paginationState.chunkIndex++; - paginationState.pageToken = undefined; - } - }; - - for (const type of groupItemTypes) { - if (messages.length < PAGE_SIZE) { - await processChunk(type); - } else { - break; - } - } - - // Handle transition to the next GroupItemType if current type is exhausted - // This ensures we paginate through all types in order - if (!nextPaginationState && messages.length < PAGE_SIZE) { - const nextTypeIndex = groupItemTypes.indexOf(paginationState.type) + 1; - if (nextTypeIndex < groupItemTypes.length) { - nextPaginationState = { - type: groupItemTypes[nextTypeIndex], - chunkIndex: 0, - groupItemsHash: paginationState.groupItemsHash, - }; - } - } - - return { messages, nextPaginationState }; -} - -async function fetchGroupMessages( - groupItemType: GroupItemType, - groupItems: GroupItem[], - emailProvider: EmailProvider, - maxResults: number, - from?: Date, - to?: Date, - pageToken?: string, -): Promise<{ messages: MessageWithGroupItem[]; nextPageToken?: string }> { - const query = buildQuery(groupItemType, groupItems, from, to); - - const response = await emailProvider.getMessagesWithPagination({ - query, - maxResults, - pageToken, - }); - - const messages = await Promise.all( - (response.messages || []).map(async (m) => { - const message = await emailProvider.getMessage(m.id); - const matchingGroupItem = findMatchingGroupItem( - message.headers, - groupItems, - ); - return { ...message, matchingGroupItem }; - }), - ); - - return { - // search might include messages that don't match the rule, so we filter those out - messages: messages.filter((message) => message.matchingGroupItem), - nextPageToken: response.nextPageToken || undefined, - }; -} - -function buildQuery( - groupItemType: GroupItemType, - groupItems: GroupItem[], - from?: Date, - to?: Date, -) { - const beforeQuery = from - ? `before:${Math.floor(from.getTime() / 1000)} ` - : ""; - const afterQuery = to ? `after:${Math.floor(to.getTime() / 1000)} ` : ""; - - if (groupItemType === GroupItemType.FROM) { - const q = `from:(${groupItems - .map((item) => `"${extractEmailAddress(item.value) || item.value}"`) - .join(" OR ")}) ${beforeQuery}${afterQuery}`; - return q; - } - - if (groupItemType === GroupItemType.SUBJECT) { - const q = `subject:(${groupItems - .map((item) => `"${item.value}"`) - .join(" OR ")}) ${beforeQuery}${afterQuery}`; - return q; - } - - return ""; -} diff --git a/apps/web/app/api/user/group/[groupId]/messages/route.ts b/apps/web/app/api/user/group/[groupId]/messages/route.ts deleted file mode 100644 index efc7ad7f45..0000000000 --- a/apps/web/app/api/user/group/[groupId]/messages/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextResponse } from "next/server"; -import { withEmailProvider } from "@/utils/middleware"; -import { getGroupEmails } from "@/app/api/user/group/[groupId]/messages/controller"; - -export const GET = withEmailProvider( - "user/group/messages", - async (request, { params }) => { - const emailAccountId = request.auth.emailAccountId; - - const { groupId } = await params; - if (!groupId) return NextResponse.json({ error: "Missing group id" }); - - const { messages } = await getGroupEmails({ - provider: request.emailProvider.name, - groupId, - emailAccountId, - from: undefined, - to: undefined, - pageToken: "", - }); - - return NextResponse.json({ messages }); - }, -); diff --git a/apps/web/app/api/user/rules/[id]/example/controller.ts b/apps/web/app/api/user/rules/[id]/example/controller.ts deleted file mode 100644 index 2ad7ad5e16..0000000000 --- a/apps/web/app/api/user/rules/[id]/example/controller.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { - MessageWithGroupItem, - RuleWithGroup, -} from "@/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/types"; -import { - matchesStaticRule, - splitEmailPatterns, -} from "@/utils/ai/choose-rule/match-rules"; -import { fetchPaginatedMessages } from "@/app/api/user/group/[groupId]/messages/controller"; -import { isGroupRule, isAIRule, isStaticRule } from "@/utils/condition"; -import { LogicalOperator } from "@/generated/prisma/enums"; -import type { EmailProvider } from "@/utils/email/types"; -import type { Logger } from "@/utils/logger"; - -export async function fetchExampleMessages( - rule: RuleWithGroup, - emailProvider: EmailProvider, - logger: Logger, -) { - const isStatic = isStaticRule(rule); - const isGroup = isGroupRule(rule); - const isAI = isAIRule(rule); - - if (isAI) return []; - - // if AND and more than 1 condition, return [] - // TODO: handle multiple conditions properly and return real examples - const conditions = [isStatic, isGroup, isAI]; - const trueConditionsCount = conditions.filter(Boolean).length; - - if ( - trueConditionsCount > 1 && - rule.conditionalOperator === LogicalOperator.AND - ) - return []; - - if (isStatic) return fetchStaticExampleMessages(rule, emailProvider, logger); - - if (isGroup) { - if (!rule.group) return []; - - const { messages } = await fetchPaginatedMessages({ - emailProvider, - groupItems: rule.group.items, - }); - return messages; - } - - return []; -} - -async function fetchStaticExampleMessages( - rule: RuleWithGroup, - emailProvider: EmailProvider, - logger: Logger, -): Promise { - // Build structured query options instead of provider-specific query strings - const options: Parameters[0] = { - maxResults: 50, - }; - - if (rule.from) { - options.froms = splitEmailPatterns(rule.from); - } - if (rule.to) { - options.tos = splitEmailPatterns(rule.to); - } - if (rule.subject) { - options.subjects = [rule.subject]; - } - - const response = await emailProvider.getMessagesByFields(options); - - // search might include messages that don't match the rule, so we filter those out - return response.messages.filter((message) => - matchesStaticRule(rule, message, logger), - ); -} diff --git a/apps/web/app/api/user/rules/[id]/example/route.ts b/apps/web/app/api/user/rules/[id]/example/route.ts deleted file mode 100644 index 5c8aac7f73..0000000000 --- a/apps/web/app/api/user/rules/[id]/example/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextResponse } from "next/server"; -import prisma from "@/utils/prisma"; -import { withEmailProvider } from "@/utils/middleware"; -import { fetchExampleMessages } from "@/app/api/user/rules/[id]/example/controller"; -import { SafeError } from "@/utils/error"; -import { createEmailProvider } from "@/utils/email/provider"; -import type { Logger } from "@/utils/logger"; - -export type ExamplesResponse = Awaited>; - -async function getExamples({ - ruleId, - emailAccountId, - provider, - logger, -}: { - ruleId: string; - emailAccountId: string; - provider: string; - logger: Logger; -}) { - const rule = await prisma.rule.findUnique({ - where: { id: ruleId, emailAccountId }, - include: { group: { include: { items: true } } }, - }); - - if (!rule) throw new SafeError("Rule not found"); - - const emailProvider = await createEmailProvider({ - emailAccountId, - provider, - logger, - }); - - const exampleMessages = await fetchExampleMessages( - rule, - emailProvider, - logger, - ); - - return exampleMessages; -} - -export const GET = withEmailProvider( - "user/rules/example", - async (request, { params }) => { - const emailAccountId = request.auth.emailAccountId; - const provider = request.emailProvider.name; - - const { id } = await params; - if (!id) return NextResponse.json({ error: "Missing rule id" }); - - const result = await getExamples({ - ruleId: id, - emailAccountId, - provider, - logger: request.logger, - }); - - return NextResponse.json(result); - }, -); diff --git a/apps/web/app/api/v1/group/[groupId]/emails/route.ts b/apps/web/app/api/v1/group/[groupId]/emails/route.ts deleted file mode 100644 index 664cef5091..0000000000 --- a/apps/web/app/api/v1/group/[groupId]/emails/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { NextResponse } from "next/server"; -import { getGroupEmails } from "@/app/api/user/group/[groupId]/messages/controller"; -import { - groupEmailsQuerySchema, - type GroupEmailsResult, -} from "@/app/api/v1/group/[groupId]/emails/validation"; -import { withError } from "@/utils/middleware"; -import { validateApiKeyAndGetEmailProvider } from "@/utils/api-auth"; -import { getEmailAccountId } from "@/app/api/v1/helpers"; - -export const GET = withError(async (request, { params }) => { - const { emailProvider, userId, accountId } = - await validateApiKeyAndGetEmailProvider(request); - - const { groupId } = await params; - if (!groupId) - return NextResponse.json({ error: "Missing groupId" }, { status: 400 }); - - const { searchParams } = new URL(request.url); - const queryResult = groupEmailsQuerySchema.safeParse( - Object.fromEntries(searchParams), - ); - - if (!queryResult.success) { - return NextResponse.json( - { error: "Invalid query parameters" }, - { status: 400 }, - ); - } - - const { pageToken, from, to, email } = queryResult.data; - - const emailAccountId = await getEmailAccountId({ - email, - accountId, - userId, - }); - - if (!emailAccountId) { - return NextResponse.json( - { error: "Email account not found" }, - { status: 400 }, - ); - } - - const { messages, nextPageToken } = await getGroupEmails({ - provider: emailProvider.name, - groupId, - emailAccountId, - from: from ? new Date(from) : undefined, - to: to ? new Date(to) : undefined, - pageToken, - }); - - const result: GroupEmailsResult = { - messages, - nextPageToken, - }; - - return NextResponse.json(result); -}); diff --git a/apps/web/app/api/v1/group/[groupId]/emails/validation.ts b/apps/web/app/api/v1/group/[groupId]/emails/validation.ts deleted file mode 100644 index 6ee6ab3911..0000000000 --- a/apps/web/app/api/v1/group/[groupId]/emails/validation.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { GroupItemType } from "@/generated/prisma/enums"; -import { z } from "zod"; - -export const groupEmailsQuerySchema = z.object({ - pageToken: z.string().optional(), - from: z.coerce.number().optional(), - to: z.coerce.number().optional(), - email: z.string().optional(), -}); - -export const groupEmailsResponseSchema = z.object({ - messages: z.array( - z.object({ - id: z.string(), - threadId: z.string(), - labelIds: z.array(z.string()).optional(), - snippet: z.string(), - historyId: z.string(), - attachments: z.array(z.object({})).optional(), - inline: z.array(z.object({})), - headers: z.object({}), - textPlain: z.string().optional(), - textHtml: z.string().optional(), - matchingGroupItem: z - .object({ - id: z.string(), - type: z.enum([ - GroupItemType.FROM, - GroupItemType.SUBJECT, - GroupItemType.BODY, - ]), - value: z.string(), - }) - .nullish(), - }), - ), - nextPageToken: z.string().optional(), -}); -export type GroupEmailsResult = z.infer; diff --git a/apps/web/app/api/v1/openapi/route.ts b/apps/web/app/api/v1/openapi/route.ts index 2fb7a5fe63..836e95eaf6 100644 --- a/apps/web/app/api/v1/openapi/route.ts +++ b/apps/web/app/api/v1/openapi/route.ts @@ -5,10 +5,6 @@ import { OpenAPIRegistry, extendZodWithOpenApi, } from "@asteasolutions/zod-to-openapi"; -import { - groupEmailsQuerySchema, - groupEmailsResponseSchema, -} from "@/app/api/v1/group/[groupId]/emails/validation"; import { statsByPeriodQuerySchema, statsByPeriodResponseSchema, @@ -29,33 +25,6 @@ registry.registerComponent("securitySchemes", "ApiKeyAuth", { name: API_KEY_HEADER, }); -registry.registerPath({ - method: "get", - path: "/group/{groupId}/emails", - description: "Get group emails", - security: [{ ApiKeyAuth: [] }], - request: { - params: z.object({ - groupId: z - .string() - .describe( - "You can find the group id by going to `https://www.getinboxzero.com/automation?tab=groups`, clicking `Matching Emails`, and then copying the id from the URL.", - ), - }), - query: groupEmailsQuerySchema, - }, - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: groupEmailsResponseSchema, - }, - }, - }, - }, -}); - registry.registerPath({ method: "get", path: "/stats/by-period", diff --git a/apps/web/components/HoverCard.tsx b/apps/web/components/HoverCard.tsx index 0081254ca9..20458a305a 100644 --- a/apps/web/components/HoverCard.tsx +++ b/apps/web/components/HoverCard.tsx @@ -3,6 +3,7 @@ import { HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; +import { cn } from "@/utils"; export function HoverCard(props: { children: React.ReactNode; @@ -12,7 +13,11 @@ export function HoverCard(props: { return ( {props.children} - + {props.content} diff --git a/apps/web/utils/__mocks__/email-provider.ts b/apps/web/utils/__mocks__/email-provider.ts index f7cf1c1447..cc974d81bd 100644 --- a/apps/web/utils/__mocks__/email-provider.ts +++ b/apps/web/utils/__mocks__/email-provider.ts @@ -112,6 +112,7 @@ export const createMockEmailProvider = ( getMessagesFromSender: vi .fn() .mockResolvedValue({ messages: [], nextPageToken: undefined }), + getThreadsWithParticipant: vi.fn().mockResolvedValue([]), getMessagesBatch: vi.fn().mockResolvedValue([]), getAccessToken: vi.fn().mockReturnValue("mock-token"), checkIfReplySent: vi.fn().mockResolvedValue(false), @@ -130,9 +131,6 @@ export const createMockEmailProvider = ( getThreadsFromSenderWithSubject: vi.fn().mockResolvedValue([]), processHistory: vi.fn().mockResolvedValue(undefined), moveThreadToFolder: vi.fn().mockResolvedValue(undefined), - getMessagesByFields: vi - .fn() - .mockResolvedValue({ messages: [], nextPageToken: undefined }), getOrCreateOutlookFolderIdByName: vi.fn().mockResolvedValue("folder1"), sendEmailWithHtml: vi.fn().mockResolvedValue(undefined), getDrafts: vi.fn().mockResolvedValue([]), diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index e15d400e10..c224bade20 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -14,6 +14,7 @@ import { type CategoryConfig, type CategoryAction, toggleRuleBody, + toggleAllRulesBody, } from "@/utils/actions/rule.validation"; import prisma from "@/utils/prisma"; import { isDuplicateError, isNotFoundError } from "@/utils/prisma-helpers"; @@ -443,6 +444,18 @@ export const toggleRuleAction = actionClient }, ); +export const toggleAllRulesAction = actionClient + .metadata({ name: "toggleAllRules" }) + .inputSchema(toggleAllRulesBody) + .action(async ({ ctx: { emailAccountId }, parsedInput: { enabled } }) => { + await prisma.rule.updateMany({ + where: { emailAccountId }, + data: { enabled }, + }); + + return { success: true }; + }); + async function toggleRule({ ruleId, systemType, diff --git a/apps/web/utils/actions/rule.validation.ts b/apps/web/utils/actions/rule.validation.ts index 0691b4bada..d88f87e24a 100644 --- a/apps/web/utils/actions/rule.validation.ts +++ b/apps/web/utils/actions/rule.validation.ts @@ -266,3 +266,8 @@ export const toggleRuleBody = z .refine((data) => data.ruleId || data.systemType, { message: "Either ruleId or systemType must be provided", }); + +export const toggleAllRulesBody = z.object({ + enabled: z.boolean(), +}); +export type ToggleAllRulesBody = z.infer; diff --git a/apps/web/utils/email.ts b/apps/web/utils/email.ts index d3e291c81a..8c8af81d0b 100644 --- a/apps/web/utils/email.ts +++ b/apps/web/utils/email.ts @@ -112,3 +112,33 @@ export function formatEmailWithName( if (!name || name === address) return address; return `${name} <${address}>`; } + +// Public email providers where we should search by full email address +// For company domains, we search by domain to catch emails from different people at same company +export const PUBLIC_EMAIL_DOMAINS = new Set([ + "gmail.com", + "yahoo.com", + "hotmail.com", + "outlook.com", + "aol.com", + "icloud.com", + "me.com", + "protonmail.com", + "zoho.com", + "yandex.com", + "fastmail.com", + "gmx.com", + "hey.com", +]); + +// Returns the search term to use when checking for previous communications +// For public email providers (gmail, yahoo, etc), returns the full email address +// For company domains, returns just the domain to catch emails from different people at same company +export function getSearchTermForSender(email: string): string { + const domain = extractDomainFromEmail(email); + if (!domain) return email; + + return PUBLIC_EMAIL_DOMAINS.has(domain.toLowerCase()) + ? extractEmailAddress(email) || email + : domain; +} diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 0ac2e1bbca..8dbc714dfe 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -71,7 +71,6 @@ import type { EmailSignature, } from "@/utils/email/types"; import { createScopedLogger, type Logger } from "@/utils/logger"; -import { extractEmailAddress } from "@/utils/email"; import { getGmailSignatures } from "@/utils/gmail/signature-settings"; export class GmailProvider implements EmailProvider { @@ -944,60 +943,18 @@ export class GmailProvider implements EmailProvider { })); } - async getMessagesByFields(options: { - froms?: string[]; - tos?: string[]; - subjects?: string[]; - before?: Date; - after?: Date; - maxResults?: number; - pageToken?: string; - }): Promise<{ - messages: ParsedMessage[]; - nextPageToken?: string; - }> { - const parts: string[] = []; - - const froms = (options.froms || []) - .map((f) => extractEmailAddress(f) || f) - .filter((f) => !!f); - if (froms.length > 0) { - const fromGroup = froms.map((f) => `"${f}"`).join(" OR "); - parts.push(`from:(${fromGroup})`); - } - - const tos = (options.tos || []) - .map((t) => extractEmailAddress(t) || t) - .filter((t) => !!t); - if (tos.length > 0) { - const toGroup = tos.map((t) => `"${t}"`).join(" OR "); - parts.push(`to:(${toGroup})`); - } - - const subjects = (options.subjects || []).filter((s) => !!s); - if (subjects.length > 0) { - const subjectGroup = subjects.map((s) => `"${s}"`).join(" OR "); - parts.push(`subject:(${subjectGroup})`); - } - - const query = parts.join(" ") || undefined; - - return this.getMessagesWithPagination({ - query, - maxResults: options.maxResults, - pageToken: options.pageToken, - before: options.before, - after: options.after, - }); - } - async getDrafts(options?: { maxResults?: number }): Promise { - const response = await this.getMessagesWithPagination({ - query: "in:draft", + const response = await this.client.users.drafts.list({ + userId: "me", maxResults: options?.maxResults || 50, }); - return response.messages; + const drafts = response.data.drafts || []; + const messagePromises = drafts + .filter((draft) => draft.message?.id) + .map((draft) => this.getMessage(draft.message!.id!)); + + return Promise.all(messagePromises); } async getMessagesBatch(messageIds: string[]): Promise { @@ -1217,7 +1174,7 @@ export class GmailProvider implements EmailProvider { date: Date; messageId: string; }): Promise { - return hasPreviousCommunicationsWithSenderOrDomain(this, options); + return hasPreviousCommunicationsWithSenderOrDomain(this.client, options); } async getThreadsFromSenderWithSubject( diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index ad31a0d911..39a6691996 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -59,7 +59,7 @@ import type { } from "@/utils/email/types"; import { unwatchOutlook, watchOutlook } from "@/utils/outlook/watch"; import { escapeODataString } from "@/utils/outlook/odata-escape"; -import { extractEmailAddress } from "@/utils/email"; +import { extractEmailAddress, getSearchTermForSender } from "@/utils/email"; import { getOrCreateOutlookFolderIdByName, getOutlookFolderTree, @@ -805,9 +805,30 @@ export class OutlookProvider implements EmailProvider { query: options.query, }); - // For Outlook, separate search queries from date filters - // Microsoft Graph API handles these differently - const originalQuery = options.query || ""; + // IMPORTANT: This is intentionally lossy! + // Gmail-style prefixes like "subject:" can't be translated to Microsoft Graph because: + // 1. $filter with contains(subject, ...) can't be combined with $search or date filters + // (causes "InefficientFilter" error) + // 2. $search doesn't support field-specific syntax like "subject:term" + // + // We strip the prefixes and use plain $search which searches subject AND body. + // This is broader than intended but still finds relevant messages. + // If subject-specific search is needed in the future, add a dedicated method + // that uses only $filter without $search or date filters. + function stripGmailPrefixes(query: string): string { + return query + .replace(/\b(subject|from|to|label):(?:"[^"]*"|\S+)/gi, (match) => { + // Extract the value without the prefix for searching + const colonIndex = match.indexOf(":"); + const value = match.slice(colonIndex + 1); + // Remove quotes if present + return value.replace(/^"|"$/g, ""); + }) + .replace(/\s+/g, " ") + .trim(); + } + + const searchQuery = stripGmailPrefixes(options.query || ""); // Build date filter for Outlook (no quotes for DateTimeOffset comparison) const dateFilters: string[] = []; @@ -818,19 +839,19 @@ export class OutlookProvider implements EmailProvider { dateFilters.push(`receivedDateTime gt ${options.after.toISOString()}`); } - this.logger.info("Calling queryBatchMessages with separated parameters", { + this.logger.info("Calling queryBatchMessages", { dateFilters, maxResults: options.maxResults || 20, pageToken: options.pageToken, }); this.logger.trace("Search query", { - searchQuery: originalQuery.trim() || undefined, + searchQuery: searchQuery || undefined, }); // Don't pass folderId - let the API return all folders except Junk/Deleted (auto-excluded) // Drafts are filtered out in convertMessages const response = await queryBatchMessages(this.client, { - searchQuery: originalQuery.trim() || undefined, + searchQuery: searchQuery || undefined, dateFilters, maxResults: options.maxResults || 20, pageToken: options.pageToken, @@ -947,76 +968,15 @@ export class OutlookProvider implements EmailProvider { return threads; } - async getMessagesByFields(options: { - froms?: string[]; - tos?: string[]; - subjects?: string[]; - before?: Date; - after?: Date; - maxResults?: number; - pageToken?: string; - }): Promise<{ - messages: ParsedMessage[]; - nextPageToken?: string; - }> { - const filters: string[] = []; - - const froms = (options.froms || []) - .map((f) => extractEmailAddress(f) || f) - .filter((f) => !!f); - if (froms.length > 0) { - const fromFilter = froms - .map((f) => `from/emailAddress/address eq '${escapeODataString(f)}'`) - .join(" or "); - filters.push(`(${fromFilter})`); - } - - const tos = (options.tos || []) - .map((t) => extractEmailAddress(t) || t) - .filter((t) => !!t); - if (tos.length > 0) { - const toFilter = tos - .map( - (t) => - `toRecipients/any(r: r/emailAddress/address eq '${escapeODataString(t)}')`, - ) - .join(" or "); - filters.push(`(${toFilter})`); - } - - const subjects = (options.subjects || []).filter((s) => !!s); - if (subjects.length > 0) { - // Use contains to match subject substrings; exact eq would be too strict - const subjectFilter = subjects - .map((s) => `contains(subject,'${escapeODataString(s)}')`) - .join(" or "); - filters.push(`(${subjectFilter})`); - } - - // Build date filters - const dateFilters: string[] = []; - if (options.before) { - dateFilters.push(`receivedDateTime lt ${options.before.toISOString()}`); - } - if (options.after) { - dateFilters.push(`receivedDateTime gt ${options.after.toISOString()}`); - } - - // Use queryMessagesWithFilters (OData $filter) instead of getMessagesWithPagination (KQL $search) - return queryMessagesWithFilters(this.client, { - filters, - dateFilters, - maxResults: options.maxResults, - pageToken: options.pageToken, - }); - } - async getDrafts(options?: { maxResults?: number }): Promise { - const response = await this.getMessagesWithPagination({ - query: "isDraft eq true", - maxResults: options?.maxResults || 50, - }); - return response.messages; + const response: { value: Message[] } = await this.client + .getClient() + .api("/me/mailFolders/drafts/messages") + .select(MESSAGE_SELECT_FIELDS) + .top(options?.maxResults || 50) + .get(); + + return response.value.map((msg) => convertMessage(msg)); } async getMessagesBatch(messageIds: string[]): Promise { @@ -1319,19 +1279,78 @@ export class OutlookProvider implements EmailProvider { messageId: string; }): Promise { try { - const escapedFrom = escapeODataString(options.from); + // Use shared logic: for public domains search by full email, for company domains search by domain + const searchTerm = getSearchTermForSender(options.from); + const isFullEmail = searchTerm.includes("@"); + const dateString = options.date.toISOString(); - // Split into two parallel queries to avoid OData "invalid nodes" error - // when combining any() lambda with other filters. - const receivedFilter = `from/emailAddress/address eq '${escapedFrom}' and receivedDateTime lt ${dateString}`; + // For domain matching, use $search instead of $filter since endsWith has limitations + // For exact email matching, use $filter with eq (case-insensitive for email addresses) + if (!isFullEmail) { + // Domain-based search - use $search for both sent and received + const escapedKqlDomain = searchTerm + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"'); + + const [sentResponse, receivedResponse] = await Promise.all([ + this.client + .getClient() + .api("/me/messages") + .search(`"to:@${escapedKqlDomain}"`) + .top(5) + .select("id,sentDateTime") + .get() + .catch((error) => { + this.logger.error("Error checking sent messages (domain)", { + error, + }); + return { value: [] }; + }), + + this.client + .getClient() + .api("/me/messages") + .search(`"from:@${escapedKqlDomain}"`) + .top(5) + .select("id,receivedDateTime") + .get() + .catch((error) => { + this.logger.error("Error checking received messages (domain)", { + error, + }); + return { value: [] }; + }), + ]); + + // Filter by date since $search doesn't support date filtering well + const validSentMessages = (sentResponse.value || []).filter( + (msg: Message) => { + if (!msg.sentDateTime) return false; + return new Date(msg.sentDateTime) < options.date; + }, + ); + + const validReceivedMessages = (receivedResponse.value || []).filter( + (msg: Message) => { + if (!msg.receivedDateTime) return false; + return new Date(msg.receivedDateTime) < options.date; + }, + ); + + const messages = [...validSentMessages, ...validReceivedMessages]; + return messages.some((message) => message.id !== options.messageId); + } + + // Full email search - use $filter for received, $search for sent + const escapedSearchTerm = escapeODataString(searchTerm); + const receivedFilter = `from/emailAddress/address eq '${escapedSearchTerm}' and receivedDateTime lt ${dateString}`; // Use $search for sent messages as $filter on toRecipients is unreliable - // We escape double quotes for the KQL search query - const escapedSearchFrom = options.from + const escapedKqlSearchTerm = searchTerm .replace(/\\/g, "\\\\") .replace(/"/g, '\\"'); - const sentSearch = `"to:${escapedSearchFrom}"`; + const sentSearch = `"to:${escapedKqlSearchTerm}"`; const [sentResponse, receivedResponse] = await Promise.all([ this.client diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 1a38e266d1..1699ecb049 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -55,18 +55,6 @@ export interface EmailProvider { getMessageByRfc822MessageId( rfc822MessageId: string, ): Promise; - getMessagesByFields(options: { - froms?: string[]; - tos?: string[]; - subjects?: string[]; - before?: Date; - after?: Date; - maxResults?: number; - pageToken?: string; - }): Promise<{ - messages: ParsedMessage[]; - nextPageToken?: string; - }>; getSentMessages(maxResults?: number): Promise; getInboxMessages(maxResults?: number): Promise; getSentMessageIds(options: { diff --git a/apps/web/utils/gmail/label.ts b/apps/web/utils/gmail/label.ts index 3200b2cc37..8231fd3f31 100644 --- a/apps/web/utils/gmail/label.ts +++ b/apps/web/utils/gmail/label.ts @@ -239,7 +239,7 @@ export async function createLabel({ ); return createdLabel.data; } catch (error) { - const errorMessage: string | undefined = (error as any).message; + const { errorMessage } = extractErrorInfo(error); if (errorMessage?.includes("Label name exists or conflicts")) { logger.warn("Label already exists", { name }); diff --git a/apps/web/utils/gmail/message.ts b/apps/web/utils/gmail/message.ts index c8665ad6c6..f295cb52af 100644 --- a/apps/web/utils/gmail/message.ts +++ b/apps/web/utils/gmail/message.ts @@ -8,14 +8,13 @@ import { isDefined, } from "@/utils/types"; import { getBatch } from "@/utils/gmail/batch"; -import { extractDomainFromEmail } from "@/utils/email"; +import { getSearchTermForSender } from "@/utils/email"; import { createScopedLogger } from "@/utils/logger"; import { sleep } from "@/utils/sleep"; import { getAccessTokenFromClient } from "@/utils/gmail/client"; import { GmailLabel } from "@/utils/gmail/label"; import { isIgnoredSender } from "@/utils/filter-ignored-senders"; import parse from "gmail-api-parse-message"; -import type { EmailProvider } from "@/utils/email/types"; import { withGmailRetry } from "@/utils/gmail/retry"; const logger = createScopedLogger("gmail/message"); @@ -171,39 +170,28 @@ export async function getMessagesBatch({ } async function findPreviousEmailsWithSender( - client: EmailProvider, + gmail: gmail_v1.Gmail, options: { sender: string; dateInSeconds: number; }, ) { - const beforeDate = new Date(options.dateInSeconds * 1000); - const [incomingEmails, outgoingEmails] = await Promise.all([ - client.getMessagesWithPagination({ - query: `from:${options.sender}`, - maxResults: 2, - before: beforeDate, - }), - client.getMessagesWithPagination({ - query: `to:${options.sender}`, - maxResults: 2, - before: beforeDate, - }), - ]); + const beforeTimestamp = Math.floor(options.dateInSeconds); + const query = `(from:${options.sender} OR to:${options.sender}) before:${beforeTimestamp}`; - const allMessages = [ - ...(incomingEmails.messages || []), - ...(outgoingEmails.messages || []), - ]; + const response = await getMessages(gmail, { + query, + maxResults: 4, + }); - return allMessages; + return response.messages || []; } -export async function hasPreviousCommunicationWithSender( - client: EmailProvider, +async function hasPreviousCommunicationWithSender( + gmail: gmail_v1.Gmail, options: { from: string; date: Date; messageId: string }, ) { - const previousEmails = await findPreviousEmailsWithSender(client, { + const previousEmails = await findPreviousEmailsWithSender(gmail, { sender: options.from, dateInSeconds: +new Date(options.date) / 1000, }); @@ -215,36 +203,13 @@ export async function hasPreviousCommunicationWithSender( return hasPreviousEmail; } -const PUBLIC_DOMAINS = new Set([ - "gmail.com", - "yahoo.com", - "hotmail.com", - "outlook.com", - "aol.com", - "icloud.com", - "@me.com", - "protonmail.com", - "zoho.com", - "yandex.com", - "fastmail.com", - "gmx.com", - "@hey.com", -]); - export async function hasPreviousCommunicationsWithSenderOrDomain( - client: EmailProvider, + gmail: gmail_v1.Gmail, options: { from: string; date: Date; messageId: string }, ) { - const domain = extractDomainFromEmail(options.from); - if (!domain) return hasPreviousCommunicationWithSender(client, options); - - // For public email providers (gmail, yahoo, etc), search by full email address - // For company domains, search by domain to catch emails from different people at same company - const searchTerm = PUBLIC_DOMAINS.has(domain.toLowerCase()) - ? options.from - : domain; + const searchTerm = getSearchTermForSender(options.from); - return hasPreviousCommunicationWithSender(client, { + return hasPreviousCommunicationWithSender(gmail, { ...options, from: searchTerm, }); diff --git a/apps/web/utils/logger.ts b/apps/web/utils/logger.ts index dcad499dc8..b238f91b07 100644 --- a/apps/web/utils/logger.ts +++ b/apps/web/utils/logger.ts @@ -252,7 +252,11 @@ function hashSensitiveFields(obj: T, depth = 0): T { processed[key] = !!value; } // Redact content fields in production (unless debug logs enabled) - else if (CONTENT_FIELD_NAMES.has(key) && env.NODE_ENV === "production" && !env.ENABLE_DEBUG_LOGS) { + else if ( + CONTENT_FIELD_NAMES.has(key) && + env.NODE_ENV === "production" && + !env.ENABLE_DEBUG_LOGS + ) { processed[key] = !!value; } // Hash emails in production only (server-side only) diff --git a/apps/web/utils/outlook/label.ts b/apps/web/utils/outlook/label.ts index f4f28be41f..26070d27c6 100644 --- a/apps/web/utils/outlook/label.ts +++ b/apps/web/utils/outlook/label.ts @@ -2,7 +2,7 @@ import type { OutlookClient } from "@/utils/outlook/client"; import { createScopedLogger } from "@/utils/logger"; import { publishArchive, type TinybirdEmailAction } from "@inboxzero/tinybird"; import { WELL_KNOWN_FOLDERS } from "./message"; -import { withOutlookRetry } from "@/utils/outlook/retry"; +import { extractErrorInfo, withOutlookRetry } from "@/utils/outlook/retry"; import { inboxZeroLabels, type InboxZeroLabel } from "@/utils/label"; import type { @@ -101,8 +101,8 @@ export async function createLabel({ ); return response; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; + let { errorMessage } = extractErrorInfo(error); + if (!errorMessage) errorMessage = (error as any)?.message || "Unknown error"; if ( errorMessage.includes("already exists") || errorMessage.includes("conflict with the current state") diff --git a/version.txt b/version.txt index fb7994a2e3..21348d8e16 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.23.5 +v2.23.6