diff --git a/apps/web/__tests__/ai-choose-rule.test.ts b/apps/web/__tests__/ai-choose-rule.test.ts index 7b6e94d3b6..3a7dc4a1d5 100644 --- a/apps/web/__tests__/ai-choose-rule.test.ts +++ b/apps/web/__tests__/ai-choose-rule.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test, vi } from "vitest"; import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; import { ActionType } from "@prisma/client"; -import { defaultReplyTrackerInstructions } from "@/utils/reply-tracker/consts"; import { getEmail, getEmailAccount, getRule } from "@/__tests__/helpers"; // pnpm test-ai ai-choose-rule @@ -70,6 +69,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { type: ActionType.REPLY, ruleId: "ruleId", label: null, + labelId: null, subject: null, content: "{{Write a joke}}", to: null, @@ -104,7 +104,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { const legal = getRule( "Match emails containing legal documents or contracts", ); - const requiresResponse = getRule(defaultReplyTrackerInstructions); + const requiresResponse = getRule("Match emails requiring a response"); const productUpdates = getRule( "Match emails about product updates or feature announcements", ); diff --git a/apps/web/__tests__/ai-detect-recurring-pattern.test.ts b/apps/web/__tests__/ai-detect-recurring-pattern.test.ts index ee2b8d6f32..220d257acd 100644 --- a/apps/web/__tests__/ai-detect-recurring-pattern.test.ts +++ b/apps/web/__tests__/ai-detect-recurring-pattern.test.ts @@ -2,7 +2,8 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; import { aiDetectRecurringPattern } from "@/utils/ai/choose-rule/ai-detect-recurring-pattern"; import type { EmailForLLM } from "@/utils/types"; -import { RuleName } from "@/utils/rule/consts"; +import { getRuleName, getRuleConfig } from "@/utils/rule/consts"; +import { SystemType } from "@prisma/client"; import { getEmailAccount } from "@/__tests__/helpers"; // Run with: pnpm test-ai ai-detect-recurring-pattern @@ -33,45 +34,18 @@ describe.runIf(isAiTest)( }); function getRealisticRules() { - return [ - { - name: "To Reply", - instructions: `Apply this to emails needing my direct response. Exclude: -- All automated notifications (LinkedIn, Facebook, GitHub, social media, marketing) -- System emails (order confirmations, calendar invites) - -Only flag when someone: -- Asks me a direct question -- Requests information or action -- Needs my specific input -- Follows up on a conversation`, - }, - { - name: RuleName.Newsletter, - instructions: - "Newsletters: Regular content from publications, blogs, or services I've subscribed to", - }, - { - name: RuleName.Marketing, - instructions: - "Marketing: Promotional emails about products, services, sales, or offers", - }, - { - name: RuleName.Calendar, - instructions: - "Calendar: Any email related to scheduling, meeting invites, or calendar notifications", - }, - { - name: RuleName.Receipt, - instructions: - "Receipts: Purchase confirmations, payment receipts, transaction records or invoices", - }, - { - name: RuleName.Notification, - instructions: - "Notifications: Alerts, status updates, or system messages", - }, - ]; + return Object.values([ + getRuleConfig(SystemType.TO_REPLY), + getRuleConfig(SystemType.AWAITING_REPLY), + getRuleConfig(SystemType.FYI), + getRuleConfig(SystemType.ACTIONED), + getRuleConfig(SystemType.MARKETING), + getRuleConfig(SystemType.NEWSLETTER), + getRuleConfig(SystemType.RECEIPT), + getRuleConfig(SystemType.CALENDAR), + getRuleConfig(SystemType.NOTIFICATION), + getRuleConfig(SystemType.COLD_EMAIL), + ]); } function getNewsletterEmails(): EmailForLLM[] { @@ -241,7 +215,7 @@ Only flag when someone: console.debug("Newsletter pattern detection result:", result); - expect(result?.matchedRule).toBe(RuleName.Newsletter); + expect(result?.matchedRule).toBe(getRuleName(SystemType.NEWSLETTER)); expect(result?.explanation).toBeDefined(); }); @@ -254,7 +228,7 @@ Only flag when someone: console.debug("Receipt pattern detection result:", result); - expect(result?.matchedRule).toBe(RuleName.Receipt); + expect(result?.matchedRule).toBe(getRuleName(SystemType.RECEIPT)); expect(result?.explanation).toBeDefined(); }); @@ -267,7 +241,7 @@ Only flag when someone: console.debug("Calendar pattern detection result:", result); - expect(result?.matchedRule).toBe(RuleName.Calendar); + expect(result?.matchedRule).toBe(getRuleName(SystemType.CALENDAR)); expect(result?.explanation).toBeDefined(); }); @@ -306,7 +280,8 @@ Only flag when someone: console.debug("Same sender different content result:", result); expect( - result === null || result?.matchedRule === RuleName.Notification, + result === null || + result?.matchedRule === getRuleName(SystemType.NOTIFICATION), ).toBeTruthy(); }); }, diff --git a/apps/web/__tests__/determine-thread-status.test.ts b/apps/web/__tests__/determine-thread-status.test.ts new file mode 100644 index 0000000000..67cdaf0a06 --- /dev/null +++ b/apps/web/__tests__/determine-thread-status.test.ts @@ -0,0 +1,467 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { aiDetermineThreadStatus } from "@/utils/ai/reply/determine-thread-status"; +import { getEmailAccount, getEmail } from "@/__tests__/helpers"; + +// Run with: pnpm test-ai determine-thread-status + +vi.mock("server-only", () => ({})); + +const TIMEOUT = 15_000; + +// Skip tests unless explicitly running AI tests +const isAiTest = process.env.RUN_AI_TESTS === "true"; + +describe.runIf(isAiTest)("aiDetermineThreadStatus", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Helper for multi-person thread tests + const getProjectThread = () => [ + getEmail({ + from: "bob@company.com", + to: "alice@company.com, carol@company.com", + subject: "Re: Q4 Project Timeline", + content: "Alice, can you send me the final design mockups by Friday?", + }), + getEmail({ + from: "alice@company.com", + to: "bob@company.com, carol@company.com", + subject: "Re: Q4 Project Timeline", + content: "I'm working on them. Should have v1 by Thursday.", + }), + getEmail({ + from: "bob@company.com", + to: "alice@company.com, carol@company.com", + subject: "Re: Q4 Project Timeline", + content: "Great! Carol, can you check the API endpoints?", + }), + getEmail({ + from: "carol@company.com", + to: "bob@company.com, alice@company.com", + subject: "Re: Q4 Project Timeline", + content: "Sure, I'll review them today and let you know.", + }), + getEmail({ + from: "alice@company.com", + to: "bob@company.com, carol@company.com", + subject: "Re: Q4 Project Timeline", + content: + "Bob, quick question - do you need mobile mockups too or just desktop?", + }), + getEmail({ + from: "bob@company.com", + to: "alice@company.com, carol@company.com", + subject: "Re: Q4 Project Timeline", + content: + "Yes please include mobile mockups. That would be really helpful.", + }), + ]; + + test( + "identifies TO_REPLY when receiving a question", + async () => { + const emailAccount = getEmailAccount(); + const latestMessage = getEmail({ + from: "sender@example.com", + to: emailAccount.email, + subject: "Quick question", + content: "Can you send me the Q3 report?", + }); + + const result = await aiDetermineThreadStatus({ + emailAccount, + threadMessages: [latestMessage], + }); + + console.debug("Result:", result); + expect(result.status).toBe("TO_REPLY"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "identifies FYI for informational emails", + async () => { + const emailAccount = getEmailAccount(); + const latestMessage = getEmail({ + from: "sender@example.com", + to: emailAccount.email, + subject: "Update", + content: "FYI, the meeting time has changed to 3pm.", + }); + + const result = await aiDetermineThreadStatus({ + emailAccount, + threadMessages: [latestMessage], + }); + + console.debug("Result:", result); + expect(result.status).toBe("FYI"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "identifies AWAITING_REPLY after sending a question", + async () => { + const emailAccount = getEmailAccount(); + const latestMessage = getEmail({ + from: emailAccount.email, + to: "recipient@example.com", + subject: "Report request", + content: "Could you send me the Q3 report by Friday?", + }); + + const result = await aiDetermineThreadStatus({ + emailAccount, + threadMessages: [latestMessage], + }); + + console.debug("Result:", result); + expect(result.status).toBe("AWAITING_REPLY"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "identifies AWAITING_REPLY when someone says they'll get back to you", + async () => { + const emailAccount = getEmailAccount(); + const messages = [ + getEmail({ + from: "recipient@example.com", + to: emailAccount.email, + subject: "Re: Report request", + content: "I'll get this for you tomorrow.", + }), + getEmail({ + from: emailAccount.email, + to: "recipient@example.com", + subject: "Report request", + content: "Could you send me the Q3 report?", + }), + ]; + + const result = await aiDetermineThreadStatus({ + emailAccount, + threadMessages: messages, + }); + + console.debug("Result:", result); + expect(result.status).toBe("AWAITING_REPLY"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "identifies ACTIONED when conversation is complete", + async () => { + const emailAccount = getEmailAccount(); + const messages = [ + getEmail({ + from: "recipient@example.com", + to: emailAccount.email, + subject: "Re: Question", + content: "Perfect, thanks!", + }), + getEmail({ + from: emailAccount.email, + to: "recipient@example.com", + subject: "Re: Question", + content: "Here it is, attached.", + }), + getEmail({ + from: "recipient@example.com", + to: emailAccount.email, + subject: "Question", + content: "Can you send me the report?", + }), + ]; + + const result = await aiDetermineThreadStatus({ + emailAccount, + threadMessages: messages, + }); + + console.debug("Result:", result); + expect(result.status).toBe("ACTIONED"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "identifies TO_REPLY even when latest message is FYI but has unanswered question", + async () => { + const emailAccount = getEmailAccount(); + const messages = [ + getEmail({ + from: "sender@example.com", + to: emailAccount.email, + subject: "Re: Two things", + content: "Also, FYI the meeting moved to 3pm.", + }), + getEmail({ + from: "sender@example.com", + to: emailAccount.email, + subject: "Two things", + content: "Can you send me the Q3 report?", + }), + ]; + + const result = await aiDetermineThreadStatus({ + emailAccount, + threadMessages: messages, + }); + + console.debug("Result:", result); + expect(result.status).toBe("TO_REPLY"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "identifies ACTIONED when user sends final message", + async () => { + const emailAccount = getEmailAccount(); + const messages = [ + getEmail({ + from: emailAccount.email, + to: "recipient@example.com", + subject: "Re: Quick question", + content: "Yes, 3pm works. See you then.", + }), + getEmail({ + from: "recipient@example.com", + to: emailAccount.email, + subject: "Quick question", + content: "Can you confirm the meeting time?", + }), + ]; + + const result = await aiDetermineThreadStatus({ + emailAccount, + threadMessages: messages, + }); + + console.debug("Result:", result); + expect(["ACTIONED", "AWAITING_REPLY"]).toContain(result.status); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "handles long thread context with multiple back-and-forth", + async () => { + const emailAccount = getEmailAccount(); + const messages = [ + getEmail({ + from: "sender@example.com", + to: emailAccount.email, + subject: "Project discussion", + content: "What do you think about the new design?", + }), + getEmail({ + from: emailAccount.email, + to: "sender@example.com", + subject: "Re: Project discussion", + content: + "I like it overall, but have concerns about the color scheme.", + }), + getEmail({ + from: "sender@example.com", + to: emailAccount.email, + subject: "Re: Project discussion", + content: "Good point. What colors would you suggest?", + }), + ]; + + const result = await aiDetermineThreadStatus({ + emailAccount, + threadMessages: messages.reverse(), // Most recent first + }); + + console.debug("Result:", result); + expect(result.status).toBe("TO_REPLY"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "identifies FYI for automated notifications", + async () => { + const emailAccount = getEmailAccount(); + const latestMessage = getEmail({ + from: "notifications@github.com", + to: emailAccount.email, + subject: "[GitHub] Pull request merged", + content: "Your pull request #123 has been merged into main.", + }); + + const result = await aiDetermineThreadStatus({ + emailAccount, + threadMessages: [latestMessage], + }); + + console.debug("Result:", result); + expect(result.status).toBe("FYI"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "handles complex multi-person thread - Alice's perspective (TO_REPLY)", + async () => { + const alice = getEmailAccount({ email: "alice@company.com" }); + + const result = await aiDetermineThreadStatus({ + emailAccount: alice, + threadMessages: getProjectThread(), + }); + + console.debug("Alice's perspective:", result); + // Alice asked about mobile mockups, Bob said "Yes please include" - Alice should acknowledge + expect(result.status).toBe("TO_REPLY"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "handles complex multi-person thread - Bob's perspective (AWAITING_REPLY)", + async () => { + const bob = getEmailAccount({ email: "bob@company.com" }); + + const result = await aiDetermineThreadStatus({ + emailAccount: bob, + threadMessages: getProjectThread(), + }); + + console.debug("Bob's perspective:", result); + // Bob is waiting for Alice to deliver mockups and Carol to report on API review + expect(result.status).toBe("AWAITING_REPLY"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "handles complex multi-person thread - Carol's perspective (AWAITING_REPLY)", + async () => { + const carol = getEmailAccount({ email: "carol@company.com" }); + + const result = await aiDetermineThreadStatus({ + emailAccount: carol, + threadMessages: getProjectThread(), + }); + + console.debug("Carol's perspective:", result); + // Carol committed to reviewing API endpoints and reporting back + expect(result.status).toBe("AWAITING_REPLY"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + // Helper for lunch scheduling thread tests + const getLunchSchedulingThread = ( + person1Email: string, + person2Email: string, + ) => [ + getEmail({ + from: person1Email, + to: person2Email, + subject: "Re: free for lunch tomorrow?", + content: "I'll get back to you soon!", + }), + getEmail({ + from: person2Email, + to: person1Email, + subject: "Re: free for lunch tomorrow?", + content: "Ok. 5pm work tomorrow?", + }), + getEmail({ + from: person1Email, + to: person2Email, + subject: "Re: free for lunch tomorrow?", + content: "Sounds good, let me know.", + }), + getEmail({ + from: person2Email, + to: person1Email, + subject: "Re: free for lunch tomorrow?", + content: "Let me get back to you about that soon!", + }), + getEmail({ + from: person1Email, + to: person2Email, + subject: "Re: free for lunch tomorrow?", + content: + "Great, does 12pm work for you? Let me know and I can book a table somewhere.", + }), + getEmail({ + from: person2Email, + to: person1Email, + subject: "Re: free for lunch tomorrow?", + content: + "Yes, I'd love to. I'm free from 11 am to 1 pm tomorrow, would any time then work for you?", + }), + getEmail({ + from: person1Email, + to: person2Email, + subject: "free for lunch tomorrow?", + content: "Lmk if you're free", + }), + ]; + + test( + "identifies AWAITING_REPLY when other person says they'll get back to you (lunch scheduling)", + async () => { + const alice = getEmailAccount({ email: "alice@gmail.com" }); + + const result = await aiDetermineThreadStatus({ + emailAccount: alice, + threadMessages: getLunchSchedulingThread( + "oliver@example.com", + alice.email, + ), + }); + + console.debug("Result:", result); + // Oliver said "I'll get back to you soon!" so Alice should be awaiting his reply + expect(result.status).toBe("AWAITING_REPLY"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "identifies TO_REPLY when user says they'll get back to someone (lunch scheduling - Oliver's perspective)", + async () => { + const oliver = getEmailAccount({ email: "oliver@example.com" }); + + const result = await aiDetermineThreadStatus({ + emailAccount: oliver, + threadMessages: getLunchSchedulingThread( + oliver.email, + "alice@gmail.com", + ), + }); + + console.debug("Result:", result); + // Oliver committed to getting back to Alice about the 5pm time, so he needs to reply + expect(result.status).toBe("TO_REPLY"); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); +}); diff --git a/apps/web/__tests__/e2e/README.md b/apps/web/__tests__/e2e/README.md new file mode 100644 index 0000000000..2b0f27bea6 --- /dev/null +++ b/apps/web/__tests__/e2e/README.md @@ -0,0 +1,90 @@ +# E2E Tests + +End-to-end integration tests for Inbox Zero AI that test against real email provider APIs. + +## Structure + +``` +e2e/ +├── labeling/ # Email labeling/category operations +│ ├── microsoft-labeling.test.ts # Outlook category CRUD, apply/remove, lifecycle +│ └── google-labeling.test.ts # Gmail label CRUD, apply/remove, lifecycle +├── gmail-operations.test.ts # Gmail webhooks, history processing +├── outlook-operations.test.ts # Outlook webhooks, threads, search, senders +└── README.md # This file +``` + +## Running E2E Tests + +E2E tests are skipped by default. To run them: + +```bash +# Run all E2E tests +pnpm test-e2e + +# Run specific test suite +pnpm test-e2e microsoft-labeling +pnpm test-e2e google-labeling +pnpm test-e2e gmail-operations +pnpm test-e2e outlook-operations + +# Run specific test within a suite +pnpm test-e2e microsoft-labeling -t "should apply and remove label" +``` + +## Setup + +### Microsoft/Outlook Tests + +Set these environment variables: + +```bash +export TEST_OUTLOOK_EMAIL=your@outlook.com +export TEST_OUTLOOK_MESSAGE_ID=AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA... +export TEST_CONVERSATION_ID=AQQkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoAEABuo... +``` + +### Google/Gmail Tests + +Set these environment variables: + +```bash +export TEST_GMAIL_EMAIL=your@gmail.com +export TEST_GMAIL_MESSAGE_ID=18d1c2f3e4b5a678 +export TEST_GMAIL_THREAD_ID=18d1c2f3e4b5a678 +``` + +## Test Approach + +All E2E tests follow a **clean slate approach**: + +1. **Setup**: Create test data (labels, etc.) +2. **Action**: Perform the operation being tested +3. **Verify**: Check that the state is correct +4. **Cleanup**: Remove test data and restore original state + +This ensures: +- Tests are idempotent and can be run multiple times +- Tests don't pollute the test account +- State verification at each step catches issues early + +## Getting Test IDs + +### For Outlook + +1. Run the app and trigger a webhook +2. Check the logs for message IDs and conversation IDs +3. Or use the Outlook API explorer: + +### For Gmail + +1. Use the Gmail API explorer: +2. Or check your app logs when processing emails + +## Notes + +- These tests use real API calls and count against your quota +- Tests may take 30+ seconds due to API rate limits +- Make sure your test account has proper permissions +- **Microsoft Graph**: All API requests use immutable IDs (`Prefer: IdType="ImmutableId"` header) to ensure message IDs remain stable across operations + diff --git a/apps/web/__tests__/gmail-operations.test.ts b/apps/web/__tests__/e2e/gmail-operations.test.ts similarity index 97% rename from apps/web/__tests__/gmail-operations.test.ts rename to apps/web/__tests__/e2e/gmail-operations.test.ts index 05d270eb4f..58bb02d724 100644 --- a/apps/web/__tests__/gmail-operations.test.ts +++ b/apps/web/__tests__/e2e/gmail-operations.test.ts @@ -1,13 +1,13 @@ /** - * Manual integration tests for Gmail operations + * E2E tests for Gmail operations (webhooks and general operations) + * + * Usage: + * pnpm test-e2e gmail-operations + * pnpm test-e2e gmail-operations -t "webhook" # Run specific test * * Setup: * 1. Set TEST_GMAIL_EMAIL env var to your Gmail email * 2. Set TEST_GMAIL_MESSAGE_ID with a real messageId from your logs - * - * Usage: - * pnpm test-e2e gmail-operations - * pnpm test-e2e gmail-operations -t "webhook" # Run specific test */ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; diff --git a/apps/web/__tests__/e2e/labeling/google-labeling.test.ts b/apps/web/__tests__/e2e/labeling/google-labeling.test.ts new file mode 100644 index 0000000000..00f990d4a8 --- /dev/null +++ b/apps/web/__tests__/e2e/labeling/google-labeling.test.ts @@ -0,0 +1,491 @@ +/** + * E2E tests for Google Gmail labeling operations + * + * Usage: + * pnpm test-e2e google-labeling + * pnpm test-e2e google-labeling -t "should apply and remove label" # Run specific test + * + * Setup: + * 1. Set TEST_GMAIL_EMAIL env var to your Gmail email + * 2. Set TEST_GMAIL_MESSAGE_ID with a real messageId from your logs + * 3. Set TEST_GMAIL_THREAD_ID with a real threadId from your logs + * + * These tests follow a clean slate approach: + * - Create test labels + * - Apply labels and verify + * - Remove labels and verify + * - Clean up all test labels at the end + */ + +import { describe, test, expect, beforeAll, afterAll, vi } from "vitest"; +import prisma from "@/utils/prisma"; +import { createEmailProvider } from "@/utils/email/provider"; +import type { GmailProvider } from "@/utils/email/google"; + +// ============================================ +// TEST DATA - SET VIA ENVIRONMENT VARIABLES +// ============================================ +const RUN_E2E_TESTS = process.env.RUN_E2E_TESTS; +const TEST_GMAIL_EMAIL = process.env.TEST_GMAIL_EMAIL; +const TEST_GMAIL_THREAD_ID = + process.env.TEST_GMAIL_THREAD_ID || "18d1c2f3e4b5a678"; +const TEST_GMAIL_MESSAGE_ID = + process.env.TEST_GMAIL_MESSAGE_ID || "18d1c2f3e4b5a678"; + +vi.mock("server-only", () => ({})); + +describe.skipIf(!RUN_E2E_TESTS)("Google Gmail Labeling E2E Tests", () => { + let provider: GmailProvider; + const createdTestLabels: string[] = []; // Track labels to clean up + + beforeAll(async () => { + const testEmail = TEST_GMAIL_EMAIL; + + if (!testEmail) { + console.warn("\n⚠️ Set TEST_GMAIL_EMAIL env var to run these tests"); + console.warn( + " Example: TEST_GMAIL_EMAIL=your@gmail.com pnpm test-e2e google-labeling\n", + ); + return; + } + + // Load account from DB + const emailAccount = await prisma.emailAccount.findFirst({ + where: { + email: testEmail, + account: { + provider: "google", + }, + }, + include: { + account: true, + }, + }); + + if (!emailAccount) { + throw new Error(`No Gmail account found for ${testEmail}`); + } + + provider = (await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: "google", + })) as GmailProvider; + + console.log(`\n✅ Using account: ${emailAccount.email}`); + console.log(` Account ID: ${emailAccount.id}`); + console.log(` Test thread ID: ${TEST_GMAIL_THREAD_ID}`); + console.log(` Test message ID: ${TEST_GMAIL_MESSAGE_ID}\n`); + }); + + afterAll(async () => { + // Clean up all test labels created during the test suite + if (createdTestLabels.length > 0) { + console.log( + `\n 🧹 Cleaning up ${createdTestLabels.length} test labels...`, + ); + + let deletedCount = 0; + let failedCount = 0; + + for (const labelName of createdTestLabels) { + try { + const label = await provider.getLabelByName(labelName); + if (label) { + await provider.deleteLabel(label.id); + deletedCount++; + } + } catch { + failedCount++; + console.log(` ⚠️ Failed to delete: ${labelName}`); + } + } + + console.log( + ` ✅ Deleted ${deletedCount} labels, ${failedCount} failed\n`, + ); + } + }); + + describe("Label Creation and Retrieval", () => { + test("should create a new label and retrieve it by name", async () => { + const testLabelName = `E2E Test ${Date.now()}`; + createdTestLabels.push(testLabelName); + + // Create the label + const createdLabel = await provider.createLabel(testLabelName); + + expect(createdLabel).toBeDefined(); + expect(createdLabel.id).toBeDefined(); + expect(createdLabel.name).toBe(testLabelName); + + console.log(" ✅ Created label:", testLabelName); + console.log(" ID:", createdLabel.id); + + // Retrieve the label by name + const retrievedLabel = await provider.getLabelByName(testLabelName); + + expect(retrievedLabel).toBeDefined(); + expect(retrievedLabel?.id).toBe(createdLabel.id); + expect(retrievedLabel?.name).toBe(testLabelName); + + console.log(" ✅ Retrieved label by name:", retrievedLabel?.name); + }); + + test("should retrieve label by ID", async () => { + const testLabelName = `E2E Test ID ${Date.now()}`; + createdTestLabels.push(testLabelName); + + // Create the label + const createdLabel = await provider.createLabel(testLabelName); + const labelId = createdLabel.id; + + console.log(" 📝 Created label with ID:", labelId); + + // Retrieve by ID + const retrievedLabel = await provider.getLabelById(labelId); + + expect(retrievedLabel).toBeDefined(); + expect(retrievedLabel?.id).toBe(labelId); + expect(retrievedLabel?.name).toBe(testLabelName); + + console.log(" ✅ Retrieved label by ID:", retrievedLabel?.name); + }); + + test("should return null for non-existent label name", async () => { + const nonExistentName = `NonExistent ${Date.now()}`; + + const label = await provider.getLabelByName(nonExistentName); + + expect(label).toBeNull(); + console.log(" ✅ Correctly returned null for non-existent label"); + }); + + test("should list all labels", async () => { + const labels = await provider.getLabels(); + + expect(labels).toBeDefined(); + expect(Array.isArray(labels)).toBe(true); + expect(labels.length).toBeGreaterThan(0); + + console.log(" ✅ Retrieved", labels.length, "labels"); + console.log(" Sample labels:"); + labels.slice(0, 5).forEach((label) => { + console.log(` - ${label.name} (${label.id})`); + }); + }); + + test("should handle duplicate label creation gracefully", async () => { + const testLabelName = `E2E Duplicate ${Date.now()}`; + createdTestLabels.push(testLabelName); + + // Create the label first time + const firstLabel = await provider.createLabel(testLabelName); + expect(firstLabel).toBeDefined(); + + console.log(" 📝 Created label first time:", testLabelName); + + // Try to create it again - Gmail should throw an error or return existing + await expect(provider.createLabel(testLabelName)).rejects.toThrow(); + + console.log( + " ✅ Duplicate creation correctly threw error (Gmail behavior)", + ); + }); + }); + + describe("Label Application to Messages", () => { + test("should apply label to a single message", async () => { + const testLabelName = `E2E Apply ${Date.now()}`; + createdTestLabels.push(testLabelName); + + // Create the label + const label = await provider.createLabel(testLabelName); + console.log(" 📝 Created label:", label.name, `(${label.id})`); + + // Apply label to message + await provider.labelMessage({ + messageId: TEST_GMAIL_MESSAGE_ID, + labelId: label.id, + }); + + console.log(" ✅ Applied label to message:", TEST_GMAIL_MESSAGE_ID); + + // Verify by fetching the message + const message = await provider.getMessage(TEST_GMAIL_MESSAGE_ID); + + expect(message.labelIds).toBeDefined(); + expect(message.labelIds).toContain(label.id); + + console.log(" ✅ Verified label is on message"); + console.log(" Message labels:", message.labelIds?.join(", ")); + + // Clean up - remove the label from the message + await provider.removeThreadLabel(message.threadId, label.id); + console.log(" 🧹 Cleaned up label from thread"); + }); + + test("should apply multiple labels to a message", async () => { + const testLabel1Name = `E2E Multi 1 ${Date.now()}`; + const testLabel2Name = `E2E Multi 2 ${Date.now()}`; + createdTestLabels.push(testLabel1Name, testLabel2Name); + + // Create two labels + const label1 = await provider.createLabel(testLabel1Name); + const label2 = await provider.createLabel(testLabel2Name); + + console.log(" 📝 Created labels:"); + console.log(" -", label1.name, `(${label1.id})`); + console.log(" -", label2.name, `(${label2.id})`); + + // Apply first label + await provider.labelMessage({ + messageId: TEST_GMAIL_MESSAGE_ID, + labelId: label1.id, + }); + + // Apply second label + await provider.labelMessage({ + messageId: TEST_GMAIL_MESSAGE_ID, + labelId: label2.id, + }); + + console.log(" ✅ Applied both labels to message"); + + // Verify both labels are on the message + const message = await provider.getMessage(TEST_GMAIL_MESSAGE_ID); + + expect(message.labelIds).toBeDefined(); + expect(message.labelIds).toContain(label1.id); + expect(message.labelIds).toContain(label2.id); + + console.log(" ✅ Verified both labels are on message"); + console.log(" Message labels:", message.labelIds?.join(", ")); + + // Clean up - remove both labels + await provider.removeThreadLabel(message.threadId, label1.id); + await provider.removeThreadLabel(message.threadId, label2.id); + console.log(" 🧹 Cleaned up both labels from thread"); + }); + + test("should handle applying label to non-existent message", async () => { + const testLabelName = `E2E Invalid ${Date.now()}`; + createdTestLabels.push(testLabelName); + + const label = await provider.createLabel(testLabelName); + const fakeMessageId = "FAKE_MESSAGE_ID_123"; + + // Should throw an error + await expect( + provider.labelMessage({ + messageId: fakeMessageId, + labelId: label.id, + }), + ).rejects.toThrow(); + + console.log(" ✅ Correctly threw error for non-existent message"); + }); + }); + + describe("Label Removal from Threads", () => { + test("should remove label from all messages in a thread", async () => { + const testLabelName = `E2E Remove ${Date.now()}`; + createdTestLabels.push(testLabelName); + + // Create and apply label + const label = await provider.createLabel(testLabelName); + console.log(` 📝 Created label: ${label.name} (${label.id})`); + + // Apply label to message + await provider.labelMessage({ + messageId: TEST_GMAIL_MESSAGE_ID, + labelId: label.id, + }); + console.log(" 📝 Applied label to message"); + + // Verify label is applied + const messageBefore = await provider.getMessage(TEST_GMAIL_MESSAGE_ID); + expect(messageBefore.labelIds).toContain(label.id); + console.log(" ✅ Verified label is on message before removal"); + + // Remove label from thread + await provider.removeThreadLabel(messageBefore.threadId, label.id); + console.log(" ✅ Removed label from thread"); + + // Verify label is removed + const messageAfter = await provider.getMessage(TEST_GMAIL_MESSAGE_ID); + expect(messageAfter.labelIds).not.toContain(label.id); + console.log(" ✅ Verified label is removed from message"); + }); + + test("should handle removing non-existent label from thread", async () => { + const fakeLabel = "FAKE_LABEL_ID_123"; + + // Should not throw error + await expect( + provider.removeThreadLabel(TEST_GMAIL_THREAD_ID, fakeLabel), + ).resolves.not.toThrow(); + + console.log(" ✅ Handled removing non-existent label gracefully"); + }); + + test("should handle removing label from thread with multiple messages", async () => { + const testLabelName = `E2E Thread ${Date.now()}`; + createdTestLabels.push(testLabelName); + + // Create label + const label = await provider.createLabel(testLabelName); + console.log(` 📝 Created label: ${label.name}`); + + // Get all messages in the thread + const threadMessages = + await provider.getThreadMessages(TEST_GMAIL_THREAD_ID); + console.log(` 📝 Thread has ${threadMessages.length} message(s)`); + + if (threadMessages.length === 0) { + console.log(" ⚠️ No messages in thread, skipping test"); + return; + } + + // Apply label to first message + await provider.labelMessage({ + messageId: threadMessages[0].id, + labelId: label.id, + }); + console.log(" 📝 Applied label to first message in thread"); + + // Remove label from entire thread + await provider.removeThreadLabel(TEST_GMAIL_THREAD_ID, label.id); + console.log(" ✅ Removed label from thread"); + + // Verify all messages in thread don't have the label + for (const msg of threadMessages) { + const message = await provider.getMessage(msg.id); + expect(message.labelIds).not.toContain(label.id); + } + + console.log( + ` ✅ Verified label removed from all ${threadMessages.length} message(s)`, + ); + }); + + test("should handle empty label ID gracefully", async () => { + await expect( + provider.removeThreadLabel(TEST_GMAIL_THREAD_ID, ""), + ).resolves.not.toThrow(); + + console.log(" ✅ Handled empty label ID gracefully"); + }); + }); + + describe("Complete Label Lifecycle", () => { + test("should complete full label lifecycle: create, apply, verify, remove, verify", async () => { + const testLabelName = `E2E Lifecycle ${Date.now()}`; + createdTestLabels.push(testLabelName); + + console.log(`\n 🔄 Starting full lifecycle test for: ${testLabelName}`); + + // Step 1: Create label + console.log(" 📝 Step 1: Creating label..."); + const label = await provider.createLabel(testLabelName); + expect(label).toBeDefined(); + expect(label.id).toBeDefined(); + console.log(" ✅ Label created:", label.id); + + // Step 2: Verify label exists in list + console.log(" 📝 Step 2: Verifying label in list..."); + const labels = await provider.getLabels(); + const foundInList = labels.find((l) => l.id === label.id); + expect(foundInList).toBeDefined(); + console.log(" ✅ Label found in list"); + + // Step 3: Apply label to message + console.log(" 📝 Step 3: Applying label to message..."); + await provider.labelMessage({ + messageId: TEST_GMAIL_MESSAGE_ID, + labelId: label.id, + }); + console.log(" ✅ Label applied"); + + // Step 4: Verify label on message + console.log(" 📝 Step 4: Verifying label on message..."); + const messageWithLabel = await provider.getMessage(TEST_GMAIL_MESSAGE_ID); + expect(messageWithLabel.labelIds).toContain(label.id); + console.log( + ` ✅ Label verified on message (${messageWithLabel.labelIds?.length} total labels)`, + ); + + // Step 5: Remove label from thread + console.log(" 📝 Step 5: Removing label from thread..."); + await provider.removeThreadLabel(messageWithLabel.threadId, label.id); + console.log(" ✅ Label removed"); + + // Step 6: Verify label no longer on message + console.log(" 📝 Step 6: Verifying label removed from message..."); + const messageWithoutLabel = await provider.getMessage( + TEST_GMAIL_MESSAGE_ID, + ); + expect(messageWithoutLabel.labelIds).not.toContain(label.id); + console.log(" ✅ Label confirmed removed from message"); + + console.log("\n ✅ Full lifecycle test completed successfully!"); + }); + }); + + 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()}`; + createdTestLabels.push(label1Name, label2Name); + + // Create two labels + const label1 = await provider.createLabel(label1Name); + const label2 = await provider.createLabel(label2Name); + + console.log(" 📝 Created two labels"); + + // Apply label1 + await provider.labelMessage({ + messageId: TEST_GMAIL_MESSAGE_ID, + labelId: label1.id, + }); + + // Verify only label1 is present + let message = await provider.getMessage(TEST_GMAIL_MESSAGE_ID); + 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({ + messageId: TEST_GMAIL_MESSAGE_ID, + labelId: label2.id, + }); + + // Verify both labels are present + message = await provider.getMessage(TEST_GMAIL_MESSAGE_ID); + 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); + + // Verify only label2 is present + message = await provider.getMessage(TEST_GMAIL_MESSAGE_ID); + 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); + + // Verify neither label is present + message = await provider.getMessage(TEST_GMAIL_MESSAGE_ID); + 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 new file mode 100644 index 0000000000..0e3ea1c167 --- /dev/null +++ b/apps/web/__tests__/e2e/labeling/microsoft-labeling.test.ts @@ -0,0 +1,500 @@ +/** + * E2E tests for Microsoft Outlook labeling operations + * + * Usage: + * pnpm test-e2e microsoft-labeling + * pnpm test-e2e microsoft-labeling -t "should apply and remove label" # Run specific test + * + * Setup: + * 1. Set TEST_OUTLOOK_EMAIL env var to your Outlook email + * 2. Set TEST_OUTLOOK_MESSAGE_ID with a real messageId from your logs + * 3. Set TEST_CONVERSATION_ID with a real conversationId from your logs + * + * These tests follow a clean slate approach: + * - Create test labels + * - Apply labels and verify + * - Remove labels and verify + * - Clean up all test labels at the end + */ + +import { describe, test, expect, beforeAll, afterAll, 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 RUN_E2E_TESTS = process.env.RUN_E2E_TESTS; +const TEST_OUTLOOK_EMAIL = process.env.TEST_OUTLOOK_EMAIL; +const TEST_CONVERSATION_ID = + process.env.TEST_CONVERSATION_ID || + "AQQkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoAEABuo-fmt9KvQ4u55KlWB32H"; +const TEST_OUTLOOK_MESSAGE_ID = + process.env.TEST_OUTLOOK_MESSAGE_ID || + "AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA"; + +vi.mock("server-only", () => ({})); + +describe.skipIf(!RUN_E2E_TESTS)("Microsoft Outlook Labeling E2E Tests", () => { + let provider: OutlookProvider; + const createdTestLabels: string[] = []; // Track labels to clean up + + 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-e2e microsoft-labeling\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}`); + console.log(` Test message ID: ${TEST_OUTLOOK_MESSAGE_ID}\n`); + }); + + afterAll(async () => { + // Clean up all test labels created during the test suite + if (createdTestLabels.length > 0) { + console.log( + `\n 🧹 Cleaning up ${createdTestLabels.length} test labels...`, + ); + + let deletedCount = 0; + let failedCount = 0; + + for (const labelName of createdTestLabels) { + try { + const label = await provider.getLabelByName(labelName); + if (label) { + await provider.deleteLabel(label.id); + deletedCount++; + } + } catch { + failedCount++; + console.log(` ⚠️ Failed to delete: ${labelName}`); + } + } + + console.log( + ` ✅ Deleted ${deletedCount} labels, ${failedCount} failed\n`, + ); + } + }); + + describe("Label Creation and Retrieval", () => { + test("should create a new label and retrieve it by name", async () => { + const testLabelName = `E2E Test ${Date.now()}`; + createdTestLabels.push(testLabelName); + + // Create the label + const createdLabel = await provider.createLabel(testLabelName); + + expect(createdLabel).toBeDefined(); + expect(createdLabel.id).toBeDefined(); + expect(createdLabel.name).toBe(testLabelName); + + console.log(" ✅ Created label:", testLabelName); + console.log(" ID:", createdLabel.id); + + // Retrieve the label by name + const retrievedLabel = await provider.getLabelByName(testLabelName); + + expect(retrievedLabel).toBeDefined(); + expect(retrievedLabel?.id).toBe(createdLabel.id); + expect(retrievedLabel?.name).toBe(testLabelName); + + console.log(" ✅ Retrieved label by name:", retrievedLabel?.name); + }); + + test("should retrieve label by ID", async () => { + const testLabelName = `E2E Test ID ${Date.now()}`; + createdTestLabels.push(testLabelName); + + // Create the label + const createdLabel = await provider.createLabel(testLabelName); + const labelId = createdLabel.id; + + console.log(" 📝 Created label with ID:", labelId); + + // Retrieve by ID + const retrievedLabel = await provider.getLabelById(labelId); + + expect(retrievedLabel).toBeDefined(); + expect(retrievedLabel?.id).toBe(labelId); + expect(retrievedLabel?.name).toBe(testLabelName); + + console.log(" ✅ Retrieved label by ID:", retrievedLabel?.name); + }); + + test("should return null for non-existent label name", async () => { + const nonExistentName = `NonExistent ${Date.now()}`; + + const label = await provider.getLabelByName(nonExistentName); + + expect(label).toBeNull(); + console.log(" ✅ Correctly returned null for non-existent label"); + }); + + test("should list all labels", async () => { + const labels = await provider.getLabels(); + + expect(labels).toBeDefined(); + expect(Array.isArray(labels)).toBe(true); + expect(labels.length).toBeGreaterThan(0); + + console.log(` ✅ Retrieved ${labels.length} labels`); + console.log(" Sample labels:"); + labels.slice(0, 5).forEach((label) => { + console.log(` - ${label.name} (${label.id})`); + }); + }); + + test("should handle duplicate label creation gracefully", async () => { + const testLabelName = `E2E Duplicate ${Date.now()}`; + createdTestLabels.push(testLabelName); + + // Create the label first time + const firstLabel = await provider.createLabel(testLabelName); + expect(firstLabel).toBeDefined(); + + console.log(" 📝 Created label first time:", testLabelName); + + // Try to create it again + const secondLabel = await provider.createLabel(testLabelName); + + // Should return the existing label without error + expect(secondLabel).toBeDefined(); + expect(secondLabel.id).toBe(firstLabel.id); + expect(secondLabel.name).toBe(testLabelName); + + console.log( + " ✅ Duplicate creation handled gracefully - returned existing label", + ); + }); + }); + + describe("Label Application to Messages", () => { + test("should apply label to a single message", async () => { + const testLabelName = `E2E Apply ${Date.now()}`; + createdTestLabels.push(testLabelName); + + // Create the label + const label = await provider.createLabel(testLabelName); + console.log(" 📝 Created label:", label.name, `(${label.id})`); + + // Apply label to message + await provider.labelMessage({ + messageId: TEST_OUTLOOK_MESSAGE_ID, + labelId: label.id, + }); + + console.log(" ✅ Applied label to message:", TEST_OUTLOOK_MESSAGE_ID); + + // Verify by fetching the message + const message = await provider.getMessage(TEST_OUTLOOK_MESSAGE_ID); + + expect(message.labelIds).toBeDefined(); + expect(message.labelIds).toContain(label.name); + + console.log(" ✅ Verified label is on message"); + console.log(` Message labels: ${message.labelIds?.join(", ")}`); + + // Clean up - remove the label from the message (use the message's actual threadId) + await provider.removeThreadLabel(message.threadId, label.id); + console.log(" 🧹 Cleaned up label from thread"); + }); + + test("should apply multiple labels to a message", async () => { + const testLabel1Name = `E2E Multi 1 ${Date.now()}`; + const testLabel2Name = `E2E Multi 2 ${Date.now()}`; + createdTestLabels.push(testLabel1Name, testLabel2Name); + + // Create two labels + const label1 = await provider.createLabel(testLabel1Name); + const label2 = await provider.createLabel(testLabel2Name); + + console.log(" 📝 Created labels:"); + console.log(` - ${label1.name} (${label1.id})`); + console.log(` - ${label2.name} (${label2.id})`); + + // Apply first label + await provider.labelMessage({ + messageId: TEST_OUTLOOK_MESSAGE_ID, + labelId: label1.id, + }); + + // Apply second label + await provider.labelMessage({ + messageId: TEST_OUTLOOK_MESSAGE_ID, + labelId: label2.id, + }); + + console.log(" ✅ Applied both labels to message"); + + // Verify both labels are on the message + const message = await provider.getMessage(TEST_OUTLOOK_MESSAGE_ID); + + expect(message.labelIds).toBeDefined(); + expect(message.labelIds).toContain(label1.name); + expect(message.labelIds).toContain(label2.name); + + console.log(" ✅ Verified both labels are on message"); + console.log(` Message labels: ${message.labelIds?.join(", ")}`); + + // Clean up - remove both labels (use the message's actual threadId) + await provider.removeThreadLabel(message.threadId, label1.id); + await provider.removeThreadLabel(message.threadId, label2.id); + console.log(" 🧹 Cleaned up both labels from thread"); + }); + + test("should handle applying label to non-existent message", async () => { + const testLabelName = `E2E Invalid ${Date.now()}`; + createdTestLabels.push(testLabelName); + + const label = await provider.createLabel(testLabelName); + const fakeMessageId = "FAKE_MESSAGE_ID_123"; + + // Should throw an error + await expect( + provider.labelMessage({ + messageId: fakeMessageId, + labelId: label.id, + }), + ).rejects.toThrow(); + + console.log(" ✅ Correctly threw error for non-existent message"); + }); + }); + + describe("Label Removal from Threads", () => { + test("should remove label from all messages in a thread", async () => { + const testLabelName = `E2E Remove ${Date.now()}`; + createdTestLabels.push(testLabelName); + + // Create and apply label + const label = await provider.createLabel(testLabelName); + console.log(` 📝 Created label: ${label.name} (${label.id})`); + + // Apply label to message + await provider.labelMessage({ + messageId: TEST_OUTLOOK_MESSAGE_ID, + labelId: label.id, + }); + console.log(" 📝 Applied label to message"); + + // Verify label is applied + const messageBefore = await provider.getMessage(TEST_OUTLOOK_MESSAGE_ID); + expect(messageBefore.labelIds).toContain(label.name); + console.log(" ✅ Verified label is on message before removal"); + + // Remove label from thread - use the message's actual conversationId + await provider.removeThreadLabel(messageBefore.threadId, label.id); + console.log(" ✅ Removed label from thread"); + + // Verify label is removed + const messageAfter = await provider.getMessage(TEST_OUTLOOK_MESSAGE_ID); + expect(messageAfter.labelIds).not.toContain(label.name); + console.log(" ✅ Verified label is removed from message"); + }); + + test("should handle removing non-existent label from thread", async () => { + const fakeLabel = "FAKE_LABEL_ID_123"; + + // Should not throw error + await expect( + provider.removeThreadLabel(TEST_CONVERSATION_ID, fakeLabel), + ).resolves.not.toThrow(); + + console.log(" ✅ Handled removing non-existent label gracefully"); + }); + + test("should handle removing label from thread with multiple messages", async () => { + const testLabelName = `E2E Thread ${Date.now()}`; + createdTestLabels.push(testLabelName); + + // Create label + const label = await provider.createLabel(testLabelName); + console.log(` 📝 Created label: ${label.name}`); + + // Get all messages in the thread + const threadMessages = + await provider.getThreadMessages(TEST_CONVERSATION_ID); + console.log(` 📝 Thread has ${threadMessages.length} message(s)`); + + if (threadMessages.length === 0) { + console.log(" ⚠️ No messages in thread, skipping test"); + return; + } + + // Apply label to first message + await provider.labelMessage({ + messageId: threadMessages[0].id, + labelId: label.id, + }); + console.log(" 📝 Applied label to first message in thread"); + + // Remove label from entire thread + await provider.removeThreadLabel(TEST_CONVERSATION_ID, label.id); + console.log(" ✅ Removed label from thread"); + + // Verify all messages in thread don't have the label + for (const msg of threadMessages) { + const message = await provider.getMessage(msg.id); + expect(message.labelIds).not.toContain(label.name); + } + + console.log( + ` ✅ Verified label removed from all ${threadMessages.length} message(s)`, + ); + }); + + test("should handle empty label ID gracefully", async () => { + await expect( + provider.removeThreadLabel(TEST_CONVERSATION_ID, ""), + ).resolves.not.toThrow(); + + console.log(" ✅ Handled empty label ID gracefully"); + }); + }); + + describe("Complete Label Lifecycle", () => { + test("should complete full label lifecycle: create, apply, verify, remove, verify", async () => { + const testLabelName = `E2E Lifecycle ${Date.now()}`; + createdTestLabels.push(testLabelName); + + console.log(`\n 🔄 Starting full lifecycle test for: ${testLabelName}`); + + // Step 1: Create label + console.log(" 📝 Step 1: Creating label..."); + const label = await provider.createLabel(testLabelName); + expect(label).toBeDefined(); + expect(label.id).toBeDefined(); + console.log(` ✅ Label created: ${label.id}`); + + // Step 2: Verify label exists in list + console.log(" 📝 Step 2: Verifying label in list..."); + const labels = await provider.getLabels(); + const foundInList = labels.find((l) => l.id === label.id); + expect(foundInList).toBeDefined(); + console.log(" ✅ Label found in list"); + + // Step 3: Apply label to message + console.log(" 📝 Step 3: Applying label to message..."); + await provider.labelMessage({ + messageId: TEST_OUTLOOK_MESSAGE_ID, + labelId: label.id, + }); + console.log(" ✅ Label applied"); + + // Step 4: Verify label on message + console.log(" 📝 Step 4: Verifying label on message..."); + const messageWithLabel = await provider.getMessage( + TEST_OUTLOOK_MESSAGE_ID, + ); + expect(messageWithLabel.labelIds).toContain(label.name); + console.log( + ` ✅ Label verified on message (${messageWithLabel.labelIds?.length} total labels)`, + ); + + // Step 5: Remove label from thread (use the message's actual threadId) + console.log(" 📝 Step 5: Removing label from thread..."); + await provider.removeThreadLabel(messageWithLabel.threadId, label.id); + console.log(" ✅ Label removed"); + + // Step 6: Verify label no longer on message + console.log(" 📝 Step 6: Verifying label removed from message..."); + const messageWithoutLabel = await provider.getMessage( + TEST_OUTLOOK_MESSAGE_ID, + ); + expect(messageWithoutLabel.labelIds).not.toContain(label.name); + console.log(" ✅ Label confirmed removed from message"); + + console.log("\n ✅ Full lifecycle test completed successfully!"); + }); + }); + + 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()}`; + createdTestLabels.push(label1Name, label2Name); + + // Create two labels + const label1 = await provider.createLabel(label1Name); + const label2 = await provider.createLabel(label2Name); + + console.log(" 📝 Created two labels"); + + // Apply label1 + await provider.labelMessage({ + messageId: TEST_OUTLOOK_MESSAGE_ID, + labelId: label1.id, + }); + + // Verify only label1 is present + let message = await provider.getMessage(TEST_OUTLOOK_MESSAGE_ID); + expect(message.labelIds).toContain(label1.name); + expect(message.labelIds).not.toContain(label2.name); + console.log(" ✅ State check 1: Only label1 present"); + + // Apply label2 + await provider.labelMessage({ + messageId: TEST_OUTLOOK_MESSAGE_ID, + labelId: label2.id, + }); + + // Verify both labels are present + message = await provider.getMessage(TEST_OUTLOOK_MESSAGE_ID); + expect(message.labelIds).toContain(label1.name); + expect(message.labelIds).toContain(label2.name); + console.log(" ✅ State check 2: Both labels present"); + + // Remove label1 (use the message's actual threadId) + await provider.removeThreadLabel(message.threadId, label1.id); + + // Verify only label2 is present + message = await provider.getMessage(TEST_OUTLOOK_MESSAGE_ID); + expect(message.labelIds).not.toContain(label1.name); + expect(message.labelIds).toContain(label2.name); + console.log(" ✅ State check 3: Only label2 present"); + + // Remove label2 (use the message's actual threadId) + await provider.removeThreadLabel(message.threadId, label2.id); + + // Verify neither label is present + message = await provider.getMessage(TEST_OUTLOOK_MESSAGE_ID); + expect(message.labelIds).not.toContain(label1.name); + expect(message.labelIds).not.toContain(label2.name); + console.log(" ✅ State check 4: No test labels present"); + + console.log(" ✅ Label state consistency maintained!"); + }); + }); +}); diff --git a/apps/web/__tests__/outlook-operations.test.ts b/apps/web/__tests__/e2e/outlook-operations.test.ts similarity index 87% rename from apps/web/__tests__/outlook-operations.test.ts rename to apps/web/__tests__/e2e/outlook-operations.test.ts index 10abfd8f4d..3475fb707a 100644 --- a/apps/web/__tests__/outlook-operations.test.ts +++ b/apps/web/__tests__/e2e/outlook-operations.test.ts @@ -1,15 +1,15 @@ /** - * Manual integration tests for Outlook operations + * E2E tests for Outlook operations (webhooks, threads, search queries) + * + * Usage: + * pnpm test-e2e outlook-operations + * pnpm test-e2e outlook-operations -t "getThread" # Run specific test * * Setup: * 1. Set TEST_OUTLOOK_EMAIL env var to your Outlook email * 2. Set TEST_OUTLOOK_MESSAGE_ID with a real messageId from your logs (optional) * 3. Set TEST_CONVERSATION_ID with a real conversationId from your logs (optional) * 4. Set TEST_CATEGORY_NAME for category/label testing (optional, defaults to "To Reply") - * - * Usage: - * TEST_OUTLOOK_EMAIL=your@email.com pnpm test-e2e outlook-operations - * pnpm test-e2e outlook-operations -t "getThread" # Run specific test */ import { describe, test, expect, beforeAll, vi } from "vitest"; @@ -456,4 +456,59 @@ describe.skipIf(!RUN_E2E_TESTS)("Outlook Webhook Payload", () => { console.log(" ℹ️ No draft action found"); } }, 30_000); + + test("should verify draft ID can be fetched immediately after creation", async () => { + const emailAccount = await prisma.emailAccount.findUniqueOrThrow({ + where: { email: TEST_OUTLOOK_EMAIL }, + }); + + const provider = (await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: "microsoft", + })) as OutlookProvider; + + // Get a real message to reply to + const messages = await provider.getThreadMessages(TEST_CONVERSATION_ID); + if (messages.length === 0) { + console.log(" ⚠️ No messages in thread, skipping test"); + return; + } + + const message = messages[0]; + + // Create a draft + const draftResult = await provider.draftEmail( + message, + { content: "Test draft - verifying ID can be fetched" }, + emailAccount.email, + ); + + expect(draftResult.draftId).toBeDefined(); + console.log(` ✅ Created draft with ID: ${draftResult.draftId}`); + + // Immediately try to fetch the draft with the returned ID + const fetchedDraft = await provider.getDraft(draftResult.draftId); + + expect(fetchedDraft).toBeDefined(); + expect(fetchedDraft?.id).toBe(draftResult.draftId); + + console.log(" ✅ Successfully fetched draft with same ID"); + console.log(` Draft ID: ${draftResult.draftId}`); + console.log(` Fetched ID: ${fetchedDraft?.id}`); + console.log( + ` Content preview: ${fetchedDraft?.textPlain?.substring(0, 50) || "(empty)"}...`, + ); + + // Clean up - delete the test draft + await provider.deleteDraft(draftResult.draftId); + console.log(" ✅ Cleaned up test draft"); + + // Try to fetch the deleted draft to see the error message + console.log("\n 🔍 Attempting to fetch deleted draft..."); + const deletedDraft = await provider.getDraft(draftResult.draftId); + + // Should return null for deleted drafts (not throw an error) + expect(deletedDraft).toBeNull(); + console.log(" ✅ getDraft correctly returned null for deleted draft"); + }, 30_000); }); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx index 71ecabac43..3ab6fd882e 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx @@ -22,10 +22,7 @@ export function ActionSummaryCard({ labels: EmailLabel[]; }) { // don't display - if ( - action.type === ActionType.TRACK_THREAD || - action.type === ActionType.DIGEST - ) { + if (action.type === ActionType.DIGEST) { return null; } diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/AvailableActionsPanel.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/AvailableActionsPanel.tsx index 4bc7582fd6..89dd5a1590 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/AvailableActionsPanel.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/AvailableActionsPanel.tsx @@ -21,7 +21,6 @@ const actionNames: Record = { [ActionType.SEND_EMAIL]: "Send email", [ActionType.CALL_WEBHOOK]: "Call webhook", [ActionType.DIGEST]: "Add to digest", - [ActionType.TRACK_THREAD]: "Track thread", }; const actionTooltips: Partial> = { diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx index 4e814c7e2d..fca9f6a93d 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx @@ -80,11 +80,7 @@ function ActionSummaryCard({ const MAX_LENGTH = 280; const aiGeneratedContent = result.actionItems - ?.filter( - (action) => - action.type !== ActionType.TRACK_THREAD && - action.type !== ActionType.DIGEST, - ) + ?.filter((action) => action.type !== ActionType.DIGEST) .map((action, i) => (
- action.type !== ActionType.DIGEST && - action.type !== ActionType.TRACK_THREAD, - ) + .filter((action) => action.type !== ActionType.DIGEST) .map((action) => ({ ...action, delayInMinutes: action.delayInMinutes, @@ -344,8 +342,10 @@ export function RuleForm({ }, [provider, terminology.label.action]); const [isNameEditMode, setIsNameEditMode] = useState(alwaysEditMode); - const [isConditionsEditMode, setIsConditionsEditMode] = - useState(alwaysEditMode); + const [isConditionsEditMode, setIsConditionsEditMode] = useState( + alwaysEditMode && + !(rule.systemType && isConversationStatusType(rule.systemType)), + ); const [isActionsEditMode, setIsActionsEditMode] = useState(alwaysEditMode); const toggleActionsEditMode = useCallback(() => { @@ -355,10 +355,13 @@ export function RuleForm({ }, [alwaysEditMode]); const toggleConditionsEditMode = useCallback(() => { - if (!alwaysEditMode) { + if ( + !alwaysEditMode && + !(rule.systemType && isConversationStatusType(rule.systemType)) + ) { setIsConditionsEditMode((prev: boolean) => !prev); } - }, [alwaysEditMode]); + }, [alwaysEditMode, rule.systemType]); const toggleNameEditMode = useCallback(() => { if (!alwaysEditMode) { @@ -409,45 +412,68 @@ export function RuleForm({ Conditions
- {isConditionsEditMode && ( - - - - - - - setValue("conditionalOperator", value as LogicalOperator) - } - > - - Match all conditions - - - Match any condition - - - - - )} + {isConditionsEditMode && + !( + rule.systemType && isConversationStatusType(rule.systemType) + ) && ( + + + + + + + setValue( + "conditionalOperator", + value as LogicalOperator, + ) + } + > + + Match all conditions + + + Match any condition + + + + + )} {!alwaysEditMode && ( - + + + + )}
@@ -560,28 +586,42 @@ export function RuleForm({ - {watch(`conditions.${index}.type`) === - ConditionType.AI && ( - - )} + {watch(`conditions.${index}.type`) === ConditionType.AI && + (rule.systemType && + isConversationStatusType(rule.systemType) ? ( +
+
+ ) : ( + + ))} {watch(`conditions.${index}.type`) === ConditionType.STATIC && ( @@ -770,21 +810,31 @@ export function RuleForm({ ))} - {isConditionsEditMode && unusedCondition && ( -
- -
- )} + {isConditionsEditMode && + unusedCondition && + !(rule.systemType && isConversationStatusType(rule.systemType)) && ( +
+ + + + + +
+ )}
Actions @@ -875,6 +925,7 @@ export function RuleForm({ onChange={(enabled) => { setValue("runOnThreads", enabled); }} + disabled={!allowMultipleConditions(rule.systemType)} /> @@ -902,6 +953,7 @@ export function RuleForm({
)} @@ -1299,7 +1351,6 @@ function ActionCard({ ); })} - {action.type === ActionType.TRACK_THREAD && } {shouldShowProTip && } {actionCanBeDelayed && (
@@ -1394,18 +1445,6 @@ function CardLayoutRight({ ); } -function ReplyTrackerAction() { - return ( -
-
- This action tracks emails this rule is applied to and removes the{" "} - {NEEDS_REPLY_LABEL_NAME} label after you - reply to the email. -
-
- ); -} - export function ThreadsExplanation({ size }: { size: "sm" | "md" }) { return ( `Only apply this rule ${filterType} emails from this address. Supports multiple addresses separated by comma, pipe, or OR. e.g. "@company.com", "hello@example.com OR support@test.com"`; + +function allowMultipleConditions(systemType: SystemType | null | undefined) { + return ( + systemType !== SystemType.COLD_EMAIL && + !isConversationStatusType(systemType) + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx index 3d0698c421..b5e39ecf0f 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx @@ -8,9 +8,8 @@ import { PlusIcon, HistoryIcon, Trash2Icon, - ToggleRightIcon, - ToggleLeftIcon, SparklesIcon, + InfoIcon, } from "lucide-react"; import { useMemo } from "react"; import { LoadingContent } from "@/components/LoadingContent"; @@ -30,36 +29,39 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { setRuleEnabledAction } from "@/utils/actions/ai-rule"; -import { deleteRuleAction } from "@/utils/actions/rule"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { deleteRuleAction, toggleRuleAction } from "@/utils/actions/rule"; import { conditionsToString } from "@/utils/condition"; import { Badge } from "@/components/Badge"; import { getActionColor } from "@/components/PlanBadge"; -import { toastError, toastSuccess } from "@/components/Toast"; +import { toastError } from "@/components/Toast"; import { useRules } from "@/hooks/useRules"; -import { ActionType, ColdEmailSetting, LogicalOperator } from "@prisma/client"; +import { type ActionType, LogicalOperator, SystemType } from "@prisma/client"; import { useAction } from "next-safe-action/hooks"; import { useAccount } from "@/providers/EmailAccountProvider"; import { prefixPath } from "@/utils/path"; import { ExpandableText } from "@/components/ExpandableText"; -import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; import type { RulesResponse } from "@/app/api/user/rules/route"; import { sortActionsByPriority } from "@/utils/action-sort"; -import { inboxZeroLabels } from "@/utils/label"; -import { isDefined } from "@/utils/types"; import { getActionDisplay, getActionIcon } from "@/utils/action-display"; import { RuleDialog } from "./RuleDialog"; import { useDialogState } from "@/hooks/useDialogState"; import { ColdEmailDialog } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailDialog"; import { useChat } from "@/providers/ChatProvider"; import { useSidebar } from "@/components/ui/sidebar"; -import { - isGoogleProvider, - isMicrosoftProvider, -} from "@/utils/email/provider-types"; import { useLabels } from "@/hooks/useLabels"; - -const COLD_EMAIL_BLOCKER_RULE_ID = "cold-email-blocker-rule"; +import { isConversationStatusType } from "@/utils/reply-tracker/conversation-status-config"; +import { + getRuleConfig, + SYSTEM_RULE_ORDER, + getDefaultActions, +} from "@/utils/rule/consts"; +import { DEFAULT_COLD_EMAIL_PROMPT } from "@/utils/cold-email/prompt"; export function Rules({ size = "md", @@ -71,7 +73,7 @@ export function Rules({ const { data, isLoading, error, mutate } = useRules(); const { setOpen } = useSidebar(); const { setInput } = useChat(); - const { data: emailAccountData } = useEmailAccountFull(); + const { userLabels } = useLabels(); const ruleDialog = useDialogState<{ ruleId: string; editMode?: boolean }>(); const coldEmailDialog = useDialogState(); @@ -79,11 +81,8 @@ export function Rules({ const onCreateRule = () => ruleDialog.onOpen(); const { emailAccountId, provider } = useAccount(); - const { executeAsync: setRuleEnabled } = useAction( - setRuleEnabledAction.bind(null, emailAccountId), - { - onSettled: () => mutate(), - }, + const { executeAsync: toggleRule } = useAction( + toggleRuleAction.bind(null, emailAccountId), ); const { executeAsync: deleteRule } = useAction( deleteRuleAction.bind(null, emailAccountId), @@ -92,134 +91,60 @@ export function Rules({ }, ); - const baseRules: RulesResponse = useMemo(() => { - return ( - data?.sort((a, b) => (b.enabled ? 1 : 0) - (a.enabled ? 1 : 0)) || [] - ); - }, [data]); - const rules: RulesResponse = useMemo(() => { - const enabled: ColdEmailSetting[] = [ - ColdEmailSetting.LABEL, - ColdEmailSetting.ARCHIVE_AND_LABEL, - ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL, - ]; + const existingRules = data || []; - const shouldArchived: ColdEmailSetting[] = [ - ColdEmailSetting.ARCHIVE_AND_LABEL, - ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL, - ]; + const systemRulePlaceholders = SYSTEM_RULE_ORDER.map((systemType) => { + const existingRule = existingRules.find( + (r) => r.systemType === systemType, + ); + if (existingRule) return existingRule; - const coldEmailBlockerEnabled = - emailAccountData?.coldEmailBlocker && - enabled.includes(emailAccountData?.coldEmailBlocker); + const ruleConfiguration = getRuleConfig(systemType); - if (!coldEmailBlockerEnabled) return baseRules; + return { + id: `placeholder-${systemType}`, + name: ruleConfiguration.name, + instructions: ruleConfiguration.instructions, + enabled: false, + runOnThreads: false, + automate: true, + actions: getDefaultActions(systemType, provider), + categoryFilters: [], + group: null, + emailAccountId: emailAccountId, + createdAt: new Date(), + updatedAt: new Date(), + categoryFilterType: null, + conditionalOperator: LogicalOperator.OR, + groupId: null, + systemType, + to: null, + from: null, + subject: null, + body: null, + promptText: null, + }; + }); - const showArchiveAction = - emailAccountData?.coldEmailBlocker && - shouldArchived.includes(emailAccountData?.coldEmailBlocker); + const userRules = existingRules.filter((rule) => !rule.systemType); - // Works differently to rules, but we want to show it in the list for user simplicity - const coldEmailBlockerRule: RulesResponse[number] = { - id: COLD_EMAIL_BLOCKER_RULE_ID, - name: "Cold Email", - instructions: emailAccountData?.coldEmailPrompt || null, - enabled: true, - runOnThreads: false, - automate: true, - actions: [ - isGoogleProvider(provider) - ? { - id: "cold-email-blocker-label", - type: ActionType.LABEL, - label: inboxZeroLabels.cold_email.name, - labelId: null, - createdAt: new Date(), - updatedAt: new Date(), - ruleId: COLD_EMAIL_BLOCKER_RULE_ID, - to: null, - subject: null, - content: null, - cc: null, - bcc: null, - url: null, - folderName: null, - folderId: null, - delayInMinutes: null, - } - : null, - showArchiveAction - ? { - id: "cold-email-blocker-archive", - type: isMicrosoftProvider(provider) - ? ActionType.MOVE_FOLDER - : ActionType.ARCHIVE, - label: null, - labelId: null, - createdAt: new Date(), - updatedAt: new Date(), - ruleId: COLD_EMAIL_BLOCKER_RULE_ID, - to: null, - subject: null, - content: null, - cc: null, - bcc: null, - url: null, - folderName: null, - folderId: null, - delayInMinutes: null, - } - : null, - emailAccountData?.coldEmailDigest - ? { - id: "cold-email-blocker-digest", - type: ActionType.DIGEST, - label: null, - labelId: null, - createdAt: new Date(), - updatedAt: new Date(), - ruleId: COLD_EMAIL_BLOCKER_RULE_ID, - to: null, - subject: null, - content: null, - cc: null, - bcc: null, - url: null, - folderName: null, - folderId: null, - delayInMinutes: null, - } - : null, - ].filter(isDefined), - categoryFilters: [], - group: null, - emailAccountId: emailAccountId, - createdAt: new Date(), - updatedAt: new Date(), - categoryFilterType: null, - conditionalOperator: LogicalOperator.OR, - groupId: null, - systemType: null, - to: null, - from: null, - subject: null, - body: null, - promptText: null, - }; - return [...(baseRules || []), coldEmailBlockerRule]; - }, [baseRules, emailAccountData, emailAccountId, provider]); + return [...systemRulePlaceholders, ...userRules].sort( + (a, b) => (b.enabled ? 1 : 0) - (a.enabled ? 1 : 0), + ); + }, [data, emailAccountId, provider]); const hasRules = !!rules?.length; return ( -
+
{hasRules ? ( + Enabled Name {size === "md" && Condition} Action @@ -239,54 +164,107 @@ export function Rules({ {rules.map((rule) => { + const isConversationStatus = isConversationStatusType( + rule.systemType, + ); const isColdEmailBlocker = - rule.id === COLD_EMAIL_BLOCKER_RULE_ID; + rule.systemType === SystemType.COLD_EMAIL; + const isPlaceholder = rule.id.startsWith("placeholder-"); return ( { - if (isColdEmailBlocker) { - coldEmailDialog.onOpen(); - } else { - ruleDialog.onOpen({ - ruleId: rule.id, - editMode: false, - }); - } + if (isPlaceholder) return; + ruleDialog.onOpen({ + ruleId: rule.id, + editMode: false, + }); }} > - - + /> + {rule.name} {size === "md" && ( - + {(() => { + const systemRuleDesc = getSystemRuleDescription( + rule.systemType, + ); + if (isConversationStatus) { + return ( +
+ + {systemRuleDesc?.condition || ""} + + + + + + +

+ System rule to track conversation + status. Conditions cannot be edited. +

+
+
+
+ ); + } + return ( + + ); + })()}
)} @@ -297,106 +275,73 @@ export function Rules({ /> - - - + + e.stopPropagation()} > - - Toggle menu - - - e.stopPropagation()} - > - { - if (isColdEmailBlocker) { - coldEmailDialog.onOpen(); - } else { - ruleDialog.onOpen({ - ruleId: rule.id, - editMode: true, - }); - } - }} - > - - Edit manually - - {!isColdEmailBlocker && ( { - setInput( - `I'd like to edit the "${rule.name}" rule:\n`, - ); - setOpen((arr) => [...arr, "chat-sidebar"]); + if (isColdEmailBlocker) { + coldEmailDialog.onOpen(); + } else { + ruleDialog.onOpen({ + ruleId: rule.id, + editMode: true, + }); + } }} > - - Edit via AI + + Edit manually - )} - - - - History - - - {!isColdEmailBlocker && ( - <> + {!isColdEmailBlocker && !isConversationStatus && ( { - const result = await setRuleEnabled({ - ruleId: rule.id, - enabled: !rule.enabled, - }); - - if (result?.serverError) { - toastError({ - description: `There was an error ${ - rule.enabled - ? "disabling" - : "enabling" - } your rule. ${result.serverError || ""}`, - }); - } else { - toastSuccess({ - description: `Rule ${ - rule.enabled ? "disabled" : "enabled" - }!`, - }); - } - - mutate(); + onClick={() => { + setInput( + `I'd like to edit the "${rule.name}" rule:\n`, + ); + setOpen((arr) => [...arr, "chat-sidebar"]); }} > - {rule.enabled ? ( - - ) : ( - - )} - {rule.enabled ? "Disable" : "Enable"} + + Edit via AI + )} + + + + History + + + {!isColdEmailBlocker && !isConversationStatus && ( { const yes = confirm( @@ -438,10 +383,10 @@ export function Rules({ Delete - - )} - - + )} + + + )}
); @@ -493,9 +438,6 @@ export function ActionBadges({ return (
{sortActionsByPriority(actions).map((action) => { - // Hidden for simplicity - if (action.type === ActionType.TRACK_THREAD) return null; - const Icon = getActionIcon(action.type); return ( @@ -531,3 +473,30 @@ function NoRules() { ); } + +function getSystemRuleDescription(systemType: SystemType | null) { + switch (systemType) { + case SystemType.TO_REPLY: + return { + condition: "Emails needing your direct response", + }; + case SystemType.FYI: + return { + condition: "Important emails that don't need a response", + }; + case SystemType.AWAITING_REPLY: + return { + condition: "Emails you're expecting a reply to", + }; + case SystemType.ACTIONED: + return { + condition: "Resolved email threads", + }; + case SystemType.COLD_EMAIL: + return { + condition: DEFAULT_COLD_EMAIL_PROMPT, + }; + default: + return null; + } +} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts b/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts index 5b3557e5b2..8b7936c573 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts +++ b/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts @@ -24,7 +24,6 @@ const ACTION_TYPE_COLORS = { [ActionType.MARK_READ]: "bg-orange-500", [ActionType.MARK_SPAM]: "bg-red-500", [ActionType.CALL_WEBHOOK]: "bg-gray-500", - [ActionType.TRACK_THREAD]: "bg-indigo-500", [ActionType.DIGEST]: "bg-teal-500", [ActionType.MOVE_FOLDER]: "bg-emerald-500", } as const; @@ -39,7 +38,6 @@ export const ACTION_TYPE_TEXT_COLORS = { [ActionType.MARK_READ]: "text-orange-500", [ActionType.MARK_SPAM]: "text-red-500", [ActionType.CALL_WEBHOOK]: "text-gray-500", - [ActionType.TRACK_THREAD]: "text-indigo-500", [ActionType.DIGEST]: "text-teal-500", [ActionType.MOVE_FOLDER]: "text-emerald-500", } as const; @@ -54,7 +52,6 @@ export const ACTION_TYPE_ICONS = { [ActionType.MARK_READ]: MailOpenIcon, [ActionType.MARK_SPAM]: ShieldCheckIcon, [ActionType.CALL_WEBHOOK]: WebhookIcon, - [ActionType.TRACK_THREAD]: EyeIcon, [ActionType.DIGEST]: FileTextIcon, [ActionType.MOVE_FOLDER]: FolderInputIcon, } as const; diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/group/LearnedPatterns.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/group/LearnedPatterns.tsx index 6b9460f27d..ce78cb4b7e 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/group/LearnedPatterns.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/group/LearnedPatterns.tsx @@ -21,9 +21,11 @@ import { Skeleton } from "@/components/ui/skeleton"; export function LearnedPatternsDialog({ ruleId, groupId, + disabled, }: { ruleId: string; groupId: string | null; + disabled?: boolean; }) { const { emailAccountId } = useAccount(); @@ -58,6 +60,7 @@ export function LearnedPatternsDialog({ variant="outline" size="sm" Icon={BrainIcon} + disabled={disabled} onClick={async () => { if (!ruleId) return; if (groupId) return; diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx deleted file mode 100644 index 5ac186de7e..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { Toggle } from "@/components/Toggle"; -import { updateReplyTrackingAction } from "@/utils/actions/settings"; -import { toastError, toastSuccess } from "@/components/Toast"; -import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; -import { useRules } from "@/hooks/useRules"; -import { LoadingContent } from "@/components/LoadingContent"; -import { Skeleton } from "@/components/ui/skeleton"; -import { SettingCard } from "@/components/SettingCard"; -import { - AWAITING_REPLY_LABEL_NAME, - NEEDS_REPLY_LABEL_NAME, -} from "@/utils/reply-tracker/consts"; -import { useAccount } from "@/providers/EmailAccountProvider"; -import { getEmailTerminology } from "@/utils/terminology"; - -export function AwaitingReplySetting() { - const { provider, isLoading: accountLoading } = useAccount(); - const { - data: emailAccountData, - isLoading, - error, - mutate, - } = useEmailAccountFull(); - const { mutate: mutateRules } = useRules(); - - const enabled = emailAccountData?.outboundReplyTracking ?? false; - const terminology = getEmailTerminology(provider); - - const handleToggle = useCallback( - async (enable: boolean) => { - if (!emailAccountData) return null; - - // Optimistically update the UI - mutate( - emailAccountData - ? { ...emailAccountData, outboundReplyTracking: enable } - : undefined, - false, - ); - - const result = await updateReplyTrackingAction(emailAccountData.id, { - enabled: enable, - }); - - if (result?.serverError) { - mutate(); // Revert optimistic update - toastError({ description: result.serverError }); - return; - } - - toastSuccess({ - description: `Reply tracking ${enable ? "enabled" : "disabled"}`, - }); - - await Promise.allSettled([mutate(), mutateRules()]); - }, - [emailAccountData, mutate, mutateRules], - ); - - return ( - } - > - - - } - /> - ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx index b4dd1c7a08..93deb3041b 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx @@ -2,23 +2,19 @@ import { AboutSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/Ab import { DigestSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/DigestSetting"; import { DraftReplies } from "@/app/(app)/[emailAccountId]/assistant/settings/DraftReplies"; import { DraftKnowledgeSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/DraftKnowledgeSetting"; -import { AwaitingReplySetting } from "@/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting"; import { ReferralSignatureSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/ReferralSignatureSetting"; import { LearnedPatternsSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/LearnedPatternsSetting"; import { PersonalSignatureSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/PersonalSignatureSetting"; -import { SystemLabelsSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting"; export function SettingsTab() { return (
- -
); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx deleted file mode 100644 index bb8af4de5a..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx +++ /dev/null @@ -1,231 +0,0 @@ -"use client"; - -import { useState, useMemo } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { SettingCard } from "@/components/SettingCard"; -import { Button } from "@/components/ui/button"; -import { toastError, toastSuccess } from "@/components/Toast"; -import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; -import { LoadingContent } from "@/components/LoadingContent"; -import { updateSystemLabelsAction } from "@/utils/actions/settings"; -import { - updateSystemLabelsBody, - type UpdateSystemLabelsBody, -} from "@/utils/actions/settings.validation"; -import { useLabels } from "@/hooks/useLabels"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Label } from "@/components/Input"; -import { LabelCombobox } from "@/components/LabelCombobox"; -import { SettingsIcon } from "lucide-react"; -import { - NEEDS_REPLY_LABEL_NAME, - AWAITING_REPLY_LABEL_NAME, -} from "@/utils/reply-tracker/consts"; -import { inboxZeroLabels } from "@/utils/label"; - -export function SystemLabelsSetting() { - const [isOpen, setIsOpen] = useState(false); - - return ( - - - - - {isOpen && ( - setIsOpen(false)} /> - )} - - } - /> - ); -} - -function SystemLabelsDialogContent({ onClose }: { onClose: () => void }) { - const { - data: emailAccountData, - isLoading: isLoadingAccount, - error: accountError, - mutate, - } = useEmailAccountFull(); - const { - userLabels, - isLoading: isLoadingLabels, - mutate: mutateLabels, - } = useLabels(); - - return ( - - - Configure System Labels - - - {emailAccountData && userLabels && ( - - )} - - - ); -} - -function SystemLabelsForm({ - emailAccountData, - userLabels, - isLoadingLabels, - mutate, - mutateLabels, - onClose, -}: { - emailAccountData: NonNullable["data"]>; - userLabels: NonNullable["userLabels"]>; - isLoadingLabels: boolean; - mutate: () => Promise; - mutateLabels: () => Promise; - onClose: () => void; -}) { - // Find default labels by name if not already set - const defaultValues = useMemo(() => { - const defaultNeedsReplyLabel = userLabels.find( - (l) => l.name === NEEDS_REPLY_LABEL_NAME, - ); - const defaultAwaitingReplyLabel = userLabels.find( - (l) => l.name === AWAITING_REPLY_LABEL_NAME, - ); - const defaultColdEmailLabel = userLabels.find( - (l) => l.name === inboxZeroLabels.cold_email.name, - ); - - return { - needsReplyLabelId: - emailAccountData.needsReplyLabelId ?? defaultNeedsReplyLabel?.id, - awaitingReplyLabelId: - emailAccountData.awaitingReplyLabelId ?? defaultAwaitingReplyLabel?.id, - coldEmailLabelId: - emailAccountData.coldEmailLabelId ?? defaultColdEmailLabel?.id, - }; - }, [emailAccountData, userLabels]); - - const { - watch, - setValue, - handleSubmit, - formState: { isSubmitting, isDirty }, - } = useForm({ - resolver: zodResolver(updateSystemLabelsBody), - defaultValues, - }); - - const onSubmit = async (data: UpdateSystemLabelsBody) => { - if (!emailAccountData?.id) return; - - const result = await updateSystemLabelsAction(emailAccountData.id, data); - - if (result?.serverError) { - toastError({ description: result.serverError }); - return; - } - - toastSuccess({ description: "System labels updated" }); - await mutate(); - onClose(); - }; - - return ( -
-
-
- -
-
- -
-
- - - - ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailContent.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailContent.tsx index 87bd5686a6..e386a190af 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailContent.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailContent.tsx @@ -1,61 +1,49 @@ -import { Fragment } from "react"; +"use client"; + import { ColdEmailList } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList"; -import { ColdEmailSettings } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings"; import { Card } from "@/components/ui/card"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { ColdEmailRejected } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected"; import { ColdEmailTest } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailTest"; -import { TabsToolbar } from "@/components/TabsToolbar"; -import { cn } from "@/utils"; +import { Button } from "@/components/ui/button"; +import { prefixPath } from "@/utils/path"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import Link from "next/link"; -export function ColdEmailContent({ - isInset, - searchParam, -}: { - isInset: boolean; - searchParam?: string; -}) { - const ToolbarWrapper = isInset ? TabsToolbar : Fragment; - const tabContentClassName = isInset ? "content-container" : ""; +export function ColdEmailContent({ searchParam }: { searchParam?: string }) { + const { emailAccountId } = useAccount(); return ( - - - Settings - Test - Cold Emails - Marked Not Cold - - - - - - + + Test + Cold Emails + Marked Not Cold + Settings + - + - + - + + + + + ); } diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailDialog.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailDialog.tsx index 354689d3c4..014212e048 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailDialog.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailDialog.tsx @@ -22,7 +22,7 @@ export function ColdEmailDialog({ isOpen, onClose }: ColdEmailDialogProps) { Cold Email Blocker - + ); diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx index 61ec326031..c60c094fb6 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx @@ -22,13 +22,14 @@ import { useSearchParams } from "next/navigation"; import { markNotColdEmailAction } from "@/utils/actions/cold-email"; import { Checkbox } from "@/components/Checkbox"; import { useToggleSelect } from "@/hooks/useToggleSelect"; -import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; import { ViewEmailButton } from "@/components/ViewEmailButton"; import { EmailMessageCellWithData } from "@/components/EmailMessageCell"; import { EnableFeatureCard } from "@/components/EnableFeatureCard"; import { toastError, toastSuccess } from "@/components/Toast"; import { useAccount } from "@/providers/EmailAccountProvider"; import { prefixPath } from "@/utils/path"; +import { useRules } from "@/hooks/useRules"; +import { isColdEmailBlockerEnabled } from "@/utils/cold-email/cold-email-blocker-enabled"; export function ColdEmailList() { const searchParams = useSearchParams(); @@ -185,10 +186,10 @@ function Row({ } function NoColdEmails() { - const { data } = useEmailAccountFull(); const { emailAccountId } = useAccount(); + const { data: rules } = useRules(); - if (!data?.coldEmailBlocker || data?.coldEmailBlocker === "DISABLED") { + if (!isColdEmailBlockerEnabled(rules || [])) { return (
void; -}) { - const { emailAccountId } = useAccount(); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(updateColdEmailPromptBody), - defaultValues: { - coldEmailPrompt: props.coldEmailPrompt || DEFAULT_COLD_EMAIL_PROMPT, - }, - }); - - const { onSuccess } = props; - - const onSubmit: SubmitHandler = useCallback( - async (data) => { - const result = await updateColdEmailPromptAction(emailAccountId, { - // if user hasn't changed the prompt, unset their custom prompt - coldEmailPrompt: - !data.coldEmailPrompt || - data.coldEmailPrompt === DEFAULT_COLD_EMAIL_PROMPT - ? null - : data.coldEmailPrompt, - }); - - if (result?.serverError) { - toastError({ description: "Error updating cold email prompt." }); - } else { - toastSuccess({ description: "Prompt updated!" }); - onSuccess(); - } - }, - [onSuccess, emailAccountId], - ); - - return ( -
- - -
- -
- - ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings.tsx deleted file mode 100644 index 83734eedf9..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings.tsx +++ /dev/null @@ -1,204 +0,0 @@ -"use client"; - -import { useCallback, useMemo, useEffect } from "react"; -import { Controller, type SubmitHandler, useForm } from "react-hook-form"; -import { LoadingContent } from "@/components/LoadingContent"; -import { toastError, toastSuccess } from "@/components/Toast"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { ColdEmailSetting } from "@prisma/client"; -import { Button } from "@/components/ui/button"; -import { - type UpdateColdEmailSettingsBody, - updateColdEmailSettingsBody, -} from "@/utils/actions/cold-email.validation"; -import { updateColdEmailSettingsAction } from "@/utils/actions/cold-email"; -import { ColdEmailPromptForm } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailPromptForm"; -import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; -import { useAccount } from "@/providers/EmailAccountProvider"; -import { Label } from "@/components/ui/label"; -import { isMicrosoftProvider } from "@/utils/email/provider-types"; -import { Toggle } from "@/components/Toggle"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -export function ColdEmailSettings() { - const { data, isLoading, error, mutate } = useEmailAccountFull(); - - return ( - - {data && ( -
- - -
- )} -
- ); -} - -export function ColdEmailForm({ - coldEmailBlocker, - coldEmailDigest, - buttonText, - onSuccess, -}: { - coldEmailBlocker?: ColdEmailSetting | null; - coldEmailDigest?: boolean; - buttonText?: string; - onSuccess?: () => void; -}) { - const { emailAccountId, provider } = useAccount(); - - const { - control, - handleSubmit, - reset, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(updateColdEmailSettingsBody), - defaultValues: { - coldEmailBlocker: coldEmailBlocker || ColdEmailSetting.DISABLED, - coldEmailDigest: coldEmailDigest ?? false, - }, - }); - - // Reset form when props change (when data loads) - useEffect(() => { - reset({ - coldEmailBlocker: coldEmailBlocker || ColdEmailSetting.DISABLED, - coldEmailDigest: coldEmailDigest ?? false, - }); - }, [coldEmailBlocker, coldEmailDigest, reset]); - - const onSubmit: SubmitHandler = useCallback( - async (data) => { - const result = await updateColdEmailSettingsAction(emailAccountId, data); - - if (result?.serverError) { - toastError({ - description: "There was an error updating the settings.", - }); - } else { - toastSuccess({ description: "Settings updated!" }); - onSuccess?.(); - } - }, - [onSuccess, emailAccountId], - ); - - const onSubmitForm = handleSubmit(onSubmit); - - const options: { - value: ColdEmailSetting; - label: string; - description: string; - }[] = useMemo( - () => [ - { - value: ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL, - label: isMicrosoftProvider(provider) - ? "Move to Folder & Mark Read" - : "Archive, Mark Read & Label", - description: isMicrosoftProvider(provider) - ? "Move cold emails to a folder and mark them as read" - : "Archive cold emails, mark them as read, and label them", - }, - { - value: ColdEmailSetting.ARCHIVE_AND_LABEL, - label: isMicrosoftProvider(provider) - ? "Move to Folder" - : "Archive & Label", - description: isMicrosoftProvider(provider) - ? "Move cold emails to a folder" - : "Archive cold emails and label them", - }, - { - value: ColdEmailSetting.LABEL, - label: isMicrosoftProvider(provider) ? "Categorize only" : "Label Only", - description: isMicrosoftProvider(provider) - ? "Categorize cold emails, but keep them in my inbox" - : "Label cold emails, but keep them in my inbox", - }, - { - value: ColdEmailSetting.DISABLED, - label: "Turn Off", - description: "Disable cold email blocker", - }, - ], - [provider], - ); - - return ( -
-
- - ( - - )} - /> - {errors.coldEmailBlocker && ( -

- {errors.coldEmailBlocker.message} -

- )} -
- - ( - - )} - /> - -
- -
- - ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/page.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/page.tsx index 6291ac71e8..09d2c65d16 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/page.tsx @@ -2,14 +2,24 @@ import { Suspense } from "react"; import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; import { GmailProvider } from "@/providers/GmailProvider"; import { ColdEmailContent } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailContent"; +import { PageWrapper } from "@/components/PageWrapper"; +import { PageHeader } from "@/components/PageHeader"; export default function ColdEmailBlockerPage() { return ( - - - - - - + + + + + +
+ +
+
+
+
); } diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding/StepFeatures.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding/StepFeatures.tsx index 94e95c95eb..f58016662e 100644 --- a/apps/web/app/(app)/[emailAccountId]/onboarding/StepFeatures.tsx +++ b/apps/web/app/(app)/[emailAccountId]/onboarding/StepFeatures.tsx @@ -71,7 +71,7 @@ export function StepFeatures({ onNext }: { onNext: () => void }) { Select as many as you want. -
+
{choices.map((choice) => (
- + - - - -
- - -
- - No folder found. - - {filteredFolders.map((folder) => ( - { - onChangeValue(currentValue === value ? "" : currentValue); - setOpen(false); - }} - > - - {folder.displayName} - - ))} - - -
-
- - ); -} diff --git a/apps/web/components/FolderSelector.tsx b/apps/web/components/FolderSelector.tsx index 84f39b99be..fb968da0cb 100644 --- a/apps/web/components/FolderSelector.tsx +++ b/apps/web/components/FolderSelector.tsx @@ -208,7 +208,7 @@ export function FolderSelector({ Loading folders... - ) : selectedFolder?.displayName ? ( + ) : value.id ? (
{value.name || selectedFolder?.displayName || ""} @@ -218,7 +218,7 @@ export function FolderSelector({ )}
- {selectedFolder?.displayName && !isLoading && ( + {value.id && !isLoading && (