diff --git a/apps/web/__tests__/ai-choose-rule.test.ts b/apps/web/__tests__/ai-choose-rule.test.ts index 3a7dc4a1d5..687c82da5b 100644 --- a/apps/web/__tests__/ai-choose-rule.test.ts +++ b/apps/web/__tests__/ai-choose-rule.test.ts @@ -17,7 +17,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ reason: "No rules" }); + expect(result).toEqual({ rules: [], reason: "" }); }); test("Should return correct rule when only one rule passed", async () => { @@ -31,18 +31,22 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(rule); + expect(result.rules[0].isPrimary).toBe(true); + expect(result.reason).toBeTruthy(); }); test("Should return correct rule when multiple rules passed", async () => { const rule1 = getRule( "Match emails that have the word 'test' in the subject line", + [], + "Test emails", ); const rule2 = getRule( "Match emails that have the word 'remember' in the subject line", + [], + "Remember emails", ); const result = await aiChooseRule({ @@ -51,36 +55,41 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: rule2, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(rule2); + expect(result.reason).toBeTruthy(); }); - test("Should generate action arguments", async () => { + test("Should select the correct rule and provide a reason", async () => { const rule1 = getRule( "Match emails that have the word 'question' in the subject line", + [], + "Question emails", + ); + const rule2 = getRule( + "Match emails asking for a joke", + [ + { + id: "id", + createdAt: new Date(), + updatedAt: new Date(), + type: ActionType.REPLY, + ruleId: "ruleId", + label: null, + labelId: null, + subject: null, + content: "{{Write a joke}}", + to: null, + cc: null, + bcc: null, + url: null, + folderName: null, + delayInMinutes: null, + folderId: null, + }, + ], + "Joke requests", ); - const rule2 = getRule("Match emails asking for a joke", [ - { - id: "id", - createdAt: new Date(), - updatedAt: new Date(), - type: ActionType.REPLY, - ruleId: "ruleId", - label: null, - labelId: null, - subject: null, - content: "{{Write a joke}}", - to: null, - cc: null, - bcc: null, - url: null, - folderName: null, - delayInMinutes: null, - folderId: null, - }, - ]); const result = await aiChooseRule({ rules: [rule1, rule2], @@ -91,46 +100,77 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: rule2, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(rule2); + expect(result.reason).toBeTruthy(); }); describe("Complex real-world rule scenarios", () => { const recruiters = getRule( "Match emails from recruiters or about job opportunities", + [], + "Recruiters", ); const legal = getRule( "Match emails containing legal documents or contracts", + [], + "Legal", + ); + const requiresResponse = getRule( + "Match emails requiring a response", + [], + "Requires Response", ); - const requiresResponse = getRule("Match emails requiring a response"); const productUpdates = getRule( "Match emails about product updates or feature announcements", + [], + "Product Updates", ); const financial = getRule( "Match emails containing financial information or invoices", + [], + "Financial", ); const technicalIssues = getRule( "Match emails about technical issues like server downtime or bug reports", + [], + "Technical Issues", ); const marketing = getRule( "Match emails containing marketing or promotional content", + [], + "Marketing", ); const teamUpdates = getRule( "Match emails about team updates or internal communications", + [], + "Team Updates", ); const customerFeedback = getRule( "Match emails about customer feedback or support requests", + [], + "Customer Feedback", ); const events = getRule( "Match emails containing event invitations or RSVPs", + [], + "Events", ); const projectDeadlines = getRule( "Match emails about project deadlines or milestones", + [], + "Project Deadlines", + ); + const urgent = getRule( + "Match urgent emails requiring immediate attention", + [], + "Urgent", + ); + const catchAll = getRule( + "Match emails that don't fit any other category", + [], + "Catch All", ); - const urgent = getRule("Match urgent emails requiring immediate attention"); - const catchAll = getRule("Match emails that don't fit any other category"); const rules = [ recruiters, @@ -159,10 +199,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: requiresResponse, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(requiresResponse); + expect(result.reason).toBeTruthy(); }); test("Should match technical issues", async () => { @@ -176,10 +215,24 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: technicalIssues, - reason: expect.any(String), - }); + // Log if multiple rules were matched + if (result.rules.length > 1) { + console.log("⚠️ Technical Issues test matched multiple rules:"); + console.log( + result.rules.map((r) => ({ + name: r.rule.name, + isPrimary: r.isPrimary, + })), + ); + console.log("Reasoning:", result.reason); + } + + // AI may match multiple rules (e.g., Technical Issues + Urgent) + // Verify the primary match is Technical Issues + const primaryRule = result.rules.find((r) => r.isPrimary); + expect(primaryRule).toBeDefined(); + expect(primaryRule?.rule).toEqual(technicalIssues); + expect(result.reason).toBeTruthy(); }); test("Should match financial emails", async () => { @@ -192,10 +245,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: financial, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(financial); + expect(result.reason).toBeTruthy(); }); test("Should match recruiter emails", async () => { @@ -209,10 +261,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: recruiters, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(recruiters); + expect(result.reason).toBeTruthy(); }); test("Should match legal documents", async () => { @@ -225,10 +276,24 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: legal, - reason: expect.any(String), - }); + // Log if multiple rules were matched + if (result.rules.length > 1) { + console.log("⚠️ Legal Documents test matched multiple rules:"); + console.log( + result.rules.map((r) => ({ + name: r.rule.name, + isPrimary: r.isPrimary, + })), + ); + console.log("Reasoning:", result.reason); + } + + // AI may match multiple rules (e.g., Legal + Requires Response) + // Verify the primary match is Legal + const primaryRule = result.rules.find((r) => r.isPrimary); + expect(primaryRule).toBeDefined(); + expect(primaryRule?.rule).toEqual(legal); + expect(result.reason).toBeTruthy(); }); test("Should match emails requiring response", async () => { @@ -241,10 +306,26 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: requiresResponse, - reason: expect.any(String), - }); + // Log if multiple rules were matched + if (result.rules.length > 1) { + console.log( + "⚠️ Emails Requiring Response test matched multiple rules:", + ); + console.log( + result.rules.map((r) => ({ + name: r.rule.name, + isPrimary: r.isPrimary, + })), + ); + console.log("Reasoning:", result.reason); + } + + // AI may match multiple rules (e.g., Requires Response + Team Updates) + // Verify the primary match is Requires Response + const primaryRule = result.rules.find((r) => r.isPrimary); + expect(primaryRule).toBeDefined(); + expect(primaryRule?.rule).toEqual(requiresResponse); + expect(result.reason).toBeTruthy(); }); test("Should match product updates", async () => { @@ -257,10 +338,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: productUpdates, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(productUpdates); + expect(result.reason).toBeTruthy(); }); test("Should match marketing emails", async () => { @@ -273,10 +353,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: marketing, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(marketing); + expect(result.reason).toBeTruthy(); }); test("Should match team updates", async () => { @@ -289,10 +368,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: teamUpdates, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(teamUpdates); + expect(result.reason).toBeTruthy(); }); test("Should match customer feedback", async () => { @@ -305,10 +383,24 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: customerFeedback, - reason: expect.any(String), - }); + // Log if multiple rules were matched + if (result.rules.length > 1) { + console.log("⚠️ Customer Feedback test matched multiple rules:"); + console.log( + result.rules.map((r) => ({ + name: r.rule.name, + isPrimary: r.isPrimary, + })), + ); + console.log("Reasoning:", result.reason); + } + + // AI may match multiple rules (e.g., Customer Feedback + Technical Issues + Requires Response) + // Verify the primary match is Customer Feedback + const primaryRule = result.rules.find((r) => r.isPrimary); + expect(primaryRule).toBeDefined(); + expect(primaryRule?.rule).toEqual(customerFeedback); + expect(result.reason).toBeTruthy(); }); test("Should match event invitations", async () => { @@ -321,10 +413,55 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: events, - reason: expect.any(String), + // Log if multiple rules were matched + if (result.rules.length > 1) { + console.log("⚠️ Event Invitations test matched multiple rules:"); + console.log( + result.rules.map((r) => ({ + name: r.rule.name, + isPrimary: r.isPrimary, + })), + ); + console.log("Reasoning:", result.reason); + } + + // AI may match multiple rules (e.g., Events + Requires Response) + // Verify the primary match is Events + const primaryRule = result.rules.find((r) => r.isPrimary); + expect(primaryRule).toBeDefined(); + expect(primaryRule?.rule).toEqual(events); + expect(result.reason).toBeTruthy(); + }); + + test("Should return no match when email doesn't fit any rule", async () => { + // Use a subset of rules WITHOUT the catch-all rule to test true no-match scenario + const rulesWithoutCatchAll = [ + recruiters, + legal, + productUpdates, + financial, + technicalIssues, + marketing, + teamUpdates, + customerFeedback, + events, + projectDeadlines, + ]; + + const result = await aiChooseRule({ + rules: rulesWithoutCatchAll, + email: getEmail({ + subject: "Weather Update: Sunny skies ahead", + content: + "Today's forecast: Clear skies with temperatures reaching 75°F. Perfect day for outdoor activities!\n\nUV Index: Moderate\nWind: 5-10 mph", + }), + emailAccount: getEmailAccount(), }); + + // This is a weather notification that doesn't match any of our business rules + // Should return empty array with no reason + expect(result.rules).toEqual([]); + expect(result.reason).toBe(""); }); }); }); diff --git a/apps/web/__tests__/helpers.ts b/apps/web/__tests__/helpers.ts index 439f56ad34..9c418220fd 100644 --- a/apps/web/__tests__/helpers.ts +++ b/apps/web/__tests__/helpers.ts @@ -41,10 +41,14 @@ export function getEmail({ }; } -export function getRule(instructions: string, actions: Action[] = []) { +export function getRule( + instructions: string, + actions: Action[] = [], + name?: string, +) { return { instructions, - name: "Joke requests", + name: name || "Joke requests", actions, id: "id", userId: "userId", @@ -57,7 +61,6 @@ export function getRule(instructions: string, actions: Action[] = []) { body: null, to: null, enabled: true, - categoryFilterType: null, conditionalOperator: LogicalOperator.AND, }; } diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ConditionSummaryCard.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ConditionSummaryCard.tsx index a3a62a1417..e945c8aae7 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ConditionSummaryCard.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ConditionSummaryCard.tsx @@ -1,16 +1,12 @@ import { BotIcon, FilterIcon } from "lucide-react"; -import { capitalCase } from "capital-case"; import type { CreateRuleBody } from "@/utils/actions/rule.validation"; import { ConditionType } from "@/utils/config"; import { CardBasic } from "@/components/ui/card"; -import { CategoryFilterType } from "@prisma/client"; export function ConditionSummaryCard({ condition, - categories, }: { condition: CreateRuleBody["conditions"][number]; - categories?: Array<{ id: string; name: string }>; }) { let summaryContent: React.ReactNode = condition.type; let Icon = FilterIcon; @@ -57,36 +53,6 @@ export function ConditionSummaryCard({ break; } - case ConditionType.CATEGORY: { - textColorClass = "text-green-500"; - const filterType = - condition.categoryFilterType || CategoryFilterType.INCLUDE; - const categoryFilters = condition.categoryFilters || []; - - if (categoryFilters.length > 0 && categories && categories.length > 0) { - const categoryNames = categoryFilters - .map((id) => { - const category = categories.find((cat) => cat.id === id); - return category ? capitalCase(category.name) : null; - }) - .filter(Boolean) - .join(", "); - - summaryContent = ( - <> - Category Condition - - {filterType === CategoryFilterType.INCLUDE ? "Match" : "Skip"}{" "} - categories: {categoryNames || "Unknown"} - - - ); - } else { - summaryContent = "Category Condition (no categories selected)"; - } - break; - } - default: summaryContent = `${condition.type} Condition`; } diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx index 946d66465a..e0fc88f0cd 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { ExternalLinkIcon, EyeIcon } from "lucide-react"; -import type { PlanHistoryResponse } from "@/app/api/user/planned/history/route"; +import type { GetExecutedRulesResponse } from "@/app/api/user/executed-rules/history/route"; import { decodeSnippet } from "@/utils/gmail/decode"; import { ActionBadgeExpanded } from "@/components/PlanBadge"; import { Tooltip } from "@/components/Tooltip"; @@ -63,12 +63,14 @@ export function EmailCell({ export function RuleCell({ rule, + executedAt, status, reason, message, setInput, }: { - rule: PlanHistoryResponse["executedRules"][number]["rule"]; + rule: GetExecutedRulesResponse["executedRules"][number]["rule"]; + executedAt: Date; status: ExecutedRuleStatus; reason?: string | null; message: ParsedMessage; @@ -136,7 +138,7 @@ export function RuleCell({ @@ -147,7 +149,7 @@ export function ActionItemsCell({ actionItems, provider, }: { - actionItems: PlanHistoryResponse["executedRules"][number]["actionItems"]; + actionItems: GetExecutedRulesResponse["executedRules"][number]["actionItems"]; provider: string; }) { return ( diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx index e863bd0ffd..9534436d06 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx @@ -1,9 +1,8 @@ import { MessageCircleIcon } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import type { ParsedMessage } from "@/utils/types"; import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; -import { truncate } from "@/utils/string"; import { Dialog, DialogContent, @@ -23,15 +22,21 @@ import { NONE_RULE_ID } from "@/app/(app)/[emailAccountId]/assistant/consts"; import { useSidebar } from "@/components/ui/sidebar"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; +import { useChat } from "@/providers/ChatProvider"; +import { + NEW_RULE_ID as CONST_NEW_RULE_ID, + NONE_RULE_ID as CONST_NONE_RULE_ID, +} from "@/app/(app)/[emailAccountId]/assistant/consts"; +import type { MessageContext } from "@/app/api/chat/validation"; export function FixWithChat({ setInput, message, - result, + results, }: { setInput: (input: string) => void; message: ParsedMessage; - result: RunRulesResult | null; + results: RunRulesResult[]; }) { const { data, isLoading, error } = useRules(); const { isModalOpen, setIsModalOpen } = useModal(); @@ -40,6 +45,14 @@ export function FixWithChat({ const [showExplanation, setShowExplanation] = useState(false); const { setOpen } = useSidebar(); + const { setContext } = useChat(); + + const selectedRuleName = useMemo(() => { + if (!data) return null; + if (selectedRuleId === NEW_RULE_ID) return "New rule"; + if (selectedRuleId === NONE_RULE_ID) return "None"; + return data.find((r) => r.id === selectedRuleId)?.name ?? null; + }, [data, selectedRuleId]); const handleRuleSelect = (ruleId: string | null) => { setSelectedRuleId(ruleId); @@ -50,24 +63,55 @@ export function FixWithChat({ if (!selectedRuleId) return; let input: string; - if (selectedRuleId === NEW_RULE_ID) { - input = getFixMessage({ - message, - result, - expectedRuleName: NEW_RULE_ID, - explanation, - }); + + if (selectedRuleId === CONST_NEW_RULE_ID) { + input = explanation?.trim() + ? `Create a new rule for emails like this: ${explanation.trim()}` + : "Create a new rule for emails like this: "; + } else if (selectedRuleId === CONST_NONE_RULE_ID) { + input = explanation?.trim() + ? `This email shouldn't have matched any rule because ${explanation.trim()}` + : "This email shouldn't have matched any rule because "; } else { - const expectedRule = data?.find((rule) => rule.id === selectedRuleId); - - input = getFixMessage({ - message, - result, - expectedRuleName: expectedRule?.name ?? null, - explanation, - }); + const rulePart = selectedRuleName + ? `the "${selectedRuleName}" rule` + : "a different rule"; + input = explanation?.trim() + ? `This email should have matched ${rulePart} because ${explanation.trim()}` + : `This email should have matched ${rulePart} because `; } + const context: MessageContext = { + type: "fix-rule", + message: { + id: message.id, + threadId: message.threadId, + snippet: message.snippet, + textPlain: message.textPlain, + textHtml: message.textHtml, + headers: { + from: message.headers.from, + to: message.headers.to, + subject: message.headers.subject, + cc: message.headers.cc, + date: message.headers.date, + "reply-to": message.headers["reply-to"], + }, + internalDate: message.internalDate, + }, + results: results.map((r) => ({ + ruleName: r.rule?.name ?? null, + reason: r.reason ?? "", + })), + expected: + selectedRuleId === CONST_NEW_RULE_ID + ? "new" + : selectedRuleId === CONST_NONE_RULE_ID + ? "none" + : { name: selectedRuleName || "Unknown" }, + }; + setContext(context); + setInput(input); setOpen((arr) => [...arr, "chat-sidebar"]); setIsModalOpen(false); @@ -105,7 +149,7 @@ export function FixWithChat({ {data && !showExplanation ? ( @@ -165,52 +209,12 @@ export function FixWithChat({ ); } -function getFixMessage({ - message, - result, - expectedRuleName, - explanation, -}: { - message: ParsedMessage; - result: RunRulesResult | null; - expectedRuleName: string | null; - explanation?: string; -}) { - // Truncate content if it's too long - // TODO: HTML text / text plain - const getMessageContent = () => { - const content = message.snippet || message.textPlain || ""; - return truncate(content, 500).trim(); - }; - - return `You applied the wrong rule to this email. -Fix our rules so this type of email is handled correctly in the future. - -Email details: -*From*: ${message.headers.from} -*Subject*: ${message.headers.subject} -*Content*: ${getMessageContent()} - -Current rule applied: ${result?.rule?.name || "No rule"} - -Reason the rule was chosen: -${result?.reason || "-"} - -${ - expectedRuleName === NEW_RULE_ID - ? "I'd like to create a new rule to handle this type of email." - : expectedRuleName - ? `The rule that should have been applied was: "${expectedRuleName}"` - : "Instead, no rule should have been applied." -}${explanation ? `\n\nExplanation: ${explanation}` : ""}`.trim(); -} - function RuleMismatch({ - result, + results, rules, onSelectExpectedRuleId, }: { - result: RunRulesResult | null; + results: RunRulesResult[]; rules: RulesResponse; onSelectExpectedRuleId: (ruleId: string | null) => void; }) { @@ -218,8 +222,8 @@ function RuleMismatch({
)} - {history.categoryFilterType && ( -
-

Category Filters

-

- Type: {history.categoryFilterType} - {history.categoryFilters && ( - - ({(history.categoryFilters as any[]).length}{" "} - categories) - - )} -

-
- )} - {history.systemType && (

System Type

diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index b95868994d..0af68609c0 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -9,6 +9,7 @@ import prisma from "@/utils/prisma"; import type { Prisma } from "@prisma/client"; import { convertToUIMessages } from "@/components/assistant-chat/helpers"; import { captureException } from "@/utils/error"; +import { messageContextSchema } from "@/app/api/chat/validation"; export const maxDuration = 120; @@ -26,6 +27,7 @@ const assistantInputSchema = z.object({ role: z.enum(["user"]), parts: z.array(textPartSchema), }), + context: messageContextSchema.optional(), }); export const POST = withEmailAccount(async (request) => { @@ -58,7 +60,7 @@ export const POST = withEmailAccount(async (request) => { ); } - const { message } = data; + const { message, context } = data; const uiMessages = [...convertToUIMessages(chat), message]; await saveChatMessage({ @@ -73,6 +75,7 @@ export const POST = withEmailAccount(async (request) => { messages: convertToModelMessages(uiMessages), emailAccountId, user, + context, }); return result.toUIMessageStreamResponse({ diff --git a/apps/web/app/api/chat/validation.ts b/apps/web/app/api/chat/validation.ts new file mode 100644 index 0000000000..005352e400 --- /dev/null +++ b/apps/web/app/api/chat/validation.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +const parsedMessageSchema = z.object({ + id: z.string(), + threadId: z.string(), + snippet: z.string(), + textPlain: z.string().optional(), + textHtml: z.string().optional(), + headers: z.object({ + from: z.string(), + to: z.string(), + subject: z.string(), + cc: z.string().optional(), + date: z.string(), + "reply-to": z.string().optional(), + }), + internalDate: z.string().optional().nullable(), +}); + +export const messageContextSchema = z.object({ + type: z.literal("fix-rule"), + message: parsedMessageSchema, + results: z.array( + z.object({ ruleName: z.string().nullable(), reason: z.string() }), + ), + expected: z.union([ + z.literal("new"), + z.literal("none"), + z.object({ name: z.string() }), + ]), +}); +export type MessageContext = z.infer; diff --git a/apps/web/app/api/google/webhook/types.ts b/apps/web/app/api/google/webhook/types.ts index 6fd1903d71..4ff63b9f61 100644 --- a/apps/web/app/api/google/webhook/types.ts +++ b/apps/web/app/api/google/webhook/types.ts @@ -1,5 +1,5 @@ import type { gmail_v1 } from "@googleapis/gmail"; -import type { RuleWithActionsAndCategories } from "@/utils/types"; +import type { RuleWithActions } from "@/utils/types"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailAccount } from "@prisma/client"; @@ -16,7 +16,7 @@ export type ProcessHistoryOptions = { history: gmail_v1.Schema$History[]; gmail: gmail_v1.Gmail; accessToken: string; - rules: RuleWithActionsAndCategories[]; + rules: RuleWithActions[]; hasAutomationRules: boolean; hasAiAccess: boolean; emailAccount: Pick & diff --git a/apps/web/app/api/outlook/webhook/process-history-item.ts b/apps/web/app/api/outlook/webhook/process-history-item.ts index bedb5fc0da..d851d56844 100644 --- a/apps/web/app/api/outlook/webhook/process-history-item.ts +++ b/apps/web/app/api/outlook/webhook/process-history-item.ts @@ -1,14 +1,14 @@ import type { OutlookResourceData } from "@/app/api/outlook/webhook/types"; import { logger as globalLogger } from "@/app/api/outlook/webhook/logger"; import { processHistoryItem as processHistoryItemShared } from "@/utils/webhook/process-history-item"; -import type { RuleWithActionsAndCategories } from "@/utils/types"; +import type { RuleWithActions } from "@/utils/types"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailAccount } from "@prisma/client"; import type { EmailProvider } from "@/utils/email/types"; type ProcessHistoryOptions = { provider: EmailProvider; - rules: RuleWithActionsAndCategories[]; + rules: RuleWithActions[]; hasAutomationRules: boolean; hasAiAccess: boolean; emailAccount: Pick & diff --git a/apps/web/app/api/threads/route.ts b/apps/web/app/api/threads/route.ts index e3a6a15c7a..1a56016c2b 100644 --- a/apps/web/app/api/threads/route.ts +++ b/apps/web/app/api/threads/route.ts @@ -3,8 +3,6 @@ import { withEmailProvider } from "@/utils/middleware"; import { type ThreadsQuery, threadsQuery } from "@/app/api/threads/validation"; import { isDefined } from "@/utils/types"; import prisma from "@/utils/prisma"; -import { getCategory } from "@/utils/redis/category"; -import { ExecutedRuleStatus } from "@prisma/client"; import { createScopedLogger } from "@/utils/logger"; import { isIgnoredSender } from "@/utils/filter-ignored-senders"; import type { EmailProvider } from "@/utils/email/types"; @@ -109,7 +107,6 @@ async function getThreads({ messages: filteredMessages, snippet: thread.snippet, plan, - category: await getCategory({ emailAccountId, threadId: thread.id }), }; }), ); diff --git a/apps/web/app/api/user/complete-registration/route.ts b/apps/web/app/api/user/complete-registration/route.ts index 4963243824..60a017685e 100644 --- a/apps/web/app/api/user/complete-registration/route.ts +++ b/apps/web/app/api/user/complete-registration/route.ts @@ -86,10 +86,9 @@ async function storePosthogSignupEvent(userId: string, email: string) { const ONE_HOUR_AGO = new Date(Date.now() - ONE_HOUR_MS); if (userCreatedAt.createdAt < ONE_HOUR_AGO) { - logger.error( - "storePosthogSignupEvent: User created more than an hour ago", - { userId }, - ); + logger.warn("storePosthogSignupEvent: User created more than an hour ago", { + userId, + }); return; } diff --git a/apps/web/app/api/user/executed-rules/batch/route.ts b/apps/web/app/api/user/executed-rules/batch/route.ts index ebdd5f68a5..b5ef82bb18 100644 --- a/apps/web/app/api/user/executed-rules/batch/route.ts +++ b/apps/web/app/api/user/executed-rules/batch/route.ts @@ -27,15 +27,19 @@ async function getData({ actionItems: true, rule: true, status: true, + createdAt: true, }, orderBy: { id: "asc" }, }); // Convert to a map for easy lookup by messageId - const rulesMap: Record = {}; + const rulesMap: Record = {}; for (const executedRule of executedRules) { - rulesMap[executedRule.messageId] = executedRule; + if (!rulesMap[executedRule.messageId]) { + rulesMap[executedRule.messageId] = []; + } + rulesMap[executedRule.messageId].push(executedRule); } return { rulesMap }; diff --git a/apps/web/app/api/user/planned/get-executed-rules.ts b/apps/web/app/api/user/executed-rules/history/route.ts similarity index 60% rename from apps/web/app/api/user/planned/get-executed-rules.ts rename to apps/web/app/api/user/executed-rules/history/route.ts index e4d5bbd75c..ee5ecba4f4 100644 --- a/apps/web/app/api/user/planned/get-executed-rules.ts +++ b/apps/web/app/api/user/executed-rules/history/route.ts @@ -1,33 +1,61 @@ +import { NextResponse } from "next/server"; +import { withEmailProvider } from "@/utils/middleware"; import { isDefined } from "@/utils/types"; import prisma from "@/utils/prisma"; -import { ExecutedRuleStatus } from "@prisma/client"; +import { ExecutedRuleStatus, type Prisma } from "@prisma/client"; import { createScopedLogger } from "@/utils/logger"; import type { EmailProvider } from "@/utils/email/types"; -const logger = createScopedLogger("api/user/planned/get-executed-rules"); - const LIMIT = 50; -export async function getExecutedRules({ - status, +export const dynamic = "force-dynamic"; + +export type GetExecutedRulesResponse = Awaited< + ReturnType +>; + +export const GET = withEmailProvider(async (request) => { + const emailAccountId = request.auth.emailAccountId; + + const url = new URL(request.url); + const page = Number.parseInt(url.searchParams.get("page") || "1"); + const ruleId = url.searchParams.get("ruleId") || "all"; + + const result = await getExecutedRules({ + page, + ruleId, + emailAccountId, + emailProvider: request.emailProvider, + }); + + return NextResponse.json(result); +}); + +async function getExecutedRules({ page, ruleId, emailAccountId, emailProvider, }: { - status: ExecutedRuleStatus; page: number; ruleId?: string; emailAccountId: string; emailProvider: EmailProvider; }) { - const where = { + const logger = createScopedLogger("api/user/executed-rules/history").with({ + emailAccountId, + ruleId, + }); + + const where: Prisma.ExecutedRuleWhereInput = { emailAccountId, - status: ruleId === "skipped" ? ExecutedRuleStatus.SKIPPED : status, + status: + ruleId === "skipped" + ? ExecutedRuleStatus.SKIPPED + : ExecutedRuleStatus.APPLIED, rule: ruleId === "skipped" ? undefined : { isNot: null }, ruleId: ruleId === "all" || ruleId === "skipped" ? undefined : ruleId, }; - logger.info("getExecutedRules query", { where }); const [executedRules, total] = await Promise.all([ prisma.executedRule.findMany({ @@ -42,7 +70,6 @@ export async function getExecutedRules({ rule: { include: { group: { select: { name: true } }, - categoryFilters: true, }, }, actionItems: true, @@ -67,8 +94,6 @@ export async function getExecutedRules({ error, messageId: p.messageId, threadId: p.threadId, - emailAccountId, - ruleId, }); } }), diff --git a/apps/web/app/api/user/planned/history/route.ts b/apps/web/app/api/user/planned/history/route.ts deleted file mode 100644 index aaa2889f9a..0000000000 --- a/apps/web/app/api/user/planned/history/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NextResponse } from "next/server"; -import { withEmailProvider } from "@/utils/middleware"; -import { ExecutedRuleStatus } from "@prisma/client"; -import { getExecutedRules } from "@/app/api/user/planned/get-executed-rules"; - -export const dynamic = "force-dynamic"; - -export type PlanHistoryResponse = Awaited>; - -export const GET = withEmailProvider(async (request) => { - const emailAccountId = request.auth.emailAccountId; - - const url = new URL(request.url); - const page = Number.parseInt(url.searchParams.get("page") || "1"); - const ruleId = url.searchParams.get("ruleId") || "all"; - - const messages = await getExecutedRules({ - status: ExecutedRuleStatus.APPLIED, - page, - ruleId, - emailAccountId, - emailProvider: request.emailProvider, - }); - - return NextResponse.json(messages); -}); diff --git a/apps/web/app/api/user/rules/[id]/example/controller.ts b/apps/web/app/api/user/rules/[id]/example/controller.ts index d387fadb7c..5cb529d9f3 100644 --- a/apps/web/app/api/user/rules/[id]/example/controller.ts +++ b/apps/web/app/api/user/rules/[id]/example/controller.ts @@ -7,12 +7,7 @@ import { splitEmailPatterns, } from "@/utils/ai/choose-rule/match-rules"; import { fetchPaginatedMessages } from "@/app/api/user/group/[groupId]/messages/controller"; -import { - isGroupRule, - isAIRule, - isStaticRule, - isCategoryRule, -} from "@/utils/condition"; +import { isGroupRule, isAIRule, isStaticRule } from "@/utils/condition"; import { LogicalOperator } from "@prisma/client"; import type { EmailProvider } from "@/utils/email/types"; @@ -23,13 +18,12 @@ export async function fetchExampleMessages( const isStatic = isStaticRule(rule); const isGroup = isGroupRule(rule); const isAI = isAIRule(rule); - const isCategory = isCategoryRule(rule); - if (isAI || isCategory) return []; + if (isAI) return []; // if AND and more than 1 condition, return [] // TODO: handle multiple conditions properly and return real examples - const conditions = [isStatic, isGroup, isAI, isCategory]; + const conditions = [isStatic, isGroup, isAI]; const trueConditionsCount = conditions.filter(Boolean).length; if ( diff --git a/apps/web/app/api/user/rules/[id]/route.ts b/apps/web/app/api/user/rules/[id]/route.ts index 3952dcd2b9..4b336ba1a6 100644 --- a/apps/web/app/api/user/rules/[id]/route.ts +++ b/apps/web/app/api/user/rules/[id]/route.ts @@ -18,7 +18,6 @@ async function getRule({ where: { id: ruleId, emailAccount: { id: emailAccountId } }, include: { actions: true, - categoryFilters: true, }, }); @@ -42,7 +41,6 @@ async function getRule({ folderName: { value: action.folderName }, folderId: { value: action.folderId }, })), - categoryFilters: rule.categoryFilters.map((category) => category.id), conditions: getConditions(rule), }; diff --git a/apps/web/app/api/user/rules/route.ts b/apps/web/app/api/user/rules/route.ts index 7d2bae0e9a..49c18fb37c 100644 --- a/apps/web/app/api/user/rules/route.ts +++ b/apps/web/app/api/user/rules/route.ts @@ -10,7 +10,6 @@ async function getRules({ emailAccountId }: { emailAccountId: string }) { include: { actions: true, group: { select: { name: true } }, - categoryFilters: { select: { id: true, name: true } }, }, orderBy: { createdAt: "asc" }, }); diff --git a/apps/web/components/CategoryBadge.tsx b/apps/web/components/CategoryBadge.tsx deleted file mode 100644 index 2a50299467..0000000000 --- a/apps/web/components/CategoryBadge.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Badge, type Color } from "@/components/Badge"; -import { capitalCase } from "capital-case"; - -const categoryColors: Record = { - NEWSLETTER: "blue", - PROMOTIONAL: "yellow", - RECEIPT: "yellow", - ALERT: "yellow", - NOTIFICATION: "yellow", - FORUM: "yellow", - EVENT: "green", - TRAVEL: "green", - QUESTION: "red", - SUPPORT: "red", - COLD_EMAIL: "yellow", - SOCIAL_MEDIA: "yellow", - LEGAL_UPDATE: "yellow", - OTHER: "yellow", -}; - -export function CategoryBadge(props: { category?: string }) { - const { category } = props; - - return ( - - {capitalCase(category || "Uncategorized")} - - ); -} diff --git a/apps/web/components/ExpandableText.tsx b/apps/web/components/ExpandableText.tsx index 28c9b34123..b1d98c9db9 100644 --- a/apps/web/components/ExpandableText.tsx +++ b/apps/web/components/ExpandableText.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { cn } from "@/utils"; export function ExpandableText({ diff --git a/apps/web/components/GroupedTable.tsx b/apps/web/components/GroupedTable.tsx index 169ffd8811..2d0274eca3 100644 --- a/apps/web/components/GroupedTable.tsx +++ b/apps/web/components/GroupedTable.tsx @@ -16,12 +16,9 @@ import { ChevronRight, MoreVerticalIcon, PencilIcon, - FileCogIcon, - PlusIcon, BookmarkXIcon, } from "lucide-react"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; -import { ConditionType } from "@/utils/config"; import { EmailCell } from "@/components/EmailCell"; import { useThreads } from "@/hooks/useThreads"; import { Skeleton } from "@/components/ui/skeleton"; @@ -58,7 +55,6 @@ import type { CategoryWithRules } from "@/utils/category.server"; import { ViewEmailButton } from "@/components/ViewEmailButton"; import { CategorySelect } from "@/components/CategorySelect"; import { useAccount } from "@/providers/EmailAccountProvider"; -import { prefixPath } from "@/utils/path"; const COLUMNS = 4; @@ -247,7 +243,6 @@ export function GroupedTable({ return ( - {category.rules.length ? ( -
- {category.rules.map((rule) => ( - - ))} -
- ) : ( - - )}
); diff --git a/apps/web/components/assistant-chat/chat.tsx b/apps/web/components/assistant-chat/chat.tsx index 7763a057c0..1c9545e159 100644 --- a/apps/web/components/assistant-chat/chat.tsx +++ b/apps/web/components/assistant-chat/chat.tsx @@ -26,7 +26,16 @@ import { useLocalStorage } from "usehooks-ts"; const MAX_MESSAGES = 20; export function Chat() { - const { chat, chatId, input, setInput, handleSubmit, setNewChat } = useChat(); + const { + chat, + chatId, + input, + setInput, + handleSubmit, + setNewChat, + context, + setContext, + } = useChat(); const { messages, status, stop, regenerate, setMessages } = chat; const [localStorageInput, setLocalStorageInput] = useLocalStorage( "input", @@ -81,6 +90,22 @@ export function Chat() { />
+ {context ? ( +
+ + Fix: {context.message.headers.subject.slice(0, 60)} + {context.message.headers.subject.length > 60 ? "..." : ""} + + +
+ ) : null} { e.preventDefault(); @@ -105,7 +130,7 @@ export function Chat() { ? "submitted" : "ready" } - disabled={!input.trim() || status !== "ready"} + disabled={(!input.trim() && !context) || status !== "ready"} className="absolute bottom-1 right-1" onClick={(e) => { if (status === "streaming") { diff --git a/apps/web/components/email-list/EmailList.tsx b/apps/web/components/email-list/EmailList.tsx index 6142d0bc9d..bd6fb549a8 100644 --- a/apps/web/components/email-list/EmailList.tsx +++ b/apps/web/components/email-list/EmailList.tsx @@ -2,8 +2,6 @@ import { useCallback, useRef, useState, useMemo } from "react"; import { useQueryState } from "nuqs"; -import countBy from "lodash/countBy"; -import { capitalCase } from "capital-case"; import Link from "next/link"; import { toast } from "sonner"; import { ChevronsDownIcon } from "lucide-react"; @@ -52,13 +50,6 @@ export function List({ const { emailAccountId } = useAccount(); const [selectedTab] = useQueryState("tab", { defaultValue: "all" }); - const categories = useMemo(() => { - return countBy( - emails, - (email) => email.category?.category || "Uncategorized", - ); - }, [emails]); - const planned = useMemo(() => { return emails.filter((email) => email.plan?.rule); }, [emails]); @@ -75,27 +66,19 @@ export function List({ value: "planned", href: "/mail?tab=planned", }, - ...Object.entries(categories).map(([category, count]) => ({ - label: `${capitalCase(category)} (${count})`, - value: category, - href: `/mail?tab=${category}`, - })), ], - [categories, planned], + [planned], ); // only show tabs if there are planned emails or categorized emails - const showTabs = !!(planned.length || emails.find((email) => email.category)); + const showTabs = !!planned.length; const filteredEmails = useMemo(() => { if (selectedTab === "planned") return planned; if (selectedTab === "all") return emails; - if (selectedTab === "Uncategorized") - return emails.filter((email) => !email.category?.category); - - return emails.filter((email) => email.category?.category === selectedTab); + return emails; }, [emails, selectedTab, planned]); return ( diff --git a/apps/web/components/email-list/EmailListItem.tsx b/apps/web/components/email-list/EmailListItem.tsx index f55d3fe441..020d42392c 100644 --- a/apps/web/components/email-list/EmailListItem.tsx +++ b/apps/web/components/email-list/EmailListItem.tsx @@ -11,7 +11,6 @@ import { ActionButtons } from "@/components/ActionButtons"; import { PlanBadge } from "@/components/PlanBadge"; import type { Thread } from "@/components/email-list/types"; import { extractNameFromEmail, participant } from "@/utils/email"; -import { CategoryBadge } from "@/components/CategoryBadge"; import { Checkbox } from "@/components/Checkbox"; import { EmailDate } from "@/components/email-list/EmailDate"; import { decodeSnippet } from "@/utils/gmail/decode"; @@ -167,11 +166,8 @@ export const EmailListItem = forwardRef( />
- {!!(thread.category?.category || thread.plan) && ( + {!!thread.plan && (
- {thread.category?.category ? ( - - ) : null}
)} diff --git a/apps/web/components/email-list/types.ts b/apps/web/components/email-list/types.ts index 20b4a254f3..e34af05c3b 100644 --- a/apps/web/components/email-list/types.ts +++ b/apps/web/components/email-list/types.ts @@ -7,7 +7,6 @@ export type Thread = { messages: FullThread["messages"]; snippet: FullThread["snippet"]; plan: FullThread["plan"]; - category: FullThread["category"]; }; export type Executing = Record; diff --git a/apps/web/components/ui/alert-dialog.tsx b/apps/web/components/ui/alert-dialog.tsx index 254c7fb440..a7cd335b51 100644 --- a/apps/web/components/ui/alert-dialog.tsx +++ b/apps/web/components/ui/alert-dialog.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import { cn } from "@/utils"; -import { buttonVariants } from "@/components/ui//button"; +import { buttonVariants } from "@/components/ui/button"; const AlertDialog = AlertDialogPrimitive.Root; diff --git a/apps/web/components/ui/form.tsx b/apps/web/components/ui/form.tsx index c7752d6c69..8bc0acac0c 100644 --- a/apps/web/components/ui/form.tsx +++ b/apps/web/components/ui/form.tsx @@ -13,7 +13,7 @@ import { } from "react-hook-form"; import { cn } from "@/utils"; -import { Label } from "@/components/ui//label"; +import { Label } from "@/components/ui/label"; const Form = FormProvider; diff --git a/apps/web/components/ui/input.tsx b/apps/web/components/ui/input.tsx index ab94e5e1ba..213900a1f1 100644 --- a/apps/web/components/ui/input.tsx +++ b/apps/web/components/ui/input.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { cn } from "@/utils"; +import { cn } from "@/utils/index"; // Note we usually use /components/Input.tsx instead of this one const Input = React.forwardRef>( diff --git a/apps/web/components/ui/progress.tsx b/apps/web/components/ui/progress.tsx index 42db6e6838..3ebcd4247e 100644 --- a/apps/web/components/ui/progress.tsx +++ b/apps/web/components/ui/progress.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import * as ProgressPrimitive from "@radix-ui/react-progress"; -import { cn } from "@/utils"; +import { cn } from "@/utils/index"; const Progress = React.forwardRef< React.ElementRef, diff --git a/apps/web/components/ui/scroll-area.tsx b/apps/web/components/ui/scroll-area.tsx index 8ba4039996..d8e9b2971e 100644 --- a/apps/web/components/ui/scroll-area.tsx +++ b/apps/web/components/ui/scroll-area.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; -import { cn } from "@/utils"; +import { cn } from "@/utils/index"; const ScrollArea = React.forwardRef< React.ElementRef, @@ -40,7 +40,7 @@ const ScrollBar = React.forwardRef< )} {...props} > - + )); ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; diff --git a/apps/web/components/ui/select.tsx b/apps/web/components/ui/select.tsx index b1e249ad45..c864ad22d8 100644 --- a/apps/web/components/ui/select.tsx +++ b/apps/web/components/ui/select.tsx @@ -2,9 +2,9 @@ import * as React from "react"; import * as SelectPrimitive from "@radix-ui/react-select"; -import { Check, ChevronDown } from "lucide-react"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; -import { cn } from "@/utils"; +import { cn } from "@/utils/index"; const Select = SelectPrimitive.Root; @@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", className, )} {...props} @@ -32,6 +32,41 @@ const SelectTrigger = React.forwardRef< )); SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + const SelectContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -40,7 +75,7 @@ const SelectContent = React.forwardRef< + {children} + )); @@ -81,7 +118,7 @@ const SelectItem = React.forwardRef< (({ className, ...props }, ref) => ( )); @@ -118,4 +155,6 @@ export { SelectLabel, SelectItem, SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, }; diff --git a/apps/web/hooks/useExecutedRules.tsx b/apps/web/hooks/useExecutedRules.tsx new file mode 100644 index 0000000000..fd575c0e95 --- /dev/null +++ b/apps/web/hooks/useExecutedRules.tsx @@ -0,0 +1,14 @@ +import useSWR from "swr"; +import type { GetExecutedRulesResponse } from "@/app/api/user/executed-rules/history/route"; + +export function useExecutedRules({ + page, + ruleId, +}: { + page: number; + ruleId: string; +}) { + return useSWR( + `/api/user/executed-rules/history?page=${page}&ruleId=${ruleId}`, + ); +} diff --git a/apps/web/hooks/useFolders.ts b/apps/web/hooks/useFolders.ts index 6d837375ac..952d3e6024 100644 --- a/apps/web/hooks/useFolders.ts +++ b/apps/web/hooks/useFolders.ts @@ -1,8 +1,16 @@ import useSWR from "swr"; import type { GetFoldersResponse } from "@/app/api/user/folders/route"; +import { isMicrosoftProvider } from "@/utils/email/provider-types"; -export function useFolders() { - const { data, error, isLoading, mutate } = - useSWR("/api/user/folders"); - return { folders: data || [], isLoading, error, mutate }; +export function useFolders(provider: string) { + const enabled = isMicrosoftProvider(provider); + const { data, error, isLoading, mutate } = useSWR( + enabled ? "/api/user/folders" : null, + ); + return { + folders: data || [], + isLoading: enabled ? !!isLoading : false, + error, + mutate, + }; } diff --git a/apps/web/package.json b/apps/web/package.json index 44e9751ca8..20025439af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -108,7 +108,6 @@ "encoding": "0.1.13", "fast-deep-equal": "3.1.3", "fast-xml-parser": "5.2.5", - "framer-motion": "12.23.12", "gmail-api-parse-message": "2.1.2", "google": "link:@next/third-parties/google", "he": "1.2.0", @@ -122,6 +121,7 @@ "linkifyjs": "4.3.2", "lodash": "4.17.21", "lucide-react": "0.542.0", + "motion": "12.23.24", "next": "15.5.2", "next-axiom": "1.9.2", "next-safe-action": "7.10.8", @@ -182,6 +182,7 @@ "@types/react-dom": "19.1.9", "@types/react-syntax-highlighter": "15.5.13", "@types/string-similarity": "4.0.2", + "@vitest/coverage-v8": "3.2.4", "@vitest/ui": "3.2.4", "autoprefixer": "10.4.21", "cross-env": "7.0.3", diff --git a/apps/web/prisma/migrations/20251021123040_drop_executed_rule_unique/migration.sql b/apps/web/prisma/migrations/20251021123040_drop_executed_rule_unique/migration.sql new file mode 100644 index 0000000000..1710a659fb --- /dev/null +++ b/apps/web/prisma/migrations/20251021123040_drop_executed_rule_unique/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "ExecutedRule_emailAccountId_threadId_messageId_key"; + +-- CreateIndex +CREATE INDEX "ExecutedRule_emailAccountId_threadId_messageId_ruleId_idx" ON "ExecutedRule"("emailAccountId", "threadId", "messageId", "ruleId"); + +-- CreateIndex +CREATE INDEX "ExecutedRule_emailAccountId_messageId_idx" ON "ExecutedRule"("emailAccountId", "messageId"); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 9129aacf84..99a5564c72 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -401,8 +401,8 @@ model Rule { // category condition // only apply to (or do not apply to) senders in these categories - categoryFilterType CategoryFilterType? - categoryFilters Category[] + categoryFilterType CategoryFilterType? // deprecated + categoryFilters Category[] // deprecated systemType SystemType? @@ -454,11 +454,11 @@ model RuleHistory { to String? subject String? body String? - categoryFilterType String? + categoryFilterType String? // deprecated systemType String? actions Json - categoryFilters Json? + categoryFilters Json? // deprecated @@unique([ruleId, version]) @@index([ruleId, createdAt]) @@ -487,7 +487,8 @@ model ExecutedRule { actionItems ExecutedAction[] scheduledActions ScheduledAction[] - @@unique([emailAccountId, threadId, messageId], name: "unique_emailAccount_thread_message") + @@index([emailAccountId, threadId, messageId, ruleId]) + @@index([emailAccountId, messageId]) @@index([emailAccountId, status, createdAt]) } @@ -658,7 +659,7 @@ model EmailMessage { messageId String date DateTime // date of the email from String - fromName String? // sender's display name + fromName String? // sender's display name fromDomain String to String unsubscribeLink String? diff --git a/apps/web/providers/ChatProvider.tsx b/apps/web/providers/ChatProvider.tsx index 86854135dd..54e2d23f21 100644 --- a/apps/web/providers/ChatProvider.tsx +++ b/apps/web/providers/ChatProvider.tsx @@ -17,6 +17,7 @@ import type { ChatMessage } from "@/components/assistant-chat/types"; import { useChatMessages } from "@/hooks/useChatMessages"; import { useAccount } from "@/providers/EmailAccountProvider"; import { EMAIL_ACCOUNT_HEADER } from "@/utils/config"; +import type { MessageContext } from "@/app/api/chat/validation"; export type Chat = ReturnType>; @@ -28,6 +29,8 @@ type ChatContextType = { setChatId: (chatId: string | null) => void; setNewChat: () => void; handleSubmit: () => void; + context: MessageContext | null; + setContext: (context: MessageContext | null) => void; }; const ChatContext = createContext(undefined); @@ -38,6 +41,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { const [input, setInput] = useState(""); const [chatId, setChatId] = useQueryState("chatId", parseAsString); + const [context, setContext] = useState(null); const { data } = useChatMessages(chatId); @@ -57,13 +61,13 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { body: { id, message: messages.at(-1), + context: context ?? undefined, ...body, }, }; }, }), - // TODO: couldn't get this to work - // messages: initialMessages, + // messages: initialMessages, // NOTE: couldn't get this to work experimental_throttle: 100, generateId: generateUUID, onFinish: () => { @@ -88,7 +92,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { parts: [ { type: "text", - text: input, + text: input.trim(), }, ], }); @@ -106,6 +110,8 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { setChatId, setNewChat, handleSubmit, + context, + setContext, }} > {children} diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index 4f0078a2e3..ae0a63f89d 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -23,9 +23,8 @@ import { aiDiffRules } from "@/utils/ai/rule/diff-rules"; import { aiFindExistingRules } from "@/utils/ai/rule/find-existing-rules"; import { aiGenerateRulesPrompt } from "@/utils/ai/rule/generate-rules-prompt"; import { aiFindSnippets } from "@/utils/ai/snippets/find-snippets"; -import type { CreateOrUpdateRuleSchemaWithCategories } from "@/utils/ai/rule/create-rule-schema"; +import type { CreateOrUpdateRuleSchema } from "@/utils/ai/rule/create-rule-schema"; import { deleteRule, safeCreateRule, safeUpdateRule } from "@/utils/rule/rule"; -import { getUserCategoriesForNames } from "@/utils/category.server"; import { actionClient } from "@/utils/actions/safe-action"; import { getEmailAccountWithAi } from "@/utils/user/get"; import { SafeError } from "@/utils/error"; @@ -40,7 +39,7 @@ export const runRulesAction = actionClient async ({ ctx: { emailAccountId, provider, logger: ctxLogger }, parsedInput: { messageId, threadId, rerun, isTest }, - }): Promise => { + }): Promise => { const logger = ctxLogger.with({ messageId, threadId }); const emailAccount = await getEmailAccountWithAi({ emailAccountId }); @@ -56,33 +55,33 @@ export const runRulesAction = actionClient const fetchExecutedRule = !isTest && !rerun; - const executedRule = fetchExecutedRule - ? await prisma.executedRule.findUnique({ + const executedRules = fetchExecutedRule + ? await prisma.executedRule.findMany({ where: { - unique_emailAccount_thread_message: { - emailAccountId, - threadId, - messageId, - }, + emailAccountId, + threadId, + messageId, }, select: { id: true, reason: true, actionItems: true, rule: true, + createdAt: true, }, }) - : null; + : []; - if (executedRule) { + if (executedRules.length > 0) { logger.info("Skipping. Rule already exists."); - return { + return executedRules.map((executedRule) => ({ rule: executedRule.rule, actionItems: executedRule.actionItems, reason: executedRule.reason, existing: true, - }; + createdAt: executedRule.createdAt, + })); } const rules = await prisma.rule.findMany({ @@ -90,7 +89,7 @@ export const runRulesAction = actionClient emailAccountId, enabled: true, }, - include: { actions: true, categoryFilters: true }, + include: { actions: true }, }); const result = await runRules({ @@ -126,7 +125,7 @@ export const testAiCustomContentAction = actionClient enabled: true, instructions: { not: null }, }, - include: { actions: true, categoryFilters: true }, + include: { actions: true }, }); const result = await runRules({ @@ -166,7 +165,7 @@ export const createAutomationAction = actionClient if (!emailAccount) throw new Error("Email account not found"); - let result: CreateOrUpdateRuleSchemaWithCategories; + let result: CreateOrUpdateRuleSchema; try { result = await aiCreateRule(prompt, emailAccount); @@ -299,7 +298,6 @@ export const saveRulesPromptAction = actionClient emailAccount, promptFile: diff.addedRules.join("\n\n"), isEditing: false, - availableCategories: emailAccount.categories.map((c) => c.name), }); logger.info("Added rules", { addedRules: addedRules?.length || 0, @@ -378,7 +376,6 @@ export const saveRulesPromptAction = actionClient ) .join("\n\n"), isEditing: true, - availableCategories: emailAccount.categories.map((c) => c.name), }); for (const rule of editedRules) { @@ -394,18 +391,12 @@ export const saveRulesPromptAction = actionClient ruleId: rule.ruleId, }); - const categoryIds = await getUserCategoriesForNames({ - emailAccountId, - names: rule.condition.categories?.categoryFilters || [], - }); - editRulesCount++; await safeUpdateRule({ ruleId: rule.ruleId, result: rule, emailAccountId, - categoryIds, provider: emailAccount.account.provider, }); } @@ -416,7 +407,6 @@ export const saveRulesPromptAction = actionClient emailAccount, promptFile: rulesPrompt, isEditing: false, - availableCategories: emailAccount.categories.map((c) => c.name), }); logger.info("Rules to be added", { count: addedRules?.length || 0 }); } @@ -428,7 +418,6 @@ export const saveRulesPromptAction = actionClient await safeCreateRule({ result: rule, emailAccountId, - categoryNames: rule.condition.categories?.categoryFilters || [], shouldCreateIfDuplicate: false, provider: emailAccount.account.provider, runOnThreads: true, @@ -492,7 +481,6 @@ export const createRulesAction = actionClient const addedRules = await aiPromptToRules({ emailAccount, promptFile: prompt, - availableCategories: emailAccount.categories.map((c) => c.name), }); logger.info("Rules to be added", { count: addedRules?.length || 0 }); @@ -505,7 +493,6 @@ export const createRulesAction = actionClient const createdRule = await safeCreateRule({ result: rule, emailAccountId, - categoryNames: rule.condition.categories?.categoryFilters || [], shouldCreateIfDuplicate: false, provider: emailAccount.account.provider, runOnThreads: true, @@ -580,15 +567,3 @@ export const generateRulesPromptAction = actionClient return { rulesPrompt: result.join("\n\n") }; }); - -export const setRuleEnabledAction = actionClient - .metadata({ name: "setRuleEnabled" }) - .schema(z.object({ ruleId: z.string(), enabled: z.boolean() })) - .action( - async ({ ctx: { emailAccountId }, parsedInput: { ruleId, enabled } }) => { - await prisma.rule.update({ - where: { id: ruleId, emailAccountId }, - data: { enabled }, - }); - }, - ); diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index b34fcfd35b..9377be64e7 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -83,15 +83,8 @@ export const createRuleAction = actionClient to: conditions.to || null, subject: conditions.subject || null, // body: conditions.body || null, - categoryFilterType: conditions.categoryFilterType || null, - categoryFilters: - conditions.categoryFilterType && conditions.categoryFilters - ? { - connect: conditions.categoryFilters.map((id) => ({ id })), - } - : {}, }, - include: { actions: true, categoryFilters: true, group: true }, + include: { actions: true, group: true }, }); // Track rule creation in history @@ -133,7 +126,7 @@ export const updateRuleAction = actionClient try { const currentRule = await prisma.rule.findUnique({ where: { id, emailAccountId }, - include: { actions: true, categoryFilters: true, group: true }, + include: { actions: true, group: true }, }); if (!currentRule) throw new SafeError("Rule not found"); @@ -161,15 +154,8 @@ export const updateRuleAction = actionClient to: conditions.to || null, subject: conditions.subject || null, // body: conditions.body || null, - categoryFilterType: conditions.categoryFilterType || null, - categoryFilters: - conditions.categoryFilterType && conditions.categoryFilters - ? { - set: conditions.categoryFilters.map((id) => ({ id })), - } - : { set: [] }, }, - include: { actions: true, categoryFilters: true, group: true }, + include: { actions: true, group: true }, }), // delete removed actions ...(actionsToDelete.length @@ -298,7 +284,7 @@ export const deleteRuleAction = actionClient .action(async ({ ctx: { emailAccountId }, parsedInput: { id } }) => { const rule = await prisma.rule.findUnique({ where: { id, emailAccountId }, - include: { actions: true, categoryFilters: true, group: true }, + include: { actions: true, group: true }, }); if (!rule) return; // already deleted if (rule.emailAccountId !== emailAccountId) diff --git a/apps/web/utils/actions/rule.validation.ts b/apps/web/utils/actions/rule.validation.ts index 660d2473d1..5bc126d62d 100644 --- a/apps/web/utils/actions/rule.validation.ts +++ b/apps/web/utils/actions/rule.validation.ts @@ -1,10 +1,5 @@ import { z } from "zod"; -import { - ActionType, - CategoryFilterType, - LogicalOperator, - SystemType, -} from "@prisma/client"; +import { ActionType, LogicalOperator, SystemType } from "@prisma/client"; import { ConditionType } from "@/utils/config"; import { NINETY_DAYS_MINUTES } from "@/utils/date"; @@ -28,11 +23,7 @@ const zodActionType = z.enum([ ActionType.MOVE_FOLDER, ]); -const zodConditionType = z.enum([ - ConditionType.AI, - ConditionType.STATIC, - ConditionType.CATEGORY, -]); +const zodConditionType = z.enum([ConditionType.AI, ConditionType.STATIC]); const zodSystemRule = z.enum([ SystemType.TO_REPLY, @@ -58,18 +49,10 @@ const zodStaticCondition = z.object({ body: z.string().nullish(), }); -const zodCategoryCondition = z.object({ - categoryFilterType: z - .enum([CategoryFilterType.INCLUDE, CategoryFilterType.EXCLUDE]) - .nullish(), - categoryFilters: z.array(z.string()).nullish(), -}); - const zodCondition = z.object({ type: zodConditionType, ...zodAiCondition.shape, ...zodStaticCondition.shape, - ...zodCategoryCondition.shape, }); export type ZodCondition = z.infer; diff --git a/apps/web/utils/ai/assistant/chat.ts b/apps/web/utils/ai/assistant/chat.ts index 4d183dbc7f..ac82f36e46 100644 --- a/apps/web/utils/ai/assistant/chat.ts +++ b/apps/web/utils/ai/assistant/chat.ts @@ -17,6 +17,10 @@ import { chatCompletionStream } from "@/utils/llms"; import { filterNullProperties } from "@/utils"; import { delayInMinutesSchema } from "@/utils/actions/rule.validation"; import { isMicrosoftProvider } from "@/utils/email/provider-types"; +import type { MessageContext } from "@/app/api/chat/validation"; +import { stringifyEmail } from "@/utils/stringify-email"; +import { getEmailForLLM } from "@/utils/get-email-from-message"; +import type { ParsedMessage } from "@/utils/types"; const logger = createScopedLogger("ai/assistant/chat"); @@ -662,10 +666,12 @@ export async function aiProcessAssistantChat({ messages, emailAccountId, user, + context, }: { messages: ModelMessage[]; emailAccountId: string; user: EmailAccountWithAI; + context?: MessageContext; }) { const system = `You are an assistant that helps create and update rules to manage a user's inbox. Our platform is called Inbox Zero. @@ -919,6 +925,33 @@ Examples: provider: user.account.provider, }; + const hiddenContextMessage = + context && context.type === "fix-rule" + ? [ + { + role: "system" as const, + content: + "Hidden context for the user's request (do not repeat this to the user):\n\n" + + `\n${stringifyEmail( + getEmailForLLM(context.message as ParsedMessage, { + maxLength: 3000, + }), + 3000, + )}\n\n\n` + + `Rules that were applied:\n${context.results + .map((r) => `- ${r.ruleName ?? "None"}: ${r.reason}`) + .join("\n")}\n\n` + + `Expected outcome: ${ + context.expected === "new" + ? "Create a new rule" + : context.expected === "none" + ? "No rule should be applied" + : `Should match the "${context.expected.name}" rule` + }`, + }, + ] + : []; + const result = chatCompletionStream({ userAi: user.user, userEmail: user.email, @@ -929,6 +962,7 @@ Examples: role: "system", content: system, }, + ...hiddenContextMessage, ...messages, ], onStepFinish: async ({ text, toolCalls }) => { diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 5e5fd81414..9c594c8823 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -1,35 +1,18 @@ import { stepCountIs, tool } from "ai"; import { z } from "zod"; import { createGenerateText } from "@/utils/llms"; -import { createScopedLogger, type Logger } from "@/utils/logger"; -import { - type Category, - GroupItemType, - LogicalOperator, - type Rule, -} from "@prisma/client"; +import { createScopedLogger } from "@/utils/logger"; +import { GroupItemType, LogicalOperator, type Rule } from "@prisma/client"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { RuleWithRelations } from "@/utils/rule/types"; -import { isDefined, type ParsedMessage } from "@/utils/types"; -import { - createRuleSchema, - type CreateRuleSchemaWithCategories, - getCreateRuleSchemaWithCategories, -} from "@/utils/ai/rule/create-rule-schema"; +import type { ParsedMessage } from "@/utils/types"; +import { createRuleSchema } from "@/utils/ai/rule/create-rule-schema"; import { deleteGroupItem } from "@/utils/group/group-item"; -import { - addRuleCategories, - createRule, - partialUpdateRule, - removeRuleCategories, -} from "@/utils/rule/rule"; -import { updateCategoryForSender } from "@/utils/categorize/senders/categorize"; -import { findSenderByEmail } from "@/utils/sender"; +import { createRule, partialUpdateRule } from "@/utils/rule/rule"; import { getEmailForLLM } from "@/utils/get-email-from-message"; import { stringifyEmailSimple } from "@/utils/stringify-email"; import { env } from "@/env"; import { posthogCaptureEvent } from "@/utils/posthog"; -import { getUserCategoriesForNames } from "@/utils/category.server"; import { getModel } from "@/utils/llms/model"; import { getUserInfoPrompt } from "@/utils/ai/helpers"; @@ -39,16 +22,12 @@ export async function processUserRequest({ originalEmail, messages, matchedRule, - categories, - senderCategory, }: { emailAccount: EmailAccountWithAI; rules: RuleWithRelations[]; originalEmail: ParsedMessage | null; messages: { role: "assistant" | "user"; content: string }[]; matchedRule: RuleWithRelations | null; - categories: Pick[] | null; - senderCategory: string | null; }) { const logger = createScopedLogger("ai-fix-rules").with({ emailAccountId: emailAccount.id, @@ -75,7 +54,6 @@ You can fix rules using these specific operations: - Change conditional operator (AND/OR logic) - Modify AI instructions - Update static conditions (from, to, subject, body) -- Add or remove categories 2. Create New Rules: - Create new rules when asked or when existing ones cannot be modified to fit the need @@ -97,7 +75,7 @@ When fixing rules: Rule matching logic: - All static conditions (from, to, subject, body) use AND logic - meaning all static conditions must match -- Top level conditions (AI instructions, static, category) can use either AND or OR logic, controlled by the conditionalOperator setting +- Top level conditions (AI instructions, static) can use either AND or OR logic, controlled by the conditionalOperator setting Best practices: - For static conditions, use email patterns (e.g., '@company.com') when matching multiple addresses @@ -129,14 +107,6 @@ ${ ${stringifyEmailSimple(getEmailForLLM(originalEmail))} ` : "" -} - -${ - originalEmail && categories?.length - ? ` -${senderCategory || "No category"} -` - : "" }`; const allMessages = [ @@ -382,142 +352,9 @@ ${senderCategory || "No category"} }), } : {}), - ...(categories - ? { - update_sender_category: getUpdateCategoryTool({ - emailAccountId: emailAccount.id, - userEmail: emailAccount.email, - categories, - logger, - }), - add_categories: tool({ - description: "Add categories to a rule", - inputSchema: z.object({ - ruleName: z - .string() - .describe("The exact name of the rule to edit"), - categories: z - .array(z.string()) - .describe("The categories to add"), - }), - execute: async (options) => { - try { - logger.info("Add Rule Categories", options); - trackToolCall({ - tool: "add_categories", - email: emailAccount.email, - }); - - const { ruleName } = options; - - const rule = rules.find((r) => r.name === ruleName); - - if (!rule) { - logger.error("Rule not found", { - ...options, - ruleName, - }); - return { error: "Rule not found" }; - } - - const categoryIds = options.categories - .map((c) => categories.find((cat) => cat.name === c)) - .filter(isDefined) - .map((c) => c.id); - - const updatedRule = await addRuleCategories( - rule.id, - categoryIds, - ); - - updatedRules.set(updatedRule.id, updatedRule); - - return { success: true }; - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - - logger.error("Error while adding categories to rule", { - ...options, - error: message, - }); - - return { - error: "Failed to add categories to rule", - message, - }; - } - }, - }), - remove_categories: tool({ - description: "Remove categories from a rule", - inputSchema: z.object({ - ruleName: z - .string() - .describe("The exact name of the rule to edit"), - categories: z - .array(z.string()) - .describe("The categories to remove"), - }), - execute: async (options) => { - try { - logger.info("Remove Rule Categories", options); - trackToolCall({ - tool: "remove_categories", - email: emailAccount.email, - }); - - const { ruleName } = options; - - const rule = rules.find((r) => r.name === ruleName); - - if (!rule) { - logger.error("Rule not found", { - ...options, - ruleName, - }); - return { error: "Rule not found" }; - } - - const categoryIds = options.categories - .map((c) => categories.find((cat) => cat.name === c)) - .filter(isDefined) - .map((c) => c.id); - - const updatedRule = await removeRuleCategories( - rule.id, - categoryIds, - ); - - updatedRules.set(updatedRule.id, updatedRule); - - return { success: true }; - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - - logger.error("Error while removing categories from rule", { - ...options, - error: message, - }); - - return { - error: "Failed to remove categories from rule", - message, - }; - } - }, - }), - } - : {}), create_rule: tool({ description: "Create a new rule", - inputSchema: categories - ? getCreateRuleSchemaWithCategories( - categories.map((c) => c.name) as [string, ...string[]], - emailAccount.account.provider, - ) - : createRuleSchema(emailAccount.account.provider), + inputSchema: createRuleSchema(emailAccount.account.provider), execute: async ({ name, condition, actions }) => { logger.info("Create Rule", { name, condition, actions }); trackToolCall({ @@ -525,15 +362,7 @@ ${senderCategory || "No category"} email: emailAccount.email, }); - const conditions = - condition as CreateRuleSchemaWithCategories["condition"]; - try { - const categoryIds = await getUserCategoriesForNames({ - emailAccountId: emailAccount.id, - names: conditions.categories?.categoryFilters || [], - }); - const rule = await createRule({ result: { name, @@ -556,7 +385,6 @@ ${senderCategory || "No category"} })), }, emailAccountId: emailAccount.id, - categoryIds, provider: emailAccount.account.provider, runOnThreads: true, }); @@ -618,70 +446,6 @@ ${senderCategory || "No category"} return result; } -const getUpdateCategoryTool = ({ - emailAccountId, - categories, - userEmail, - logger, -}: { - emailAccountId: string; - categories: Pick[]; - userEmail: string; - logger: Logger; -}) => - tool({ - description: "Update the category of a sender", - inputSchema: z.object({ - sender: z.string().describe("The sender to update"), - category: z - .enum([ - ...(categories.map((c) => c.name) as [string, ...string[]]), - "none", - ]) - .describe("The name of the category to assign"), - }), - execute: async ({ sender, category }) => { - logger.info("Update Category", { sender, category }); - trackToolCall({ - tool: "update_sender_category", - email: userEmail, - }); - - const existingSender = await findSenderByEmail({ - emailAccountId, - email: sender, - }); - - const cat = categories.find((c) => c.name === category); - - if (!cat) { - logger.error("Category not found", { category }); - return { error: "Category not found" }; - } - - try { - await updateCategoryForSender({ - emailAccountId, - sender: existingSender?.email || sender, - categoryId: cat.id, - }); - return { success: true }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - - logger.error("Error while updating category for sender", { - sender, - category, - error: message, - }); - return { - error: "Failed to update category for sender", - message, - }; - } - }, - }); - function ruleToXML(rule: RuleWithRelations) { return ` ${rule.name} @@ -698,14 +462,6 @@ function ruleToXML(rule: RuleWithRelations) { ` : "" } - ${ - hasCategoryConditions(rule) - ? ` - ${rule.categoryFilterType ? `${rule.categoryFilterType}` : ""} - ${rule.categoryFilters?.map((category) => `${category.name}`).join("\n ")} - ` - : "" - } ${ @@ -736,10 +492,6 @@ function hasStaticConditions(rule: RuleWithRelations) { return Boolean(rule.from || rule.to || rule.subject || rule.body); } -function hasCategoryConditions(rule: RuleWithRelations) { - return Boolean(rule.categoryFilters && rule.categoryFilters.length > 0); -} - function getPatternType(type: string) { if (type === "from") return GroupItemType.FROM; if (type === "subject") return GroupItemType.SUBJECT; diff --git a/apps/web/utils/ai/choose-rule/ai-choose-rule.ts b/apps/web/utils/ai/choose-rule/ai-choose-rule.ts index a77c45b37d..cab3aa8353 100644 --- a/apps/web/utils/ai/choose-rule/ai-choose-rule.ts +++ b/apps/web/utils/ai/choose-rule/ai-choose-rule.ts @@ -1,28 +1,114 @@ import { z } from "zod"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import { stringifyEmail } from "@/utils/stringify-email"; -import type { EmailForLLM } from "@/utils/types"; +import { isDefined, type EmailForLLM } from "@/utils/types"; import { getModel, type ModelType } from "@/utils/llms/model"; import { createGenerateObject } from "@/utils/llms"; -import { createScopedLogger } from "@/utils/logger"; import { getUserInfoPrompt, getUserRulesPrompt } from "@/utils/ai/helpers"; -// import { Braintrust } from "@/utils/braintrust"; -// const braintrust = new Braintrust("choose-rule-2"); - -const logger = createScopedLogger("AI Choose Rule"); type GetAiResponseOptions = { email: EmailForLLM; emailAccount: EmailAccountWithAI; - rules: { name: string; instructions: string }[]; + rules: { name: string; instructions: string; systemType?: string | null }[]; modelType?: ModelType; }; -async function getAiResponse(options: GetAiResponseOptions) { +export async function aiChooseRule< + T extends { name: string; instructions: string; systemType?: string | null }, +>({ + email, + rules, + emailAccount, + modelType, +}: { + email: EmailForLLM; + rules: T[]; + emailAccount: EmailAccountWithAI; + modelType?: ModelType; +}): Promise<{ + rules: { rule: T; isPrimary?: boolean }[]; + reason: string; +}> { + if (!rules.length) return { rules: [], reason: "" }; + + const { result: aiResponse } = await getAiResponse({ + email, + rules, + emailAccount, + modelType, + }); + + if (aiResponse.noMatchFound) return { rules: [], reason: "" }; + + const rulesWithMetadata = aiResponse.matchedRules + .map((match) => { + const rule = rules.find( + (r) => r.name.toLowerCase() === match.ruleName.toLowerCase(), + ); + return rule ? { rule, isPrimary: match.isPrimary } : undefined; + }) + .filter(isDefined); + + return { + rules: rulesWithMetadata, + reason: aiResponse.reasoning, + }; +} + +async function getAiResponse(options: GetAiResponseOptions): Promise<{ + result: { + matchedRules: { ruleName: string; isPrimary?: boolean }[]; + reasoning: string; + noMatchFound: boolean; + }; + modelOptions: ReturnType; +}> { const { email, emailAccount, rules, modelType = "default" } = options; - const emailSection = stringifyEmail(email, 500); + const modelOptions = getModel(emailAccount.user, modelType); + const generateObject = createGenerateObject({ + userEmail: emailAccount.email, + label: "Choose rule", + modelOptions, + }); + + const hasCustomRules = rules.some((rule) => !rule.systemType); + + if (hasCustomRules) { + const result = await getAiResponseMultiRule({ + email, + emailAccount, + rules, + modelOptions, + generateObject, + }); + + return { result, modelOptions }; + } else { + return getAiResponseSingleRule({ + email, + emailAccount, + rules, + modelOptions, + generateObject, + }); + } +} + +async function getAiResponseSingleRule({ + email, + emailAccount, + rules, + modelOptions, + generateObject, +}: { + email: EmailForLLM; + emailAccount: EmailAccountWithAI; + rules: GetAiResponseOptions["rules"]; + modelOptions: ReturnType; + generateObject: ReturnType; +}) { const system = `You are an AI assistant that helps people manage their emails. @@ -51,7 +137,7 @@ Respond with a valid JSON object: Example response format: { - "reason": "This email is a newsletter subscription", + "reasoning": "This email is a newsletter subscription", "ruleName": "Newsletter", "noMatchFound": false }`; @@ -59,23 +145,15 @@ Example response format: const prompt = `Select a rule to apply to this email that was sent to me: -${emailSection} +${stringifyEmail(email, 500)} `; - const modelOptions = getModel(emailAccount.user, modelType); - - const generateObject = createGenerateObject({ - userEmail: emailAccount.email, - label: "Choose rule", - modelOptions, - }); - const aiResponse = await generateObject({ ...modelOptions, system, prompt, schema: z.object({ - reason: z + reasoning: z .string() .describe("The reason you chose the rule. Keep it concise"), ruleName: z @@ -87,77 +165,121 @@ ${emailSection} }), }); - // braintrust.insertToDataset({ - // id: email.id, - // input: { - // email: emailSection, - // rules: rules.map((rule) => ({ - // name: rule.name, - // instructions: rule.instructions, - // })), - // hasAbout: !!emailAccount.about, - // userAbout: emailAccount.about, - // userEmail: emailAccount.email, - // }, - // expected: aiResponse.object.ruleName, - // }); - - return { result: aiResponse.object, modelOptions }; + return { + result: { + matchedRules: aiResponse.object ? [aiResponse.object] : [], + noMatchFound: aiResponse.object?.noMatchFound ?? false, + reasoning: aiResponse.object?.reasoning, + }, + modelOptions, + }; } -export async function aiChooseRule< - T extends { name: string; instructions: string }, ->({ +async function getAiResponseMultiRule({ email, - rules, emailAccount, - modelType, + rules, + modelOptions, + generateObject, }: { email: EmailForLLM; - rules: T[]; emailAccount: EmailAccountWithAI; - modelType?: ModelType; + rules: GetAiResponseOptions["rules"]; + modelOptions: ReturnType; + generateObject: ReturnType; }) { - if (!rules.length) return { reason: "No rules" }; + const rulesSection = rules + .map( + (rule) => + `\n${rule.name}\n${rule.instructions}\n`, + ) + .join("\n"); - const { result: aiResponse, modelOptions } = await getAiResponse({ - email, - rules, - emailAccount, - modelType, - }); + const system = `You are an AI assistant that helps people manage their emails. - if (aiResponse.noMatchFound) - return { rule: undefined, reason: "No match found" }; - - const selectedRule = aiResponse.ruleName - ? rules.find( - (rule) => - rule.name.toLowerCase() === aiResponse.ruleName?.toLowerCase(), - ) - : undefined; - - // The AI found a match, but didn't select a rule - // We should probably force a retry in this case - if (aiResponse.ruleName && !selectedRule) { - logger.error("No matching rule found", { - noMatchFound: aiResponse.noMatchFound, - reason: aiResponse.reason, - ruleName: aiResponse.ruleName, - rules: rules.map((r) => ({ - name: r.name, - instructions: r.instructions, - })), - emailId: email.id, - model: modelOptions.modelName, - provider: modelOptions.provider, - providerOptions: modelOptions.providerOptions, - modelType, - }); - } + + IMPORTANT: Follow these instructions carefully when selecting rules: + + + - Review all available rules and select those that genuinely match this email. + - You can select multiple rules, but BE SELECTIVE - it's rare that you need to select more than 1-2 rules. + - Only set "noMatchFound" to true if no rules can reasonably apply. There is usually a rule that matches. + + + + - When returning multiple rules, mark ONLY ONE rule as the primary match (isPrimary: true). + - The primary rule should be the MOST SPECIFIC rule that best matches the email's content and purpose. + + + + - If a rule says to exclude certain types of emails, DO NOT select that rule for those excluded emails. + - Do not be greedy - only select rules that add meaningful context. + - Be concise in your reasoning - avoid repetitive explanations. + + + + +${rulesSection} + + +${getUserInfoPrompt({ emailAccount })} + +Respond with a valid JSON object: + +Example response format (single rule): +{ + "matchedRules": [{ "ruleName": "Newsletter", "isPrimary": true }], + "noMatchFound": false, + "reasoning": "This is a newsletter subscription" +} + +Example response format (multiple rules): +{ + "matchedRules": [ + { "ruleName": "To Reply", "isPrimary": true }, + { "ruleName": "Team Emails", "isPrimary": false } + ], + "noMatchFound": false, + "reasoning": "This email requires a response and is from a team member" +}`; + + const prompt = `Select all rules that apply to this email that was sent to me: + + +${stringifyEmail(email, 500)} +`; + + const aiResponse = await generateObject({ + ...modelOptions, + system, + prompt, + schema: z.object({ + matchedRules: z + .array( + z.object({ + ruleName: z.string().describe("The exact name of the rule"), + isPrimary: z + .boolean() + .describe( + "True if the rule is the primary match, false otherwise", + ), + }), + ) + .describe("Array of all matching rules"), + reasoning: z + .string() + .describe( + "The reasoning you used to choose the rules. Keep it concise", + ), + noMatchFound: z + .boolean() + .describe("True if no match was found, false otherwise"), + }), + }); return { - rule: selectedRule, - reason: aiResponse?.reason, + matchedRules: aiResponse.object.matchedRules || [], + noMatchFound: aiResponse.object?.noMatchFound ?? false, + reasoning: aiResponse.object?.reasoning ?? "", }; } diff --git a/apps/web/utils/ai/choose-rule/match-rules.test.ts b/apps/web/utils/ai/choose-rule/match-rules.test.ts index 05762d3404..ad17baf76c 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.test.ts @@ -1,21 +1,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { filterMultipleSystemRules } from "./match-rules"; import { - findMatchingRule, + findMatchingRules, matchesStaticRule, - filterToReplyPreset, + filterConversationStatusRules, + evaluateRuleConditions, } from "./match-rules"; import { - type Category, - CategoryFilterType, type GroupItem, GroupItemType, LogicalOperator, - type Newsletter, type Prisma, SystemType, } from "@prisma/client"; import type { - RuleWithActionsAndCategories, + RuleWithActions, ParsedMessage, ParsedMessageHeaders, } from "@/utils/types"; @@ -23,6 +22,12 @@ import type { EmailProvider } from "@/utils/email/types"; import prisma from "@/utils/__mocks__/prisma"; import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; import { getEmailAccount } from "@/__tests__/helpers"; +import { ConditionType } from "@/utils/config"; +import { + getColdEmailRule, + isColdEmailRuleEnabled, +} from "@/utils/cold-email/cold-email-rule"; +import { isColdEmail } from "@/utils/cold-email/is-cold-email"; // Run with: // pnpm test match-rules.test.ts @@ -39,6 +44,13 @@ vi.mock("@/utils/ai/choose-rule/ai-choose-rule", () => ({ vi.mock("@/utils/reply-tracker/check-sender-reply-history", () => ({ checkSenderReplyHistory: vi.fn(), })); +vi.mock("@/utils/cold-email/cold-email-rule", () => ({ + getColdEmailRule: vi.fn(), + isColdEmailRuleEnabled: vi.fn(), +})); +vi.mock("@/utils/cold-email/is-cold-email", () => ({ + isColdEmail: vi.fn(), +})); describe("matchesStaticRule", () => { it("should match wildcard pattern at start of email", () => { @@ -763,7 +775,7 @@ describe("findMatchingRule", () => { headers: getHeaders({ from: "test@example.com" }), }); const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ + const result = await findMatchingRules({ rules, message, emailAccount, @@ -771,8 +783,10 @@ describe("findMatchingRule", () => { modelType: "default", }); - expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBe("Matched static conditions"); + expect(result.matches[0].rule.id).toBe(rule.id); + expect(result.matches[0].matchReasons).toEqual([ + { type: ConditionType.STATIC }, + ]); }); it("matches a static domain", async () => { @@ -783,7 +797,7 @@ describe("findMatchingRule", () => { }); const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ + const result = await findMatchingRules({ rules, message, emailAccount, @@ -791,8 +805,10 @@ describe("findMatchingRule", () => { modelType: "default", }); - expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBe("Matched static conditions"); + expect(result.matches[0].rule.id).toBe(rule.id); + expect(result.matches[0].matchReasons).toEqual([ + { type: ConditionType.STATIC }, + ]); }); it("doens't match wrong static domain", async () => { @@ -803,7 +819,7 @@ describe("findMatchingRule", () => { }); const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ + const result = await findMatchingRules({ rules, message, emailAccount, @@ -811,8 +827,8 @@ describe("findMatchingRule", () => { modelType: "default", }); - expect(result.rule?.id).toBeUndefined(); - expect(result.reason).toBeUndefined(); + expect(result.matches).toHaveLength(0); + expect(result.reasoning).toBe(""); }); it("matches a group rule", async () => { @@ -834,101 +850,7 @@ describe("findMatchingRule", () => { }); const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ - rules, - message, - emailAccount, - provider, - modelType: "default", - }); - - expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBe( - `Matched learned pattern: "FROM: test@example.com"`, - ); - }); - - it("matches a smart category rule", async () => { - prisma.newsletter.findUnique.mockResolvedValue( - getNewsletter({ categoryId: "test-category" }), - ); - - const rule = getRule({ - categoryFilters: [getCategory({ id: "test-category" })], - categoryFilterType: CategoryFilterType.INCLUDE, - }); - const rules = [rule]; - const message = getMessage(); - const emailAccount = getEmailAccount(); - - const result = await findMatchingRule({ - rules, - message, - emailAccount, - provider, - modelType: "default", - }); - - expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBe('Matched category: "category"'); - }); - - it("matches a smart category rule with exclude", async () => { - prisma.newsletter.findUnique.mockResolvedValue( - getNewsletter({ categoryId: "test-category" }), - ); - - const rule = getRule({ - categoryFilters: [getCategory({ id: "test-category" })], - categoryFilterType: CategoryFilterType.EXCLUDE, - }); - const rules = [rule]; - const message = getMessage(); - const emailAccount = getEmailAccount(); - - const result = await findMatchingRule({ - rules, - message, - emailAccount, - provider, - modelType: "default", - }); - - expect(result.rule?.id).toBeUndefined(); - expect(result.reason).toBeUndefined(); - }); - - it("matches a rule with multiple conditions AND (category and group)", async () => { - const rule = getRule({ - conditionalOperator: LogicalOperator.AND, - categoryFilters: [getCategory({ id: "category1" })], - groupId: "group1", - }); - - prisma.group.findMany.mockResolvedValue([ - getGroup({ - id: "group1", - items: [ - getGroupItem({ - groupId: "group1", - type: GroupItemType.FROM, - value: "test@example.com", - }), - ], - rule, - }), - ]); - prisma.newsletter.findUnique.mockResolvedValue( - getNewsletter({ categoryId: "category1" }), - ); - - const rules = [rule]; - const message = getMessage({ - headers: getHeaders({ from: "test@example.com" }), - }); - const emailAccount = getEmailAccount(); - - const result = await findMatchingRule({ + const result = await findMatchingRules({ rules, message, emailAccount, @@ -936,146 +858,18 @@ describe("findMatchingRule", () => { modelType: "default", }); - expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBe( + expect(result.matches[0]?.rule.id).toBe(rule.id); + expect(result.reasoning).toBe( `Matched learned pattern: "FROM: test@example.com"`, ); }); - it("matches a rule with multiple conditions AND (category and AI)", async () => { - prisma.newsletter.findUnique.mockResolvedValue( - getNewsletter({ categoryId: "newsletterCategory" }), - ); - - const rule = getRule({ - conditionalOperator: LogicalOperator.AND, - instructions: "Match if the email is an AI newsletter", - categoryFilters: [getCategory({ id: "newsletterCategory" })], - }); - - (aiChooseRule as ReturnType).mockImplementationOnce(() => { - return { - reason: "reason", - rule: { id: "r123" }, - }; - }); - - const rules = [rule]; - const message = getMessage({ - headers: getHeaders({ from: "ai@newsletter.com" }), - }); - const emailAccount = getEmailAccount(); - - const result = await findMatchingRule({ - rules, - message, - emailAccount, - provider, - modelType: "default", - }); - - expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBeDefined(); - expect(aiChooseRule).toHaveBeenCalledOnce(); - }); - - it("doesn't match when AI condition fails (category matches but AI doesn't)", async () => { - prisma.newsletter.findUnique.mockResolvedValue( - getNewsletter({ categoryId: "newsletterCategory" }), - ); - - const rule = getRule({ - conditionalOperator: LogicalOperator.AND, - instructions: "Match if the email is an AI newsletter", - categoryFilters: [getCategory({ id: "newsletterCategory" })], - }); - - (aiChooseRule as ReturnType).mockImplementationOnce(() => { - return { - reason: "Not an AI newsletter", - rule: undefined, - }; - }); - - const rules = [rule]; - const message = getMessage({ - headers: getHeaders({ from: "marketing@newsletter.com" }), - }); - const emailAccount = getEmailAccount(); - - const result = await findMatchingRule({ - rules, - message, - emailAccount, - provider, - modelType: "default", - }); - - expect(result.rule).toBeUndefined(); - expect(result.reason).toBeDefined(); - expect(aiChooseRule).toHaveBeenCalledOnce(); - }); - - it("should match with only one of category or group", async () => { - prisma.newsletter.findUnique.mockResolvedValue( - getNewsletter({ categoryId: "category1" }), - ); - prisma.group.findMany.mockResolvedValue([]); - - const rule = getRule({ - conditionalOperator: LogicalOperator.AND, - categoryFilters: [getCategory({ id: "category1" })], - groupId: "group1", - }); - const rules = [rule]; - const message = getMessage(); - const emailAccount = getEmailAccount(); - - const result = await findMatchingRule({ - rules, - message, - emailAccount, - provider, - modelType: "default", - }); - - expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBe('Matched category: "category"'); - }); - - it("matches with OR and one of category or group", async () => { - prisma.newsletter.findUnique.mockResolvedValue( - getNewsletter({ categoryId: "category1" }), - ); - prisma.group.findMany.mockResolvedValue([]); - - const rule = getRule({ - conditionalOperator: LogicalOperator.OR, - categoryFilters: [getCategory({ id: "category1" })], - groupId: "group1", - }); - const rules = [rule]; - const message = getMessage(); - const emailAccount = getEmailAccount(); - - const result = await findMatchingRule({ - rules, - message, - emailAccount, - provider, - modelType: "default", - }); - - expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBe('Matched category: "category"'); - }); - - it("should not match when item matches but is in wrong group", async () => { + it("should match via default when group doesn't match and no other conditions", async () => { const rule = getRule({ groupId: "correctGroup", // Rule specifically looks for correctGroup }); - // Set up two groups - one referenced by the rule, one not + // Set up groups - message doesn't match the rule's group prisma.group.findMany.mockResolvedValue([ getGroup({ id: "wrongGroup", @@ -1102,11 +896,11 @@ describe("findMatchingRule", () => { const rules = [rule]; const message = getMessage({ - headers: getHeaders({ from: "test@example.com" }), // This matches item in wrongGroup + headers: getHeaders({ from: "test@example.com" }), // Doesn't match correctGroup }); const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ + const result = await findMatchingRules({ rules, message, emailAccount, @@ -1114,8 +908,9 @@ describe("findMatchingRule", () => { modelType: "default", }); - expect(result.rule).toBeUndefined(); - expect(result.reason).toBeUndefined(); + // Group didn't match, but no other conditions means match everything + expect(result.matches).toHaveLength(1); + expect(result.matches[0]?.matchReasons).toEqual([]); }); it("should match only when item is in the correct group", async () => { @@ -1152,7 +947,7 @@ describe("findMatchingRule", () => { }); const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ + const result = await findMatchingRules({ rules, message, emailAccount, @@ -1160,8 +955,8 @@ describe("findMatchingRule", () => { modelType: "default", }); - expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toContain("test@example.com"); + expect(result.matches[0]?.rule.id).toBe(rule.id); + expect(result.reasoning).toContain("test@example.com"); }); it("should handle multiple rules with different group conditions correctly", async () => { @@ -1199,7 +994,7 @@ describe("findMatchingRule", () => { }); const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ + const result = await findMatchingRules({ rules, message, emailAccount, @@ -1208,8 +1003,8 @@ describe("findMatchingRule", () => { }); // Should match the first rule only - expect(result.rule?.id).toBe("rule1"); - expect(result.reason).toContain("test@example.com"); + expect(result.matches[0]?.rule.id).toBe("rule1"); + expect(result.reasoning).toContain("test@example.com"); }); it("should exclude a rule when an exclusion pattern matches", async () => { @@ -1240,7 +1035,7 @@ describe("findMatchingRule", () => { }); const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ + const result = await findMatchingRules({ rules, message, emailAccount, @@ -1249,8 +1044,142 @@ describe("findMatchingRule", () => { }); // The rule should be excluded (not matched) - expect(result.rule).toBeUndefined(); - expect(result.reason).toBeUndefined(); + expect(result.matches).toHaveLength(0); + expect(result.reasoning).toBe(""); + }); + + it("should match via static condition when group rule doesn't match pattern (OR operator)", async () => { + const rule = getRule({ + id: "group-with-fallback", + groupId: "test-group", + from: "fallback@example.com", // Static condition + conditionalOperator: LogicalOperator.OR, + }); + + // Group has different pattern + prisma.group.findMany.mockResolvedValue([ + getGroup({ + id: "test-group", + items: [ + getGroupItem({ + type: GroupItemType.FROM, + value: "group@example.com", + }), + ], + rule, + }), + ]); + + const rules = [rule]; + const message = getMessage({ + headers: getHeaders({ from: "fallback@example.com" }), // Matches static, not group + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + expect(result.matches[0]?.rule.id).toBe(rule.id); + expect(result.matches[0]?.matchReasons).toEqual([ + { type: ConditionType.STATIC }, + ]); + }); + + it("should match via static when group rule has group miss and static hit (AND operator)", async () => { + const rule = getRule({ + id: "group-with-and", + groupId: "test-group", + from: "test@example.com", // Static condition + conditionalOperator: LogicalOperator.AND, // Only applies to AI/Static, not groups + }); + + // Group has different pattern + prisma.group.findMany.mockResolvedValue([ + getGroup({ + id: "test-group", + items: [ + getGroupItem({ + type: GroupItemType.FROM, + value: "group@example.com", + }), + ], + rule, + }), + ]); + + const rules = [rule]; + const message = getMessage({ + headers: getHeaders({ from: "test@example.com" }), // Matches static, not group + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + // Groups are independent of AND/OR operator - static match should work + expect(result.matches[0]?.rule.id).toBe(rule.id); + expect(result.matches[0]?.matchReasons).toEqual([ + { type: ConditionType.STATIC }, + ]); + }); + + it("should match when group rule with AND operator has both group and static match", async () => { + const rule = getRule({ + id: "group-with-and-both", + groupId: "test-group", + subject: "Important", // Additional static condition + conditionalOperator: LogicalOperator.AND, + }); + + prisma.group.findMany.mockResolvedValue([ + getGroup({ + id: "test-group", + items: [ + getGroupItem({ type: GroupItemType.FROM, value: "test@example.com" }), + ], + rule, + }), + ]); + + const rules = [rule]; + const message = getMessage({ + headers: getHeaders({ + from: "test@example.com", // Matches group + subject: "Important update", // Matches static + }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + // Should match via learned pattern and short-circuit (not check static) + expect(result.matches[0]?.rule.id).toBe(rule.id); + expect(result.matches[0]?.matchReasons).toEqual([ + { + type: ConditionType.LEARNED_PATTERN, + groupItem: expect.objectContaining({ + type: GroupItemType.FROM, + value: "test@example.com", + }), + group: expect.objectContaining({ id: "test-group" }), + }, + ]); }); it("should match learned pattern when email has display name format", async () => { @@ -1287,7 +1216,7 @@ describe("findMatchingRule", () => { }); const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ + const result = await findMatchingRules({ rules, message, emailAccount, @@ -1296,8 +1225,8 @@ describe("findMatchingRule", () => { }); // Should match despite the display name format, due to the group rule - expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBe( + expect(result.matches[0]?.rule.id).toBe(rule.id); + expect(result.reasoning).toBe( `Matched learned pattern: "FROM: central@example.com"`, ); expect(aiChooseRule).not.toHaveBeenCalled(); @@ -1325,7 +1254,7 @@ describe("filterToReplyPreset", () => { headers: getHeaders({ from: "noreply@company.com" }), }); - const result = await filterToReplyPreset( + const result = await filterConversationStatusRules( potentialMatches, message, provider, @@ -1355,7 +1284,7 @@ describe("filterToReplyPreset", () => { headers: getHeaders({ from: "user@example.com" }), }); - const result = await filterToReplyPreset( + const result = await filterConversationStatusRules( potentialMatches, message, provider, @@ -1399,7 +1328,7 @@ describe("filterToReplyPreset", () => { headers: getHeaders({ from: "sender@example.com" }), }); - const result = await filterToReplyPreset( + const result = await filterConversationStatusRules( potentialMatches, message, provider, @@ -1447,7 +1376,7 @@ describe("filterToReplyPreset", () => { headers: getHeaders({ from: "friend@example.com" }), }); - const result = await filterToReplyPreset( + const result = await filterConversationStatusRules( potentialMatches, message, provider, @@ -1484,7 +1413,7 @@ describe("filterToReplyPreset", () => { headers: getHeaders({ from: "newcontact@example.com" }), }); - const result = await filterToReplyPreset( + const result = await filterConversationStatusRules( potentialMatches, message, provider, @@ -1517,7 +1446,7 @@ describe("filterToReplyPreset", () => { headers: getHeaders({ from: email }), }); - const result = await filterToReplyPreset( + const result = await filterConversationStatusRules( [toReplyRule], message, provider, @@ -1550,7 +1479,7 @@ describe("filterToReplyPreset", () => { headers: getHeaders({ from: "user@example.com" }), }); - const result = await filterToReplyPreset( + const result = await filterConversationStatusRules( potentialMatches, message, provider, @@ -1575,7 +1504,7 @@ describe("filterToReplyPreset", () => { headers: getHeaders({ from: "" }), }); - const result = await filterToReplyPreset( + const result = await filterConversationStatusRules( potentialMatches, message, provider, @@ -1587,19 +1516,16 @@ describe("filterToReplyPreset", () => { }); }); -function getRule( - overrides: Partial = {}, -): RuleWithActionsAndCategories { +function getRule(overrides: Partial = {}): RuleWithActions { return { id: "r123", userId: "userId", runOnThreads: true, conditionalOperator: LogicalOperator.AND, - categoryFilters: [], - categoryFilterType: CategoryFilterType.INCLUDE, type: null, + systemType: null, ...overrides, - } as RuleWithActionsAndCategories; + } as RuleWithActions; } function getHeaders( @@ -1621,18 +1547,6 @@ function getMessage(overrides: Partial = {}): ParsedMessage { return message as ParsedMessage; } -function getCategory(overrides: Partial = {}): Category { - return { - id: "category1", - name: "category", - createdAt: new Date(), - updatedAt: new Date(), - emailAccountId: "emailAccountId", - description: null, - ...overrides, - }; -} - function getGroup( overrides: Partial< Prisma.GroupGetPayload<{ include: { items: true; rule: true } }> @@ -1664,23 +1578,874 @@ function getGroupItem(overrides: Partial = {}): GroupItem { }; } -function getNewsletter(overrides: Partial = {}): Newsletter { - return { - id: "newsletter1", - createdAt: new Date(), - updatedAt: new Date(), - userId: "userId", - email: "test@example.com", - status: null, - categoryId: "category1", - ...overrides, - } as Newsletter; -} +describe("findMatchingRules - Integration Tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should detect and return cold email when enabled", async () => { + const coldEmailRule = getRule({ + id: "cold-email-rule", + systemType: SystemType.COLD_EMAIL, + }); + + vi.mocked(getColdEmailRule).mockResolvedValue(coldEmailRule); + vi.mocked(isColdEmailRuleEnabled).mockReturnValue(true); + vi.mocked(isColdEmail).mockResolvedValue({ + isColdEmail: true, + reason: "ai", + }); + vi.mocked(prisma.rule.findUniqueOrThrow).mockResolvedValue(coldEmailRule); + + const rules = [coldEmailRule]; + const message = getMessage({ + headers: getHeaders({ from: "coldemailer@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + expect(getColdEmailRule).toHaveBeenCalledWith(emailAccount.id); + expect(isColdEmailRuleEnabled).toHaveBeenCalledWith(coldEmailRule); + expect(isColdEmail).toHaveBeenCalledWith({ + email: expect.any(Object), + emailAccount, + provider, + modelType: "default", + coldEmailRule, + }); + + expect(result.matches[0]?.rule.id).toBe("cold-email-rule"); + expect(result.reasoning).toBe("ai"); + }); + + it("should skip cold email detection when rule is not enabled", async () => { + const coldEmailRule = getRule({ + id: "cold-email-rule", + systemType: SystemType.COLD_EMAIL, + }); + + const normalRule = getRule({ + id: "normal-rule", + from: "test@example.com", + }); + + vi.mocked(getColdEmailRule).mockResolvedValue(coldEmailRule); + vi.mocked(isColdEmailRuleEnabled).mockReturnValue(false); + + const rules = [coldEmailRule, normalRule]; + const message = getMessage({ + headers: getHeaders({ from: "test@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + expect(getColdEmailRule).toHaveBeenCalledWith(emailAccount.id); + expect(isColdEmailRuleEnabled).toHaveBeenCalledWith(coldEmailRule); + expect(isColdEmail).not.toHaveBeenCalled(); + + // Should match the normal rule instead + expect(result.matches[0]?.rule.id).toBe("normal-rule"); + }); + + it("should continue to other rules when email is not cold", async () => { + const coldEmailRule = getRule({ + id: "cold-email-rule", + systemType: SystemType.COLD_EMAIL, + }); + + const normalRule = getRule({ + id: "normal-rule", + from: "test@example.com", + }); + + vi.mocked(getColdEmailRule).mockResolvedValue(coldEmailRule); + vi.mocked(isColdEmailRuleEnabled).mockReturnValue(true); + vi.mocked(isColdEmail).mockResolvedValue({ + isColdEmail: false, + reason: "hasPreviousEmail", + }); + + const rules = [coldEmailRule, normalRule]; + const message = getMessage({ + headers: getHeaders({ from: "test@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + expect(isColdEmail).toHaveBeenCalled(); + + // Should continue and match the normal rule + expect(result.matches[0]?.rule.id).toBe("normal-rule"); + }); + + it("should match calendar rule when message has .ics attachment", async () => { + const calendarRule = getRule({ + id: "calendar-rule", + systemType: SystemType.CALENDAR, + }); + + const rules = [calendarRule]; + const message = getMessage({ + headers: getHeaders(), + attachments: [ + { + filename: "meeting.ics", + mimeType: "text/calendar", + size: 1024, + attachmentId: "attachment-1", + headers: { + "content-type": "text/calendar", + "content-description": "", + "content-transfer-encoding": "", + "content-id": "", + }, + }, + ], + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + expect(result.matches[0]?.rule.id).toBe("calendar-rule"); + expect(result.matches[0]?.matchReasons).toEqual([ + { type: ConditionType.PRESET, systemType: SystemType.CALENDAR }, + ]); + }); + + it("should execute AI rules when potentialAiMatches exist", async () => { + const aiRule = getRule({ + id: "ai-rule", + instructions: "Archive promotional emails", + from: null, + to: null, + subject: null, + body: null, + }); + + vi.mocked(aiChooseRule).mockResolvedValue({ + rules: [{ rule: aiRule as any }], + reason: "This is a promotional email", + }); + + const rules = [aiRule]; + const message = getMessage(); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + expect(aiChooseRule).toHaveBeenCalledWith( + expect.objectContaining({ + email: expect.any(Object), + emailAccount, + modelType: "default", + rules: expect.arrayContaining([ + expect.objectContaining({ + id: "ai-rule", + instructions: "Archive promotional emails", + }), + ]), + }), + ); + + expect(result.matches[0]?.rule.id).toBe("ai-rule"); + expect(result.matches[0]?.matchReasons).toEqual([ + { type: ConditionType.AI }, + ]); + expect(result.reasoning).toBe("This is a promotional email"); + }); + + it("should prioritize learned patterns over AI rules", async () => { + const learnedPatternRule = getRule({ + id: "learned-rule", + groupId: "group1", + }); + + const aiRule = getRule({ + id: "ai-rule", + instructions: "Some AI instructions", + }); + + prisma.group.findMany.mockResolvedValue([ + getGroup({ + id: "group1", + items: [ + getGroupItem({ type: GroupItemType.FROM, value: "test@example.com" }), + ], + rule: learnedPatternRule, + }), + ]); + + const rules = [learnedPatternRule, aiRule]; + const message = getMessage({ + headers: getHeaders({ from: "test@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + // Should match via learned pattern + expect(result.matches[0]?.rule.id).toBe("learned-rule"); + expect(result.matches[0]?.matchReasons?.[0]?.type).toBe( + ConditionType.LEARNED_PATTERN, + ); + + // AI should NOT be called because learned pattern matched + expect(aiChooseRule).not.toHaveBeenCalled(); + }); + + it("should skip rules with runOnThreads=false when message is a thread", async () => { + const threadRule = getRule({ + id: "thread-rule", + from: "test@example.com", + runOnThreads: false, + }); + + // Mock provider to return true for isReplyInThread + const threadProvider = { + isReplyInThread: vi.fn().mockReturnValue(true), + } as unknown as EmailProvider; + + // Mock no previously executed rules in thread + prisma.executedRule.findMany.mockResolvedValue([]); + + const rules = [threadRule]; + const message = getMessage({ + headers: getHeaders({ from: "test@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider: threadProvider, + modelType: "default", + }); + + // Rule should not match because it's a thread and runOnThreads=false + expect(result.matches).toHaveLength(0); + }); + + describe("filterMultipleSystemRules branches", () => { + it("returns all system rules when none marked primary (plus conversation rules)", () => { + const sysA: { + name: string; + instructions: string; + systemType: string | null; + } = { + name: "Sys A", + instructions: "", + systemType: "TO_REPLY", + }; + const sysB: { + name: string; + instructions: string; + systemType: string | null; + } = { + name: "Sys B", + instructions: "", + systemType: "AWAITING_REPLY", + }; + const conv: { + name: string; + instructions: string; + systemType: string | null; + } = { + name: "Conv", + instructions: "", + systemType: null, + }; + + const result = filterMultipleSystemRules([ + { rule: sysA, isPrimary: false }, + { rule: sysB }, + { rule: conv }, + ]); + + expect(result).toEqual([sysA, sysB, conv]); + }); + + it("keeps only the primary system rule when multiple system rules present", () => { + const sysA: { + name: string; + instructions: string; + systemType: string | null; + } = { + name: "Sys A", + instructions: "", + systemType: "TO_REPLY", + }; + const sysB: { + name: string; + instructions: string; + systemType: string | null; + } = { + name: "Sys B", + instructions: "", + systemType: "AWAITING_REPLY", + }; + const conv: { + name: string; + instructions: string; + systemType: string | null; + } = { + name: "Conv", + instructions: "", + systemType: null, + }; + + const result = filterMultipleSystemRules([ + { rule: sysA, isPrimary: false }, + { rule: sysB, isPrimary: true }, + { rule: conv }, + ]); + + expect(result).toEqual([sysB, conv]); + }); + }); + + describe("Group rules fallthrough when no groups exist", () => { + it("falls through to static/AI evaluation when getGroupsWithRules returns empty", async () => { + const groupRule = getRule({ + id: "group-rule-1", + from: "group@example.com", + groupId: "g1", + }); + + // Ensure provider treats this as non-thread + const providerNoThread = { + isReplyInThread: vi.fn().mockReturnValue(false), + } as unknown as EmailProvider; + + // Mock groups to be empty so the code path skips learned pattern branch + const groupModule = await import("@/utils/group/find-matching-group"); + vi.spyOn(groupModule, "getGroupsWithRules").mockResolvedValue([] as any); + + const rules = [groupRule]; + const message = getMessage({ + headers: getHeaders({ from: "group@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider: providerNoThread, + modelType: "default", + }); + + // Should match via static evaluation since groups are empty + expect(result.matches).toHaveLength(1); + expect(result.matches[0]?.rule.id).toBe("group-rule-1"); + }); + }); + describe("Thread continuity - runOnThreads=false rules", () => { + it("should continue applying rule in a thread when it was previously applied", async () => { + const notifRule = getRule({ + id: "notif-rule", + from: "notif@example.com", + runOnThreads: false, + }); + + // Mock provider to indicate this is a thread + const threadProvider = { + isReplyInThread: vi.fn().mockReturnValue(true), + } as unknown as EmailProvider; + + // Mock DB to return previously executed rule id + prisma.executedRule.findMany.mockResolvedValue([ + { ruleId: "notif-rule" }, + ] as any); + + const rules = [notifRule]; + const message = getMessage({ + headers: getHeaders({ from: "notif@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider: threadProvider, + modelType: "default", + }); + + expect(prisma.executedRule.findMany).toHaveBeenCalledTimes(1); + expect(result.matches).toHaveLength(1); + expect(result.matches[0]?.rule.id).toBe("notif-rule"); + }); + + it("should lazy-load previous rules only once for multiple runOnThreads=false rules", async () => { + const ruleA = getRule({ + id: "rule-a", + from: "multi@example.com", + runOnThreads: false, + }); + const ruleB = getRule({ + id: "rule-b", + from: "multi@example.com", + runOnThreads: false, + }); + + const threadProvider = { + isReplyInThread: vi.fn().mockReturnValue(true), + } as unknown as EmailProvider; + + prisma.executedRule.findMany.mockResolvedValue([ + { ruleId: "rule-a" }, + { ruleId: "rule-b" }, + ] as any); + + const rules = [ruleA, ruleB]; + const message = getMessage({ + headers: getHeaders({ from: "multi@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider: threadProvider, + modelType: "default", + }); + + expect(prisma.executedRule.findMany).toHaveBeenCalledTimes(1); + expect(result.matches.map((m) => m.rule.id).sort()).toEqual([ + "rule-a", + "rule-b", + ]); + }); + + it("should not query DB when message is not a thread", async () => { + const notifRule = getRule({ + id: "not-thread", + from: "no-thread@example.com", + runOnThreads: false, + }); + + const providerNotThread = { + isReplyInThread: vi.fn().mockReturnValue(false), + } as unknown as EmailProvider; + + const rules = [notifRule]; + const message = getMessage({ + headers: getHeaders({ from: "no-thread@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider: providerNotThread, + modelType: "default", + }); + + expect(prisma.executedRule.findMany).not.toHaveBeenCalled(); + // Not a thread, so normal matching applies (matches by static from) + expect(result.matches).toHaveLength(1); + expect(result.matches[0]?.rule.id).toBe("not-thread"); + }); + + it("should not query DB when rule has runOnThreads=true (even in a thread)", async () => { + const threadRule = getRule({ + id: "thread-ok", + from: "yes-thread@example.com", + runOnThreads: true, + }); + + const threadProvider = { + isReplyInThread: vi.fn().mockReturnValue(true), + } as unknown as EmailProvider; + + const rules = [threadRule]; + const message = getMessage({ + headers: getHeaders({ from: "yes-thread@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider: threadProvider, + modelType: "default", + }); + + expect(prisma.executedRule.findMany).not.toHaveBeenCalled(); + expect(result.matches).toHaveLength(1); + expect(result.matches[0]?.rule.id).toBe("thread-ok"); + }); + }); + + it("should handle invalid regex patterns gracefully", () => { + const rule = getRule({ + from: "[invalid(regex", + }); + + const message = getMessage({ + headers: getHeaders({ from: "test@example.com" }), + }); + + // Should not throw, just return false + expect(() => matchesStaticRule(rule, message)).not.toThrow(); + const result = matchesStaticRule(rule, message); + expect(result).toBe(false); + }); + + it("should combine static match with AI potentialMatch correctly", async () => { + const mixedRule = getRule({ + id: "mixed-rule", + from: "test@example.com", + instructions: "Archive if promotional", + conditionalOperator: LogicalOperator.AND, + }); + + vi.mocked(aiChooseRule).mockResolvedValue({ + rules: [{ rule: mixedRule as any }], + reason: "Email is promotional", + }); + + const rules = [mixedRule]; + const message = getMessage({ + headers: getHeaders({ from: "test@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + // Static matched, so should be sent to AI for AND check + expect(aiChooseRule).toHaveBeenCalled(); + expect(result.matches[0]?.rule.id).toBe("mixed-rule"); + }); + + it("merges static match with AI rule and combines reasoning text", async () => { + const staticRule = getRule({ + id: "static-rule-1", + from: "reason@example.com", + }); + const aiOnlyRule = getRule({ id: "ai-rule-2", instructions: "Do X" }); + + // Ensure potentialAiMatches includes aiOnlyRule + vi.mocked(aiChooseRule).mockResolvedValue({ + rules: [aiOnlyRule as any], + reason: "AI reasoning here", + }); + + const rules = [staticRule, aiOnlyRule]; + const message = getMessage({ + headers: getHeaders({ from: "reason@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + // Reasoning should combine existing matchReasons text + AI reason + // existing part comes from getMatchReason => "Matched static conditions" + expect(result.reasoning).toBe( + "Matched static conditions; AI reasoning here", + ); + }); + + it("matchesStaticRule: catches RegExp construction error and returns false", () => { + const rule = getRule({ from: "trigger-error" }); + const message = getMessage({ + headers: getHeaders({ from: "any@example.com" }), + }); + + const OriginalRegExp = RegExp; + // Monkeypatch RegExp to throw for our specific pattern + // Only for this test; restore afterwards + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).RegExp = ((pattern: string) => { + if (pattern.includes("trigger-error")) { + throw new Error("synthetic error"); + } + // Delegate to original + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new (OriginalRegExp as any)(pattern); + }) as unknown as RegExpConstructor; + + try { + const matched = matchesStaticRule(rule as any, message as any); + expect(matched).toBe(false); + } finally { + // restore + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).RegExp = + OriginalRegExp as unknown as RegExpConstructor; + } + }); + + it("AI path: returns only AI reasoning when no static matches and AI returns no rules", async () => { + const aiOnlyRule = getRule({ id: "ai-only-1", instructions: "Do Y" }); + + vi.mocked(aiChooseRule).mockResolvedValue({ + rules: [], + reason: "AI had reasoning but selected nothing", + }); + + const rules = [aiOnlyRule]; + const message = getMessage({ + // No static matchers + headers: getHeaders({ from: "nobody@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + expect(result.matches.map((m) => m.rule.id)).toEqual([]); + expect(result.reasoning).toBe("AI had reasoning but selected nothing"); + }); + + it("AI path: dedups AI-selected rule when it duplicates a static match", async () => { + const dupRule = getRule({ + id: "dup-rule", + from: "dup@example.com", + instructions: "Use AI too", + runOnThreads: true, + }); + + vi.mocked(aiChooseRule).mockResolvedValue({ + rules: [{ rule: dupRule as any }], + reason: "AI selects dup-rule", + }); + + const rules = [dupRule]; + const message = getMessage({ + headers: getHeaders({ from: "dup@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const spy = vi.spyOn(provider, "isReplyInThread").mockReturnValue(false); + try { + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + // Only one occurrence of dup-rule should remain + const ids = result.matches.map((m) => m.rule.id); + expect(ids).toEqual(["dup-rule"]); + expect(result.reasoning).toContain("AI selects dup-rule"); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("evaluateRuleConditions", () => { + it("should match STATIC condition", () => { + const rule = getRule({ from: "test@example.com" }); + const message = getMessage({ + headers: getHeaders({ from: "test@example.com" }), + }); + + const result = evaluateRuleConditions({ rule, message }); + + expect(result.matched).toBe(true); + expect(result.potentialAiMatch).toBe(false); + expect(result.matchReasons).toEqual([{ type: ConditionType.STATIC }]); + }); + + it("should not match when STATIC condition fails", () => { + const rule = getRule({ from: "test@example.com" }); + const message = getMessage({ + headers: getHeaders({ from: "other@example.com" }), + }); + + const result = evaluateRuleConditions({ rule, message }); + + expect(result.matched).toBe(false); + expect(result.potentialAiMatch).toBe(false); + expect(result.matchReasons).toEqual([]); + }); + + it("should return potentialAiMatch for AI-only rule", () => { + const rule = getRule({ + instructions: "Some AI instructions", + from: null, + to: null, + subject: null, + body: null, + }); + const message = getMessage(); + + const result = evaluateRuleConditions({ rule, message }); + + expect(result.matched).toBe(false); + expect(result.potentialAiMatch).toBe(true); + expect(result.matchReasons).toEqual([]); + }); + + it("OR: should match immediately with STATIC, ignoring AI", () => { + const rule = getRule({ + conditionalOperator: LogicalOperator.OR, + from: "test@example.com", + instructions: "Some AI instructions", + }); + const message = getMessage({ + headers: getHeaders({ from: "test@example.com" }), + }); + + const result = evaluateRuleConditions({ rule, message }); + + expect(result.matched).toBe(true); + expect(result.potentialAiMatch).toBe(false); + expect(result.matchReasons).toEqual([{ type: ConditionType.STATIC }]); + }); + + it("OR: should return potentialAiMatch when STATIC fails but has AI", () => { + const rule = getRule({ + conditionalOperator: LogicalOperator.OR, + from: "test@example.com", + instructions: "Some AI instructions", + }); + const message = getMessage({ + headers: getHeaders({ from: "other@example.com" }), + }); + + const result = evaluateRuleConditions({ rule, message }); + + expect(result.matched).toBe(false); + expect(result.potentialAiMatch).toBe(true); + expect(result.matchReasons).toEqual([]); + }); + + it("AND: should return potentialAiMatch when STATIC passes and has AI", () => { + const rule = getRule({ + conditionalOperator: LogicalOperator.AND, + from: "test@example.com", + instructions: "Some AI instructions", + }); + const message = getMessage({ + headers: getHeaders({ from: "test@example.com" }), + }); + + const result = evaluateRuleConditions({ rule, message }); + + expect(result.matched).toBe(false); + expect(result.potentialAiMatch).toBe(true); + expect(result.matchReasons).toEqual([{ type: ConditionType.STATIC }]); + }); + + it("AND: should not match when STATIC fails even with AI", () => { + const rule = getRule({ + conditionalOperator: LogicalOperator.AND, + from: "test@example.com", + instructions: "Some AI instructions", + }); + const message = getMessage({ + headers: getHeaders({ from: "other@example.com" }), + }); + + const result = evaluateRuleConditions({ rule, message }); + + expect(result.matched).toBe(false); + expect(result.potentialAiMatch).toBe(false); + expect(result.matchReasons).toEqual([]); + }); + + it("should match when no conditions are present", () => { + const rule = getRule({ + from: null, + to: null, + subject: null, + body: null, + instructions: null, + }); + const message = getMessage(); + + const result = evaluateRuleConditions({ rule, message }); + + expect(result.matched).toBe(true); + expect(result.potentialAiMatch).toBe(false); + expect(result.matchReasons).toEqual([]); + }); + + it("OR: should not match when STATIC fails and no AI condition", () => { + const rule = getRule({ + conditionalOperator: LogicalOperator.OR, + from: "test@example.com", + instructions: null, + }); + const message = getMessage({ + headers: getHeaders({ from: "other@example.com" }), + }); + + const result = evaluateRuleConditions({ rule, message }); + + expect(result.matched).toBe(false); + expect(result.potentialAiMatch).toBe(false); + expect(result.matchReasons).toEqual([]); + }); +}); function getStaticRule( - rule: Partial< - Pick - >, + rule: Partial>, ) { return { from: null, diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index ad18c34323..8afb2d5f00 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -3,13 +3,9 @@ import { findMatchingGroup, getGroupsWithRules, } from "@/utils/group/find-matching-group"; -import type { - ParsedMessage, - RuleWithActions, - RuleWithActionsAndCategories, -} from "@/utils/types"; +import type { ParsedMessage, RuleWithActions } from "@/utils/types"; import { - CategoryFilterType, + ExecutedRuleStatus, LogicalOperator, SystemType, } from "@prisma/client"; @@ -33,156 +29,308 @@ import { isColdEmailRuleEnabled, } from "@/utils/cold-email/cold-email-rule"; import { isColdEmail } from "@/utils/cold-email/is-cold-email"; +import { isConversationStatusType } from "@/utils/reply-tracker/conversation-status-config"; const logger = createScopedLogger("match-rules"); const TO_REPLY_RECEIVED_THRESHOLD = 10; -// if we find a match, return it -// if we don't find a match, return the potential matches -// ai rules need further processing to determine if they match +type MatchingRulesResult = { + matches: { + rule: RuleWithActions; + matchReasons?: MatchReason[]; + }[]; + reasoning: string; +}; -async function findPotentialMatchingRules({ +export async function findMatchingRules({ rules, message, - isThread, + emailAccount, provider, + modelType, }: { - rules: RuleWithActionsAndCategories[]; + rules: RuleWithActions[]; message: ParsedMessage; - isThread: boolean; + emailAccount: EmailAccountWithAI; provider: EmailProvider; -}): Promise { - const potentialMatches: (RuleWithActionsAndCategories & { - instructions: string; - })[] = []; - - const isCalendarEvent = hasIcsAttachment(message); - if (isCalendarEvent) { - const calendarRule = rules.find( - (r) => r.systemType === SystemType.CALENDAR, - ); - if (calendarRule) { - logger.info("Found matching calendar rule", { - ruleId: calendarRule.id, - messageId: message.id, + modelType: ModelType; +}): Promise { + const coldEmailRule = await getColdEmailRule(emailAccount.id); + + if (coldEmailRule && isColdEmailRuleEnabled(coldEmailRule)) { + const coldEmailResult = await isColdEmail({ + email: getEmailForLLM(message), + emailAccount, + provider, + modelType, + coldEmailRule, + }); + + if (coldEmailResult.isColdEmail) { + const coldRule = await prisma.rule.findUniqueOrThrow({ + where: { id: coldEmailRule.id }, + include: { actions: true }, }); + return { - match: calendarRule, - matchReasons: [ - { type: ConditionType.PRESET, systemType: SystemType.CALENDAR }, - ], + matches: [{ rule: coldRule, matchReasons: [] }], + reasoning: coldEmailResult.reason, }; } } - // only load once and only when needed - let groups: Awaited>; - async function getGroups({ emailAccountId }: { emailAccountId: string }) { - if (!groups) groups = await getGroupsWithRules({ emailAccountId }); - return groups; - } - - let sender: { categoryId: string | null } | null | undefined; - async function getSender({ emailAccountId }: { emailAccountId: string }) { - if (typeof sender === "undefined") { - sender = await prisma.newsletter.findUnique({ - where: { - email_emailAccountId: { - email: extractEmailAddress(message.headers.from), - emailAccountId, - }, - }, - select: { categoryId: true }, - }); - } - return sender; - } + // Filter out cold email rule which was already checked above + const rulesWithoutColdEmail = rules.filter( + (rule) => rule.systemType !== SystemType.COLD_EMAIL, + ); - // loop through rules and check if they match - for (const rule of rules) { - const { runOnThreads, conditionalOperator: operator } = rule; + const results = await findMatchingRulesWithReasons( + rulesWithoutColdEmail, + message, + emailAccount, + provider, + modelType, + ); - if (isThread && !runOnThreads) continue; + return results; +} - const conditionTypes = getConditionTypes(rule); - const matchReasons: MatchReason[] = []; +/** + * Finds all rules that potentially match a message. + * + * Matching Logic: + * 1. For rules with learned patterns (groups): + * - If pattern matches → add to matches and short-circuit (skip other checks for this rule) + * - If pattern doesn't match → continue to check static/AI conditions below + * - Note: Groups are independent of the AND/OR operator (which only applies to AI/Static conditions) + * + * 2. For all other rules (or group rules that didn't match via pattern): + * - Check static conditions (from, to, subject, body) + * - Check if AI instructions are present + * - Respect the conditional operator (AND/OR) between static and AI conditions + * - Add to matches if conditions match, or to potentialAiMatches if AI check is needed + * + * 3. Prioritization (at the end): + * - If ANY learned pattern matches were found → ignore all potentialAiMatches + * - This is an optimization: learned patterns are trusted and avoid expensive AI calls + * - Multiple learned pattern matches can be returned + */ +async function findPotentialMatchingRules({ + rules, + message, + isThread, + provider, + emailAccountId, +}: { + rules: RuleWithActions[]; + message: ParsedMessage; + isThread: boolean; + provider: EmailProvider; + emailAccountId: string; +}): Promise { + const matches: { + rule: RuleWithActions; + matchReasons: MatchReason[]; + }[] = []; + const potentialAiMatches: (RuleWithActions & { instructions: string })[] = []; + + const learnedPatternsLoader = new LearnedPatternsLoader(); + const previousRulesLoader = new PreviousThreadRulesLoader({ + emailAccountId, + threadId: message.threadId, + }); + + // Go through all rules and collect matches and potential AI matches + for (const rule of rules) { + // Special case for calendar rules + const calendarMatch = + rule.systemType === SystemType.CALENDAR && hasIcsAttachment(message); - // group - ignores conditional operator - // if a match is found, return it - if (rule.groupId) { - const { matchingItem, group, ruleExcluded } = await matchesGroupRule( + if (calendarMatch) { + matches.push({ rule, - await getGroups({ emailAccountId: rule.emailAccountId }), - message, - ); - - // If this rule is excluded by an exclusion pattern, skip it entirely - if (ruleExcluded) continue; + matchReasons: [ + { type: ConditionType.PRESET, systemType: SystemType.CALENDAR }, + ], + }); + continue; + } - if (matchingItem) { - matchReasons.push({ - type: ConditionType.GROUP, - groupItem: matchingItem, - group, - }); + // Learned patterns (groups) + // Note: Groups are independent of the AND/OR operator (which only applies to AI/Static conditions) + if (rule.groupId) { + const groups = await learnedPatternsLoader.getGroups(rule.emailAccountId); + if (groups?.length) { + const { matchingItem, group, ruleExcluded } = matchesGroupRule( + rule, + groups, + message, + ); - return { match: rule, matchReasons }; + // If this rule is excluded by an exclusion pattern, skip it entirely + if (ruleExcluded) continue; + + if (matchingItem) { + // Group matched - add to matches and skip other condition checks + matches.push({ + rule, + matchReasons: [ + { + type: ConditionType.LEARNED_PATTERN, + groupItem: matchingItem, + group, + }, + ], + }); + continue; + } } } - // Regular conditions: - const unmatchedConditions = new Set( - Object.keys(conditionTypes) as ConditionType[], - ); + // Skip rules with runOnThreads=false, unless this rule was previously applied in the thread + // This ensures thread continuity (e.g., notifications continue to be labeled as notifications) + if (isThread && !rule.runOnThreads) { + const previousRuleIds = await previousRulesLoader.getRuleIds(); + const wasPreviouslyApplied = previousRuleIds.has(rule.id); - if (conditionTypes.STATIC) { - const match = matchesStaticRule(rule, message); - if (match) { - unmatchedConditions.delete(ConditionType.STATIC); - matchReasons.push({ type: ConditionType.STATIC }); - if (operator === LogicalOperator.OR || !unmatchedConditions.size) - return { match: rule, matchReasons }; - } else { - // no match, so can't be a match with AND - if (operator === LogicalOperator.AND) continue; + if (!wasPreviouslyApplied) { + continue; } } - if (conditionTypes.CATEGORY) { - const matchedCategory = await matchesCategoryRule( - rule, - await getSender({ emailAccountId: rule.emailAccountId }), - ); - if (matchedCategory) { - unmatchedConditions.delete(ConditionType.CATEGORY); - if (typeof matchedCategory !== "boolean") { - matchReasons.push({ - type: ConditionType.CATEGORY, - category: matchedCategory, - }); - } - if (operator === LogicalOperator.OR || !unmatchedConditions.size) - return { match: rule, matchReasons }; - } else { - // no match, so can't be a match with AND - if (operator === LogicalOperator.AND) continue; - } + // AI + Static conditions + const { matched, potentialAiMatch, matchReasons } = evaluateRuleConditions({ + rule, + message, + }); + + if (matched) { + matches.push({ rule, matchReasons }); } - if (conditionTypes.AI && isAIRule(rule)) { - // we'll need to run the LLM later to determine if it matches - potentialMatches.push(rule); + if (potentialAiMatch) { + potentialAiMatches.push({ + ...rule, + instructions: rule.instructions ?? "", + }); } } - const filteredPotentialMatches = await filterToReplyPreset( - potentialMatches, + // TODO: move into loop for consistency? + const filteredPotentialAiMatches = await filterConversationStatusRules( + potentialAiMatches, message, provider, ); - return { potentialMatches: filteredPotentialMatches }; + const hasLearnedPatternMatch = matches.some((m) => + m.matchReasons.some((r) => r.type === ConditionType.LEARNED_PATTERN), + ); + + // If we have a learned pattern match, then return all matches and no potential AI matches + // Learned patterns are used for efficiency to avoid running AI for every rule + return { + matches, + potentialAiMatches: hasLearnedPatternMatch + ? [] + : filteredPotentialAiMatches, + }; +} + +export function evaluateRuleConditions({ + rule, + message, +}: { + rule: RuleWithActions; + message: ParsedMessage; +}): { + matched: boolean; + potentialAiMatch: boolean; + matchReasons: MatchReason[]; +} { + const { conditionalOperator: operator } = rule; + const conditionTypes = getConditionTypes(rule); + const hasAiCondition = conditionTypes.AI && isAIRule(rule); + const hasStaticCondition = conditionTypes.STATIC; + + const matchReasons: MatchReason[] = []; + + // Check STATIC condition + const staticMatch = hasStaticCondition + ? matchesStaticRule(rule, message) + : false; + if (staticMatch) { + matchReasons.push({ type: ConditionType.STATIC }); + } + + // Determine result based on what we have + if (operator === LogicalOperator.OR) { + // OR logic + if (staticMatch) { + // Found a match, no need for AI + return { matched: true, potentialAiMatch: false, matchReasons }; + } + if (hasAiCondition) { + // No static match, but have AI - need to check AI + return { matched: false, potentialAiMatch: true, matchReasons }; + } + // No conditions at all (match everything) or no matches + const matched = !hasStaticCondition && !hasAiCondition; + return { matched, potentialAiMatch: false, matchReasons }; + } else { + // AND logic + if (hasStaticCondition && !staticMatch) { + // Static failed, so AND fails + return { matched: false, potentialAiMatch: false, matchReasons: [] }; + } + if (hasAiCondition) { + // Static passed (or doesn't exist), but need AI to complete AND + return { matched: false, potentialAiMatch: true, matchReasons }; + } + // Only static (and it passed), or no conditions at all (match everything) + const matched = hasStaticCondition ? staticMatch : true; + return { matched, potentialAiMatch: false, matchReasons }; + } +} + +// Lazy load learned patterns when needed +class LearnedPatternsLoader { + private groups?: Awaited> | null; + + async getGroups(emailAccountId: string) { + if (this.groups === undefined) + this.groups = await getGroupsWithRules({ emailAccountId }); + return this.groups; + } +} + +// Lazy load previously executed rules in thread when needed +class PreviousThreadRulesLoader { + private ruleIds?: Set; + private readonly emailAccountId: string; + private readonly threadId: string; + + constructor({ + emailAccountId, + threadId, + }: { + emailAccountId: string; + threadId: string; + }) { + this.emailAccountId = emailAccountId; + this.threadId = threadId; + } + + async getRuleIds(): Promise> { + if (this.ruleIds === undefined) { + this.ruleIds = await getPreviouslyExecutedRuleIds({ + emailAccountId: this.emailAccountId, + threadId: this.threadId, + }); + } + return this.ruleIds; + } } function getMatchReason(matchReasons?: MatchReason[]): string | undefined { @@ -193,10 +341,8 @@ function getMatchReason(matchReasons?: MatchReason[]): string | undefined { switch (reason.type) { case ConditionType.STATIC: return "Matched static conditions"; - case ConditionType.GROUP: + case ConditionType.LEARNED_PATTERN: return `Matched learned pattern: "${reason.groupItem.type}: ${reason.groupItem.value}"`; - case ConditionType.CATEGORY: - return `Matched category: "${reason.category.name}"`; case ConditionType.PRESET: return "Matched a system preset"; } @@ -204,100 +350,81 @@ function getMatchReason(matchReasons?: MatchReason[]): string | undefined { .join(", "); } -export async function findMatchingRule({ - rules, - message, - emailAccount, - provider, - modelType, -}: { - rules: RuleWithActionsAndCategories[]; - message: ParsedMessage; - emailAccount: EmailAccountWithAI; - provider: EmailProvider; - modelType: ModelType; -}): Promise<{ - rule?: RuleWithActionsAndCategories; - matchReasons?: MatchReason[]; - reason?: string; -}> { - const coldEmailRule = await getColdEmailRule(emailAccount.id); - - if (coldEmailRule && isColdEmailRuleEnabled(coldEmailRule)) { - const coldEmailResult = await isColdEmail({ - email: getEmailForLLM(message), - emailAccount, - provider, - modelType, - coldEmailRule, - }); - - if (coldEmailResult.isColdEmail) { - const coldEmailRuleWithCategories = await prisma.rule.findUniqueOrThrow({ - where: { id: coldEmailRule.id }, - include: { actions: true, categoryFilters: true }, - }); - - return { - rule: coldEmailRuleWithCategories, - reason: coldEmailResult.reason, - }; - } - } - - // Filter out cold email rule which was already checked above - const rulesWithoutColdEmail = rules.filter( - (rule) => rule.systemType !== SystemType.COLD_EMAIL, - ); - - const result = await findMatchingRuleWithReasons( - rulesWithoutColdEmail, - message, - emailAccount, - provider, - modelType, - ); - return { - ...result, - reason: result.reason || getMatchReason(result.matchReasons || []), - }; -} - -async function findMatchingRuleWithReasons( - rules: RuleWithActionsAndCategories[], +async function findMatchingRulesWithReasons( + rules: RuleWithActions[], message: ParsedMessage, emailAccount: EmailAccountWithAI, provider: EmailProvider, modelType: ModelType, -): Promise<{ - rule?: RuleWithActionsAndCategories; - matchReasons?: MatchReason[]; - reason?: string; -}> { +): Promise { const isThread = provider.isReplyInThread(message); - const { match, matchReasons, potentialMatches } = - await findPotentialMatchingRules({ - rules, - message, - isThread, - provider, - }); - - if (match) return { rule: match, matchReasons }; + const { matches, potentialAiMatches } = await findPotentialMatchingRules({ + rules, + message, + isThread, + provider, + emailAccountId: emailAccount.id, + }); - if (potentialMatches?.length) { - const result = await aiChooseRule({ + if (potentialAiMatches.length) { + const fullResult = await aiChooseRule({ email: getEmailForLLM(message), - rules: potentialMatches, + rules: potentialAiMatches, emailAccount, modelType, }); - return result; + const result = { + rules: filterMultipleSystemRules(fullResult.rules), + reason: fullResult.reason, + }; + + // Build combined matches: start with existing static/learned matches, then append AI-selected matches + const combinedMatches = [ + // Map existing matches to the same output shape + ...matches.map((match) => ({ + rule: match.rule, + matchReasons: match.matchReasons || [], + })), + // Append AI-selected matches, deduplicating by rule id + ...result.rules + .filter( + (aiRule) => + !matches.some( + (existingMatch) => existingMatch.rule.id === aiRule.id, + ), + ) + .map((rule) => ({ + rule, + matchReasons: [{ type: ConditionType.AI }], + })), + ]; + + // Combine reasoning: existing reasoning plus AI reasoning + const existingReasoning = matches + .map((m) => getMatchReason(m.matchReasons)) + .filter((r): r is string => !!r) + .join(", "); + + const aiReason = result.reason?.trim(); + const combinedReasoning = [existingReasoning, aiReason] + .filter((r): r is string => !!r) + .join("; "); + + return { + matches: combinedMatches, + reasoning: combinedReasoning, + }; + } else { + return { + matches, + reasoning: matches + .map((m) => getMatchReason(m.matchReasons)) + .filter((r): r is string => !!r) + .join(", "), + }; } - - return {}; } export function matchesStaticRule( @@ -367,8 +494,8 @@ export function splitEmailPatterns(pattern: string): string[] { .filter(Boolean); } -async function matchesGroupRule( - rule: RuleWithActionsAndCategories, +function matchesGroupRule( + rule: RuleWithActions, groups: Awaited>, message: ParsedMessage, ) { @@ -390,31 +517,7 @@ async function matchesGroupRule( return { group: null, matchingItem: null, ruleExcluded: false }; } -async function matchesCategoryRule( - rule: RuleWithActionsAndCategories, - sender: { categoryId: string | null } | null, -) { - if (!rule.categoryFilterType || rule.categoryFilters.length === 0) - return true; - - if (!sender) return false; - - const matchedFilter = rule.categoryFilters.find( - (c) => c.id === sender.categoryId, - ); - - if ( - (rule.categoryFilterType === CategoryFilterType.INCLUDE && - !matchedFilter) || - (rule.categoryFilterType === CategoryFilterType.EXCLUDE && matchedFilter) - ) { - return false; - } - - return matchedFilter; -} - -export async function filterToReplyPreset< +export async function filterConversationStatusRules< T extends { id: string; systemType: SystemType | null }, >( potentialMatches: T[], @@ -443,14 +546,16 @@ export async function filterToReplyPreset< "account@", ]; - function filteredOutToReplyRule() { - return potentialMatches.filter((r) => r.systemType !== SystemType.TO_REPLY); + function filteredOutConversationStatusRules() { + return potentialMatches.filter( + (r) => !isConversationStatusType(r.systemType), + ); } if ( noReplyPrefixes.some((prefix) => extractedSenderEmail.startsWith(prefix)) ) { - return filteredOutToReplyRule(); + return filteredOutConversationStatusRules(); } try { @@ -469,7 +574,7 @@ export async function filterToReplyPreset< receivedCount, }, ); - return filteredOutToReplyRule(); + return filteredOutConversationStatusRules(); } } catch (error) { logger.error("Error checking reply history for TO_REPLY filter", { @@ -480,3 +585,53 @@ export async function filterToReplyPreset< return potentialMatches; } + +/** + * Filter system rules: if multiple system rules were matched, only keep the primary one. + * Always keep all conversation rules (non-system rules). + */ +export function filterMultipleSystemRules< + T extends { name: string; instructions: string; systemType?: string | null }, +>(selectedRules: { rule: T; isPrimary?: boolean }[]): T[] { + const systemRules = selectedRules.filter((r) => r.rule?.systemType); + const conversationRules = selectedRules.filter( + (r) => r.rule && !r.rule?.systemType, + ); + + let filteredSystemRules = systemRules; + if (systemRules.length > 1) { + // Only keep the primary system rule + const primarySystemRule = systemRules.find((r) => r.isPrimary); + filteredSystemRules = primarySystemRule ? [primarySystemRule] : systemRules; + } + + return [...filteredSystemRules, ...conversationRules].map((r) => r.rule); +} + +/** + * Gets the IDs of rules that were previously executed in this thread. + * This allows us to continue applying the same rules to a thread for consistency, + * even if `runOnThreads` is false. + */ +async function getPreviouslyExecutedRuleIds({ + emailAccountId, + threadId, +}: { + emailAccountId: string; + threadId: string; +}): Promise> { + const previousRules = await prisma.executedRule.findMany({ + where: { + emailAccountId, + threadId, + status: ExecutedRuleStatus.APPLIED, + ruleId: { not: null }, + }, + select: { ruleId: true }, + distinct: ["ruleId"], + }); + + return new Set( + previousRules.map((r) => r.ruleId).filter((id): id is string => !!id), + ); +} diff --git a/apps/web/utils/ai/choose-rule/run-rules.test.ts b/apps/web/utils/ai/choose-rule/run-rules.test.ts new file mode 100644 index 0000000000..9e7b93ff63 --- /dev/null +++ b/apps/web/utils/ai/choose-rule/run-rules.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + ensureConversationRuleContinuity, + CONVERSATION_TRACKING_META_RULE_ID, +} from "./run-rules"; +import { ExecutedRuleStatus, SystemType } from "@prisma/client"; +import { ConditionType } from "@/utils/config"; +import prisma from "@/utils/__mocks__/prisma"; +import type { RuleWithActions } from "@/utils/types"; + +vi.mock("@/utils/prisma"); +vi.mock("server-only", () => ({})); + +describe("ensureConversationRuleContinuity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const emailAccountId = "account-1"; + const threadId = "thread-1"; + + const createRule = ( + id: string, + systemType: SystemType | null = null, + ): RuleWithActions => ({ + id, + name: `Rule ${id}`, + instructions: `Instructions for ${id}`, + enabled: true, + emailAccountId, + createdAt: new Date(), + updatedAt: new Date(), + actions: [], + runOnThreads: false, + from: null, + to: null, + subject: null, + body: null, + groupId: null, + conditionalOperator: "AND" as const, + systemType, + automate: true, + promptText: null, + categoryFilterType: null, + }); + + const conversationMetaRule = createRule(CONVERSATION_TRACKING_META_RULE_ID); + const toReplyRule = createRule("to-reply-rule", SystemType.TO_REPLY); + const regularRule = createRule("regular-rule"); + + it("returns matches unchanged when there are no conversation rules", async () => { + const matches = [{ rule: regularRule }]; + + const result = await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + conversationRules: [], + regularRules: [regularRule], + matches, + }); + + expect(result).toEqual(matches); + expect(prisma.executedRule.findFirst).not.toHaveBeenCalled(); + }); + + it("returns matches unchanged when no previous conversation rule was applied in thread", async () => { + prisma.executedRule.findFirst.mockResolvedValue(null); + + const matches = [{ rule: regularRule }]; + + const result = await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + conversationRules: [toReplyRule], + regularRules: [regularRule, conversationMetaRule], + matches, + }); + + expect(result).toEqual(matches); + expect(prisma.executedRule.findFirst).toHaveBeenCalledWith({ + where: { + emailAccountId, + threadId, + status: ExecutedRuleStatus.APPLIED, + rule: { + systemType: { + in: expect.arrayContaining([ + SystemType.TO_REPLY, + SystemType.AWAITING_REPLY, + SystemType.FYI, + SystemType.ACTIONED, + ]), + }, + }, + }, + select: { id: true }, + }); + }); + + it("returns matches unchanged when conversation meta rule is already in matches", async () => { + prisma.executedRule.findFirst.mockResolvedValue({ + id: "executed-rule-1", + } as any); + + const matches = [{ rule: conversationMetaRule }, { rule: regularRule }]; + + const result = await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + conversationRules: [toReplyRule], + regularRules: [regularRule, conversationMetaRule], + matches, + }); + + expect(result).toEqual(matches); + }); + + it("adds conversation meta rule when previous conversation rule was applied and meta rule not in matches", async () => { + prisma.executedRule.findFirst.mockResolvedValue({ + id: "executed-rule-1", + } as any); + + const matches = [{ rule: regularRule }]; + + const result = await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + conversationRules: [toReplyRule], + regularRules: [regularRule, conversationMetaRule], + matches, + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ rule: regularRule }); + expect(result[1]).toEqual({ + rule: conversationMetaRule, + matchReasons: [{ type: ConditionType.STATIC }], + }); + }); + + it("returns original matches when conversation meta rule cannot be found in regularRules", async () => { + prisma.executedRule.findFirst.mockResolvedValue({ + id: "executed-rule-1", + } as any); + + const matches = [{ rule: regularRule }]; + + const result = await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + conversationRules: [toReplyRule], + regularRules: [regularRule], // No meta rule + matches, + }); + + expect(result).toEqual(matches); + }); + + it("does not mutate the original matches array", async () => { + prisma.executedRule.findFirst.mockResolvedValue({ + id: "executed-rule-1", + } as any); + + const matches = [{ rule: regularRule }]; + const originalMatches = [...matches]; + + const result = await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + conversationRules: [toReplyRule], + regularRules: [regularRule, conversationMetaRule], + matches, + }); + + expect(matches).toEqual(originalMatches); + expect(result).not.toBe(matches); + }); + + it("queries database with correct parameters", async () => { + prisma.executedRule.findFirst.mockResolvedValue(null); + + const matches = [{ rule: regularRule }]; + + await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + conversationRules: [toReplyRule], + regularRules: [regularRule, conversationMetaRule], + matches, + }); + + expect(prisma.executedRule.findFirst).toHaveBeenCalledWith({ + where: { + emailAccountId, + threadId, + status: ExecutedRuleStatus.APPLIED, + rule: { + systemType: { + in: expect.any(Array), + }, + }, + }, + select: { id: true }, + }); + }); +}); diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index 2be3c3f8ae..bc06c54a35 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -1,21 +1,12 @@ import { after } from "next/server"; -import type { - ParsedMessage, - RuleWithActionsAndCategories, -} from "@/utils/types"; +import type { ParsedMessage, RuleWithActions } from "@/utils/types"; import type { EmailAccountWithAI } from "@/utils/llms/types"; -import { - ExecutedRuleStatus, - SystemType, - type Prisma, - type Rule, -} from "@prisma/client"; +import { ExecutedRuleStatus, SystemType, type Rule } from "@prisma/client"; import type { ActionItem } from "@/utils/ai/types"; -import { findMatchingRule } from "@/utils/ai/choose-rule/match-rules"; +import { findMatchingRules } from "@/utils/ai/choose-rule/match-rules"; import { getActionItemsWithAiArgs } from "@/utils/ai/choose-rule/choose-args"; import { executeAct } from "@/utils/ai/choose-rule/execute"; import prisma from "@/utils/prisma"; -import { isDuplicateError } from "@/utils/prisma-helpers"; import { createScopedLogger } from "@/utils/logger"; import type { MatchReason } from "@/utils/ai/choose-rule/types"; import { sanitizeActionFields } from "@/utils/action-item"; @@ -29,13 +20,17 @@ import { import groupBy from "lodash/groupBy"; import type { EmailProvider } from "@/utils/email/types"; import type { ModelType } from "@/utils/llms/model"; -import { isConversationStatusType } from "@/utils/reply-tracker/conversation-status-config"; +import { + CONVERSATION_STATUS_TYPES, + isConversationStatusType, +} from "@/utils/reply-tracker/conversation-status-config"; import { determineConversationStatus, updateThreadTrackers, } from "@/utils/reply-tracker/handle-conversation-status"; import { saveColdEmail } from "@/utils/cold-email/is-cold-email"; import { internalDateToDate } from "@/utils/date"; +import { ConditionType } from "@/utils/config"; const logger = createScopedLogger("ai-run-rules"); @@ -45,9 +40,10 @@ export type RunRulesResult = { reason?: string | null; matchReasons?: MatchReason[]; existing?: boolean; + createdAt: Date; }; -const CONVERSATION_TRACKING_META_RULE_ID = "conversation-tracking-meta"; +export const CONVERSATION_TRACKING_META_RULE_ID = "conversation-tracking-meta"; export async function runRules({ provider, @@ -59,14 +55,15 @@ export async function runRules({ }: { provider: EmailProvider; message: ParsedMessage; - rules: RuleWithActionsAndCategories[]; + rules: RuleWithActions[]; emailAccount: EmailAccountWithAI; isTest: boolean; modelType: ModelType; -}): Promise { +}): Promise { + const batchTimestamp = new Date(); // Single timestamp for this batch execution const { regularRules, conversationRules } = prepareRulesWithMetaRule(rules); - const result = await findMatchingRule({ + const results = await findMatchingRules({ rules: regularRules, message, emailAccount, @@ -74,16 +71,44 @@ export async function runRules({ modelType, }); + // Auto-reapply conversation tracking for thread continuity + const finalMatches = await ensureConversationRuleContinuity({ + emailAccountId: emailAccount.id, + threadId: message.threadId, + conversationRules, + regularRules, + matches: results.matches, + }); + logger.trace("Matching rule", () => ({ - result: filterNullProperties(result), + results: finalMatches.map(filterNullProperties), })); - if (result.rule) { + if (!finalMatches.length) { + const reason = results.reasoning || "No rules matched"; + if (!isTest) { + await prisma.executedRule.create({ + data: { + threadId: message.threadId, + messageId: message.id, + automated: true, + reason, + status: ExecutedRuleStatus.SKIPPED, + emailAccount: { connect: { id: emailAccount.id } }, + }, + }); + } + + return [{ rule: null, reason, createdAt: batchTimestamp }]; + } + + const executedRules: RunRulesResult[] = []; + + for (const result of finalMatches) { let ruleToExecute = result.rule; - let reasonToUse = result.reason; + let reasonToUse = results.reasoning; - // Check if this is the conversation tracking meta-rule - if (result.rule.id === CONVERSATION_TRACKING_META_RULE_ID) { + if (result.rule && isConversationRule(result.rule.id)) { // Determine which specific sub-rule applies const { specificRule, reason: statusReason } = await determineConversationStatus({ @@ -95,10 +120,14 @@ export async function runRules({ }); if (!specificRule) { - return { + const executedRule: RunRulesResult = { rule: null, reason: statusReason || "No enabled conversation status rule found", + createdAt: batchTimestamp, }; + + executedRules.push(executedRule); + continue; } ruleToExecute = specificRule; @@ -112,7 +141,7 @@ export async function runRules({ }); } - return await executeMatchedRule( + const executedRule = await executeMatchedRule( ruleToExecute, message, emailAccount, @@ -121,21 +150,18 @@ export async function runRules({ result.matchReasons, isTest, modelType, + batchTimestamp, ); - } else { - await saveSkippedExecutedRule({ - emailAccountId: emailAccount.id, - threadId: message.threadId, - messageId: message.id, - reason: result.reason, - }); + + executedRules.push(executedRule); } - return result; + + return executedRules; } -function prepareRulesWithMetaRule(rules: RuleWithActionsAndCategories[]): { - regularRules: RuleWithActionsAndCategories[]; - conversationRules: RuleWithActionsAndCategories[]; +function prepareRulesWithMetaRule(rules: RuleWithActions[]): { + regularRules: RuleWithActions[]; + conversationRules: RuleWithActions[]; } { // Separate conversation status rules from regular rules const conversationRules = rules.filter((r) => @@ -153,12 +179,11 @@ function prepareRulesWithMetaRule(rules: RuleWithActionsAndCategories[]): { id: CONVERSATION_TRACKING_META_RULE_ID, name: "Conversation Tracking", instructions: - "Personal conversations with real people. Excludes automated notifications, newsletters, marketing, receipts, and calendar invites. Apply when the email is part of an actual conversation.", + "Personal conversations and communication with real people (emails requiring response, FYI updates, discussions, etc). This is the PRIMARY rule for human-to-human email communication.", enabled: true, runOnThreads: true, systemType: null, actions: [], - categoryFilters: [], }; regularRules.push(metaRule); @@ -168,7 +193,7 @@ function prepareRulesWithMetaRule(rules: RuleWithActionsAndCategories[]): { } async function executeMatchedRule( - rule: RuleWithActionsAndCategories, + rule: RuleWithActions, message: ParsedMessage, emailAccount: EmailAccountWithAI, client: EmailProvider, @@ -176,8 +201,8 @@ async function executeMatchedRule( matchReasons: MatchReason[] | undefined, isTest: boolean, modelType: ModelType, + batchTimestamp: Date, ) { - // get action items with args const actionItems = await getActionItemsWithAiArgs({ message, emailAccount, @@ -192,226 +217,116 @@ async function executeMatchedRule( : "immediateActions", ); - // handle action + if (isTest) { + return { + rule, + actionItems, + executedRule: null, + reason, + matchReasons, + createdAt: batchTimestamp, + }; + } - let executedRule: Awaited> | null = null; + const executedRule = await prisma.executedRule.create({ + data: { + actionItems: { + createMany: { + data: + // Only save immediate actions as ExecutedActions + immediateActions?.map((item) => { + const { + delayInMinutes: _delayInMinutes, + ...executedActionFields + } = sanitizeActionFields(item); + return executedActionFields; + }) || [], + }, + }, + messageId: message.id, + threadId: message.threadId, + automated: true, + status: ExecutedRuleStatus.APPLYING, // Changed from PENDING - rules are now always automated + reason, + rule: rule?.id ? { connect: { id: rule.id } } : undefined, + emailAccount: { connect: { id: emailAccount.id } }, + createdAt: batchTimestamp, // Use batch timestamp for grouping + }, + include: { actionItems: true }, + }); - if (!isTest) { - executedRule = await saveExecutedRule( - { - emailAccountId: emailAccount.id, + if (rule.systemType === SystemType.COLD_EMAIL) { + await saveColdEmail({ + email: { + id: message.id, threadId: message.threadId, - messageId: message.id, + from: message.headers.from, }, - { - rule, - actionItems: immediateActions, // Only save immediate actions as ExecutedActions - reason, - }, - ); + emailAccount, + aiReason: reason ?? null, + }); + } - if (rule.systemType === SystemType.COLD_EMAIL) { - await saveColdEmail({ - email: { - id: message.id, - threadId: message.threadId, - from: message.headers.from, - }, - emailAccount, - aiReason: reason ?? null, - }); - } + if (isConversationStatusType(rule.systemType)) { + await updateThreadTrackers({ + emailAccountId: emailAccount.id, + threadId: message.threadId, + messageId: message.id, + sentAt: internalDateToDate(message.internalDate), + status: rule.systemType, + }); + } - if (isConversationStatusType(rule.systemType)) { - await updateThreadTrackers({ + if (executedRule) { + if (delayedActions?.length > 0) { + // Cancels existing scheduled actions to avoid duplicates + await cancelScheduledActions({ emailAccountId: emailAccount.id, + messageId: message.id, threadId: message.threadId, + ruleId: rule.id, + reason: "Superseded by new rule execution", + }); + await scheduleDelayedActions({ + executedRuleId: executedRule.id, + actionItems: delayedActions, messageId: message.id, - sentAt: internalDateToDate(message.internalDate), - status: rule.systemType, + threadId: message.threadId, + emailAccountId: emailAccount.id, }); } - if (executedRule) { - if (delayedActions?.length > 0) { - // Attempts to cancel any existing scheduled actions to avoid duplicates - await cancelScheduledActions({ - emailAccountId: emailAccount.id, - messageId: message.id, - threadId: message.threadId, - reason: "Superseded by new rule execution", - }); - await scheduleDelayedActions({ - executedRuleId: executedRule.id, - actionItems: delayedActions, - messageId: message.id, - threadId: message.threadId, - emailAccountId: emailAccount.id, - }); - } - - // Execute immediate actions if any - if (immediateActions?.length > 0) { - await executeAct({ - client, - userEmail: emailAccount.email, - userId: emailAccount.userId, - emailAccountId: emailAccount.id, - executedRule, - message, - }); - } else if (!delayedActions?.length) { - // No actions at all (neither immediate nor delayed), mark as applied - await prisma.executedRule.update({ - where: { id: executedRule.id }, - data: { status: ExecutedRuleStatus.APPLIED }, - }); - } + // Execute immediate actions if any + if (immediateActions?.length > 0) { + await executeAct({ + client, + userEmail: emailAccount.email, + userId: emailAccount.userId, + emailAccountId: emailAccount.id, + executedRule, + message, + }); + } else if (!delayedActions?.length) { + // No actions at all (neither immediate nor delayed), mark as applied + await prisma.executedRule.update({ + where: { id: executedRule.id }, + data: { status: ExecutedRuleStatus.APPLIED }, + }); } } // Note: If there are ONLY delayed actions (no immediate), status stays APPLYING // and will be updated to APPLIED by checkAndCompleteExecutedRule() when scheduled actions finish - return { rule, actionItems, executedRule, reason, matchReasons, + createdAt: batchTimestamp, }; } -async function saveSkippedExecutedRule({ - emailAccountId, - threadId, - messageId, - reason, -}: { - emailAccountId: string; - threadId: string; - messageId: string; - reason?: string; -}) { - const data: Prisma.ExecutedRuleCreateInput = { - threadId, - messageId, - automated: true, - reason, - status: ExecutedRuleStatus.SKIPPED, - emailAccount: { connect: { id: emailAccountId } }, - }; - - await upsertExecutedRule({ - emailAccountId, - threadId, - messageId, - data, - }); -} - -async function saveExecutedRule( - { - emailAccountId, - threadId, - messageId, - }: { - emailAccountId: string; - threadId: string; - messageId: string; - }, - { - rule, - actionItems, - reason, - }: { - rule: RuleWithActionsAndCategories; - actionItems: ActionItem[]; - reason?: string; - }, -) { - const data: Prisma.ExecutedRuleCreateInput = { - actionItems: { - createMany: { - data: - actionItems?.map((item) => { - const { delayInMinutes: _delayInMinutes, ...executedActionFields } = - sanitizeActionFields(item); - return executedActionFields; - }) || [], - }, - }, - messageId, - threadId, - automated: true, - status: ExecutedRuleStatus.APPLYING, // Changed from PENDING - rules are now always automated - reason, - rule: rule?.id ? { connect: { id: rule.id } } : undefined, - emailAccount: { connect: { id: emailAccountId } }, - }; - - return await upsertExecutedRule({ - emailAccountId, - threadId, - messageId, - data, - }); -} - -async function upsertExecutedRule({ - emailAccountId, - threadId, - messageId, - data, -}: { - emailAccountId: string; - threadId: string; - messageId: string; - data: Prisma.ExecutedRuleCreateInput; -}) { - try { - return await prisma.executedRule.upsert({ - where: { - unique_emailAccount_thread_message: { - emailAccountId, - threadId, - messageId, - }, - }, - create: data, - update: data.rule - ? data - : { - ...data, - rule: { disconnect: true }, - }, - include: { actionItems: true }, - }); - } catch (error) { - if (isDuplicateError(error)) { - // Unique constraint violation, ignore the error - // May be due to a race condition? - logger.info("Ignored duplicate entry for ExecutedRule", { - emailAccountId, - threadId, - messageId, - }); - return await prisma.executedRule.findUnique({ - where: { - unique_emailAccount_thread_message: { - emailAccountId, - threadId, - messageId, - }, - }, - include: { actionItems: true }, - }); - } - // Re-throw any other errors - throw error; - } -} - async function analyzeSenderPatternIfAiMatch({ isTest, result, @@ -445,18 +360,118 @@ function shouldAnalyzeSenderPattern({ }) { if (isTest) return false; if (!result.rule) return false; - if (result.rule.systemType === SystemType.TO_REPLY) return false; + if (isConversationStatusType(result.rule.systemType)) return false; // skip if we already matched for static reasons // learnings only needed for rules that would run through an ai if ( result.matchReasons?.some( (reason) => - reason.type === "STATIC" || - reason.type === "GROUP" || - reason.type === "CATEGORY", + reason.type === ConditionType.STATIC || + reason.type === ConditionType.LEARNED_PATTERN, ) - ) + ) { return false; + } + return true; } + +/** + * Checks if a conversation status rule was previously applied to any email in this thread. + */ +async function checkPreviousConversationRuleInThread({ + emailAccountId, + threadId, +}: { + emailAccountId: string; + threadId: string; +}): Promise { + const previousConversationRule = await prisma.executedRule.findFirst({ + where: { + emailAccountId, + threadId, + status: ExecutedRuleStatus.APPLIED, + rule: { systemType: { in: CONVERSATION_STATUS_TYPES } }, + }, + select: { id: true }, + }); + + return !!previousConversationRule; +} + +/** + * Ensures conversation tracking continues throughout a thread. + * If a conversation meta rule was previously applied to any email in this thread, + * we automatically add it to matches even if the AI didn't select it. + * This ensures conversation tracking continues consistently throughout the thread. + * + * Note: The meta rule is still passed to the AI (in regularRules), which may + * influence it to select the rule naturally, but we enforce it regardless. + * + * Returns a new array of matches (does not mutate the input). + * + * @internal Exported for testing + */ +export async function ensureConversationRuleContinuity({ + emailAccountId, + threadId, + conversationRules, + regularRules, + matches, +}: { + emailAccountId: string; + threadId: string; + conversationRules: RuleWithActions[]; + regularRules: RuleWithActions[]; + matches: { rule: RuleWithActions; matchReasons?: MatchReason[] }[]; +}): Promise<{ rule: RuleWithActions; matchReasons?: MatchReason[] }[]> { + if (conversationRules.length === 0) { + return matches; + } + + const hadConversationRuleInThread = + await checkPreviousConversationRuleInThread({ + emailAccountId, + threadId, + }); + + if (!hadConversationRuleInThread) { + return matches; + } + + const hasConversationMetaRuleInMatches = matches.some((match) => + isConversationRule(match.rule.id), + ); + + if (hasConversationMetaRuleInMatches) { + return matches; + } + + logger.info( + "Automatically adding conversation meta rule due to previous application in thread", + ); + + // Find the meta rule in regularRules + const metaRule = regularRules.find((r) => isConversationRule(r.id)); + + if (!metaRule) { + return matches; + } + + return [ + ...matches, + { + rule: metaRule, + matchReasons: [ + { + type: ConditionType.STATIC, + }, + ], + }, + ]; +} + +function isConversationRule(ruleId: string): boolean { + return ruleId === CONVERSATION_TRACKING_META_RULE_ID; +} diff --git a/apps/web/utils/ai/choose-rule/types.ts b/apps/web/utils/ai/choose-rule/types.ts index 571d2796e2..4335abfc25 100644 --- a/apps/web/utils/ai/choose-rule/types.ts +++ b/apps/web/utils/ai/choose-rule/types.ts @@ -1,22 +1,17 @@ -import type { Category, Group, GroupItem, SystemType } from "@prisma/client"; +import type { Group, GroupItem, SystemType } from "@prisma/client"; import type { ConditionType } from "@/utils/config"; -import type { RuleWithActionsAndCategories } from "@/utils/types"; +import type { RuleWithActions } from "@/utils/types"; export type StaticMatch = { type: Extract; }; -export type GroupMatch = { - type: Extract; +export type LearnedPatternMatch = { + type: Extract; group: Pick; groupItem: Pick; }; -export type CategoryMatch = { - type: Extract; - category: Pick; -}; - export type AiMatch = { type: Extract; }; @@ -28,15 +23,16 @@ export type PresetMatch = { export type MatchReason = | StaticMatch - | GroupMatch - | CategoryMatch + | LearnedPatternMatch | AiMatch | PresetMatch; export type MatchingRuleResult = { - match?: RuleWithActionsAndCategories; - matchReasons?: MatchReason[]; - potentialMatches?: (RuleWithActionsAndCategories & { + matches: { + rule: RuleWithActions; + matchReasons: MatchReason[]; + }[]; + potentialAiMatches: (RuleWithActions & { instructions: string; })[]; }; diff --git a/apps/web/utils/ai/rule/create-rule-schema.ts b/apps/web/utils/ai/rule/create-rule-schema.ts index 1d1b1f2490..ab3bba5889 100644 --- a/apps/web/utils/ai/rule/create-rule-schema.ts +++ b/apps/web/utils/ai/rule/create-rule-schema.ts @@ -1,9 +1,5 @@ import { z } from "zod"; -import { - ActionType, - CategoryFilterType, - LogicalOperator, -} from "@prisma/client"; +import { ActionType, LogicalOperator } from "@prisma/client"; import { delayInMinutesSchema } from "@/utils/actions/rule.validation"; import { isMicrosoftProvider } from "@/utils/email/provider-types"; import { isDefined } from "@/utils/types"; @@ -122,45 +118,7 @@ export const createRuleSchema = (provider: string) => actions: z.array(actionSchema(provider)).describe("The actions to take"), }); -export const getCreateRuleSchemaWithCategories = ( - availableCategories: [string, ...string[]], - provider: string, -) => { - return createRuleSchema(provider).extend({ - condition: conditionSchema.extend({ - categories: z - .object({ - categoryFilterType: z - .enum([CategoryFilterType.INCLUDE, CategoryFilterType.EXCLUDE]) - .nullish() - .describe( - "Whether senders in `categoryFilters` should be included or excluded", - ), - categoryFilters: z - .array(z.string()) - .nullish() - .describe( - `The categories to match. If multiple categories are specified, the rule will match if ANY of the categories match (OR operation). Available categories: ${availableCategories - .map((c) => `"${c}"`) - .join(", ")}`, - ), - }) - .nullish() - .describe("The categories to match or skip"), - }), - }); -}; - export type CreateRuleSchema = z.infer>; -export type CreateRuleSchemaWithCategories = CreateRuleSchema & { - condition: CreateRuleSchema["condition"] & { - categories?: { - categoryFilterType: CategoryFilterType; - categoryFilters: string[]; - }; - }; +export type CreateOrUpdateRuleSchema = CreateRuleSchema & { + ruleId?: string; }; -export type CreateOrUpdateRuleSchemaWithCategories = - CreateRuleSchemaWithCategories & { - ruleId?: string; - }; diff --git a/apps/web/utils/ai/rule/prompt-to-rules-old.ts b/apps/web/utils/ai/rule/prompt-to-rules-old.ts index 5420ff95fc..80b8acf1bf 100644 --- a/apps/web/utils/ai/rule/prompt-to-rules-old.ts +++ b/apps/web/utils/ai/rule/prompt-to-rules-old.ts @@ -2,9 +2,8 @@ import { z } from "zod"; import { createGenerateObject } from "@/utils/llms"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import { - type CreateOrUpdateRuleSchemaWithCategories, + type CreateOrUpdateRuleSchema, createRuleSchema, - getCreateRuleSchemaWithCategories, } from "@/utils/ai/rule/create-rule-schema"; import { createScopedLogger } from "@/utils/logger"; import { convertMentionsToLabels } from "@/utils/mention"; @@ -21,36 +20,18 @@ export async function aiPromptToRulesOld({ emailAccount, promptFile, isEditing, - availableCategories, }: { emailAccount: EmailAccountWithAI; promptFile: string; isEditing: boolean; - availableCategories?: string[]; -}): Promise { +}): Promise { function getSchema() { - if (availableCategories?.length) { - const createRuleSchemaWithCategories = getCreateRuleSchemaWithCategories( - availableCategories as [string, ...string[]], - emailAccount.account.provider, - ); - const updateRuleSchemaWithCategories = - createRuleSchemaWithCategories.extend({ - ruleId: z.string().optional(), - }); - - return isEditing - ? updateRuleSchemaWithCategories - : createRuleSchemaWithCategories; - } return isEditing ? updateRuleSchema(emailAccount.account.provider) : createRuleSchema(emailAccount.account.provider); } - const system = getSystemPrompt({ - hasSmartCategories: !!availableCategories?.length, - }); + const system = getSystemPrompt(); const cleanedPromptFile = convertMentionsToLabels(promptFile); @@ -85,11 +66,7 @@ ${cleanedPromptFile} return rules; } -function getSystemPrompt({ - hasSmartCategories, -}: { - hasSmartCategories: boolean; -}) { +function getSystemPrompt() { return `You are an AI assistant that converts email management rules into a structured format. Parse the given prompt file and conver them into rules. IMPORTANT: If a user provides a snippet, use that full snippet in the rule. Don't include placeholders unless it's clear one is needed. @@ -108,16 +85,6 @@ If a rule can be handled fully with static conditions, do so, but this is rarely "name": "Label Newsletters", "condition": { "aiInstructions": "Apply this rule to newsletters" - ${ - hasSmartCategories - ? `, - "categories": { - "categoryFilterType": "INCLUDE", - "categoryFilters": ["Newsletters"] - }, - "conditionalOperator": "OR"` - : "" - } }, "actions": [ { diff --git a/apps/web/utils/ai/rule/prompt-to-rules.ts b/apps/web/utils/ai/rule/prompt-to-rules.ts index d438fa8711..c29d3ccdd1 100644 --- a/apps/web/utils/ai/rule/prompt-to-rules.ts +++ b/apps/web/utils/ai/rule/prompt-to-rules.ts @@ -2,9 +2,8 @@ import { z } from "zod"; import { createGenerateObject } from "@/utils/llms"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import { + type CreateRuleSchema, createRuleSchema, - type CreateRuleSchemaWithCategories, - getCreateRuleSchemaWithCategories, } from "@/utils/ai/rule/create-rule-schema"; import { createScopedLogger } from "@/utils/logger"; import { convertMentionsToLabels } from "@/utils/mention"; @@ -15,26 +14,11 @@ const logger = createScopedLogger("ai-prompt-to-rules"); export async function aiPromptToRules({ emailAccount, promptFile, - availableCategories, }: { emailAccount: EmailAccountWithAI; promptFile: string; - availableCategories?: string[]; -}): Promise { - function getSchema() { - if (availableCategories?.length) { - return getCreateRuleSchemaWithCategories( - availableCategories as [string, ...string[]], - emailAccount.account.provider, - ); - } - - return createRuleSchema(emailAccount.account.provider); - } - - const system = getSystemPrompt({ - hasSmartCategories: !!availableCategories?.length, - }); +}): Promise { + const system = getSystemPrompt(); const cleanedPromptFile = convertMentionsToLabels(promptFile); @@ -56,7 +40,9 @@ ${cleanedPromptFile} ...modelOptions, prompt, system, - schema: z.object({ rules: z.array(getSchema()) }), + schema: z.object({ + rules: z.array(createRuleSchema(emailAccount.account.provider)), + }), }); if (!aiResponse.object) { @@ -69,11 +55,7 @@ ${cleanedPromptFile} return rules; } -function getSystemPrompt({ - hasSmartCategories, -}: { - hasSmartCategories: boolean; -}) { +function getSystemPrompt() { return `You are an AI assistant that converts email management rules into a structured format. Parse the given prompt and convert it into rules. IMPORTANT: If a user provides a snippet, use that full snippet in the rule. Don't include placeholders unless it's clear one is needed. @@ -95,16 +77,6 @@ IMPORTANT: You must return a JSON object. "name": "Label Newsletters", "condition": { "aiInstructions": "Apply this rule to newsletters" - ${ - hasSmartCategories - ? `, - "categories": { - "categoryFilterType": "INCLUDE", - "categoryFilters": ["Newsletters"] - }, - "conditionalOperator": "OR"` - : "" - } }, "actions": [ { diff --git a/apps/web/utils/assistant/process-assistant-email.ts b/apps/web/utils/assistant/process-assistant-email.ts index f6ae8ccf37..b91613e54e 100644 --- a/apps/web/utils/assistant/process-assistant-email.ts +++ b/apps/web/utils/assistant/process-assistant-email.ts @@ -96,7 +96,7 @@ async function processAssistantEmailInternal({ const originalMessageId = firstMessageToAssistant.headers["in-reply-to"]; const originalMessage = await provider.getOriginalMessage(originalMessageId); - const [emailAccount, executedRule, senderCategory] = await Promise.all([ + const [emailAccount, executedRules] = await Promise.all([ prisma.emailAccount.findUnique({ where: { email: userEmail }, select: { @@ -114,7 +114,6 @@ async function processAssistantEmailInternal({ rules: { include: { actions: true, - categoryFilters: true, group: { select: { id: true, @@ -130,43 +129,27 @@ async function processAssistantEmailInternal({ }, }, }, - categories: true, account: { select: { provider: true } }, }, }), originalMessage - ? prisma.executedRule.findUnique({ + ? prisma.executedRule.findMany({ where: { - unique_emailAccount_thread_message: { - emailAccountId, - threadId: originalMessage.threadId, - messageId: originalMessage.id, - }, + emailAccountId, + threadId: originalMessage.threadId, + messageId: originalMessage.id, }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], select: { rule: { include: { actions: true, - categoryFilters: true, group: true, }, }, }, }) : null, - originalMessage - ? prisma.newsletter.findUnique({ - where: { - email_emailAccountId: { - email: extractEmailAddress(originalMessage.headers.from), - emailAccountId, - }, - }, - select: { - category: { select: { name: true } }, - }, - }) - : null, ]); if (!emailAccount) { @@ -217,9 +200,7 @@ async function processAssistantEmailInternal({ rules: emailAccount.rules, originalEmail: originalMessage, messages, - matchedRule: executedRule?.rule || null, - categories: emailAccount.categories.length ? emailAccount.categories : null, - senderCategory: senderCategory?.category?.name ?? null, + matchedRule: executedRules?.length ? executedRules[0].rule : null, // TODO: support multiple rule matching }); const toolCalls = result.steps.flatMap((step) => step.toolCalls); diff --git a/apps/web/utils/category.server.ts b/apps/web/utils/category.server.ts index 19359bd8bd..c77e45d0cf 100644 --- a/apps/web/utils/category.server.ts +++ b/apps/web/utils/category.server.ts @@ -1,8 +1,5 @@ import prisma from "@/utils/prisma"; import type { Prisma } from "@prisma/client"; -import { createScopedLogger } from "@/utils/logger"; - -const logger = createScopedLogger("category"); export type CategoryWithRules = Prisma.CategoryGetPayload<{ select: { @@ -40,26 +37,3 @@ export const getUserCategoriesWithRules = async ({ }); return categories; }; - -export const getUserCategoriesForNames = async ({ - emailAccountId, - names, -}: { - emailAccountId: string; - names: string[]; -}) => { - if (!names.length) return []; - - const categories = await prisma.category.findMany({ - where: { emailAccountId, name: { in: names } }, - select: { id: true }, - }); - if (categories.length !== names.length) { - logger.warn("Not all categories were found", { - requested: names.length, - found: categories.length, - names, - }); - } - return categories.map((c) => c.id); -}; diff --git a/apps/web/utils/cold-email/is-cold-email.ts b/apps/web/utils/cold-email/is-cold-email.ts index 45f35df6cd..89aa72e9d2 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -10,6 +10,7 @@ import type { EmailForLLM } from "@/utils/types"; import type { EmailProvider } from "@/utils/email/types"; import { getModel, type ModelType } from "@/utils/llms/model"; import { createGenerateObject } from "@/utils/llms"; +import { extractEmailAddress } from "@/utils/email"; export const COLD_EMAIL_FOLDER_NAME = "Cold Emails"; @@ -167,17 +168,19 @@ export async function saveColdEmail({ emailAccount: EmailAccountWithAI; aiReason: string | null; }): Promise { + const from = extractEmailAddress(email.from) || email.from; + return await prisma.coldEmail.upsert({ where: { emailAccountId_fromEmail: { emailAccountId: emailAccount.id, - fromEmail: email.from, + fromEmail: from, }, }, update: { status: ColdEmailStatus.AI_LABELED_COLD }, create: { status: ColdEmailStatus.AI_LABELED_COLD, - fromEmail: email.from, + fromEmail: from, emailAccountId: emailAccount.id, reason: aiReason, messageId: email.id, diff --git a/apps/web/utils/condition.ts b/apps/web/utils/condition.ts index 85eb88125a..b8b9401e57 100644 --- a/apps/web/utils/condition.ts +++ b/apps/web/utils/condition.ts @@ -1,9 +1,4 @@ -import { - CategoryFilterType, - LogicalOperator, - type Category, - type Rule, -} from "@prisma/client"; +import { LogicalOperator, type Rule } from "@prisma/client"; import { ConditionType, type CoreConditionType } from "@/utils/config"; import type { CreateRuleBody, @@ -22,11 +17,9 @@ export type RuleConditions = Partial< | "to" | "subject" | "body" - | "categoryFilterType" | "conditionalOperator" > & { group?: { name: string } | null; - categoryFilters?: Pick[]; } >; @@ -46,10 +39,6 @@ export function isStaticRule(rule: RuleConditions) { return !!rule.from || !!rule.to || !!rule.subject || !!rule.body; } -export function isCategoryRule(rule: RuleConditions) { - return !!(rule.categoryFilters?.length && rule.categoryFilterType); -} - export function getConditions(rule: RuleConditions) { const conditions: CreateRuleBody["conditions"] = []; @@ -70,33 +59,22 @@ export function getConditions(rule: RuleConditions) { }); } - if (isCategoryRule(rule)) { - conditions.push({ - type: ConditionType.CATEGORY, - categoryFilterType: rule.categoryFilterType, - categoryFilters: rule.categoryFilters?.map((category) => category.id), - }); - } - return conditions; } export function getConditionTypes( rule: RuleConditions, -): Record { +): Record { return getConditions(rule).reduce( (acc, condition) => { acc[condition.type] = true; return acc; }, - {} as Record, + {} as Record, ); } -export function getEmptyCondition( - type: CoreConditionType, - category?: string, -): ZodCondition { +export function getEmptyCondition(type: CoreConditionType): ZodCondition { switch (type) { case ConditionType.AI: return { @@ -111,12 +89,6 @@ export function getEmptyCondition( subject: null, body: null, }; - case ConditionType.CATEGORY: - return { - type: ConditionType.CATEGORY, - categoryFilterType: CategoryFilterType.INCLUDE, - categoryFilters: category ? [category] : null, - }; default: // biome-ignore lint/correctness/noSwitchDeclarations: intentional exhaustive check const exhaustiveCheck: never = type; @@ -130,8 +102,6 @@ type FlattenedConditions = { to?: string | null; subject?: string | null; body?: string | null; - categoryFilterType?: CategoryFilterType | null; - categoryFilters?: string[] | null; }; export const flattenConditions = ( @@ -148,10 +118,6 @@ export const flattenConditions = ( acc.subject = condition.subject; acc.body = condition.body; break; - case ConditionType.CATEGORY: - acc.categoryFilterType = condition.categoryFilterType; - acc.categoryFilters = condition.categoryFilters; - break; default: logger.warn("Unknown condition type", { condition }); // biome-ignore lint/correctness/noSwitchDeclarations: intentional exhaustive check @@ -178,10 +144,8 @@ function conditionTypeToString(conditionType: ConditionType): string { return "AI"; case ConditionType.STATIC: return "Static"; - case ConditionType.GROUP: + case ConditionType.LEARNED_PATTERN: return "Group"; - case ConditionType.CATEGORY: - return "Category"; case ConditionType.PRESET: return "Preset"; default: @@ -207,36 +171,5 @@ export function conditionsToString(rule: RuleConditions) { // AI condition if (rule.instructions) conditions.push(rule.instructions); - // Category condition - const categoryFilters = rule.categoryFilters; - if (rule.categoryFilterType && categoryFilters?.length) { - const max = 3; - const categories = - categoryFilters - .slice(0, max) - .map((category) => category.name) - .join(", ") + (categoryFilters.length > max ? ", ..." : ""); - conditions.push( - `${rule.categoryFilterType === CategoryFilterType.EXCLUDE ? "Exclude " : ""}${ - categoryFilters.length === 1 ? "Category" : "Categories" - }: ${categories}`, - ); - } - return conditions.join(connector); } - -export function categoryFilterTypeToString( - categoryFilterType: CategoryFilterType, -): string { - switch (categoryFilterType) { - case CategoryFilterType.INCLUDE: - return "Include"; - case CategoryFilterType.EXCLUDE: - return "Exclude"; - default: - // biome-ignore lint/correctness/noSwitchDeclarations: intentional exhaustive check - const exhaustiveCheck: never = categoryFilterType; - return exhaustiveCheck; - } -} diff --git a/apps/web/utils/config.ts b/apps/web/utils/config.ts index 20e16a567f..3afc7a1236 100644 --- a/apps/web/utils/config.ts +++ b/apps/web/utils/config.ts @@ -12,13 +12,12 @@ export const KNOWLEDGE_BASIC_MAX_CHARS = 2000; export const ConditionType = { AI: "AI", STATIC: "STATIC", - GROUP: "GROUP", - CATEGORY: "CATEGORY", + LEARNED_PATTERN: "LEARNED_PATTERN", PRESET: "PRESET", } as const; export type ConditionType = (typeof ConditionType)[keyof typeof ConditionType]; -export type CoreConditionType = Exclude; +export type CoreConditionType = Extract; export const WELCOME_PATH = "/welcome-redirect"; diff --git a/apps/web/utils/reply-tracker/handle-conversation-status.ts b/apps/web/utils/reply-tracker/handle-conversation-status.ts index 2644a75476..36ca905e3d 100644 --- a/apps/web/utils/reply-tracker/handle-conversation-status.ts +++ b/apps/web/utils/reply-tracker/handle-conversation-status.ts @@ -1,9 +1,6 @@ import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { ModelType } from "@/utils/llms/model"; -import type { - ParsedMessage, - RuleWithActionsAndCategories, -} from "@/utils/types"; +import type { ParsedMessage, RuleWithActions } from "@/utils/types"; import type { EmailProvider } from "@/utils/email/types"; import { aiDetermineThreadStatus } from "@/utils/ai/reply/determine-thread-status"; import { getEmailForLLM } from "@/utils/get-email-from-message"; @@ -26,13 +23,13 @@ export async function determineConversationStatus({ provider, modelType, }: { - conversationRules: RuleWithActionsAndCategories[]; + conversationRules: RuleWithActions[]; message: ParsedMessage; emailAccount: EmailAccountWithAI; provider: EmailProvider; modelType: ModelType; }): Promise<{ - specificRule: RuleWithActionsAndCategories | null; + specificRule: RuleWithActions | null; reason: string; }> { logger.info("Determining conversation status", { diff --git a/apps/web/utils/rule/rule-history.ts b/apps/web/utils/rule/rule-history.ts index b6f38aa52a..772caeac5e 100644 --- a/apps/web/utils/rule/rule-history.ts +++ b/apps/web/utils/rule/rule-history.ts @@ -41,13 +41,6 @@ export async function createRuleHistory({ url: action.url, })); - // Serialize category filters to JSON - const categoryFiltersSnapshot = rule.categoryFilters?.map((category) => ({ - id: category.id, - name: category.name, - description: category.description, - })); - return prisma.ruleHistory.create({ data: { ruleId: rule.id, @@ -61,11 +54,9 @@ export async function createRuleHistory({ to: rule.to, subject: rule.subject, body: rule.body, - categoryFilterType: rule.categoryFilterType, systemType: rule.systemType, promptText: rule.promptText, actions: actionsSnapshot, - categoryFilters: categoryFiltersSnapshot, triggerType, // Note: this is unique and can fail in race conditions. Not a big deal for now. version: nextVersion, diff --git a/apps/web/utils/rule/rule-to-text.ts b/apps/web/utils/rule/rule-to-text.ts index 407fd20b47..eca86a0e01 100644 --- a/apps/web/utils/rule/rule-to-text.ts +++ b/apps/web/utils/rule/rule-to-text.ts @@ -1,13 +1,8 @@ import type { Rule, Action } from "@prisma/client"; -import { - ActionType, - CategoryFilterType, - LogicalOperator, -} from "@prisma/client"; +import { ActionType, LogicalOperator } from "@prisma/client"; export interface RuleWithActions extends Rule { actions: Action[]; - categoryFilters?: { name: string }[]; group?: { name: string } | null; } @@ -36,19 +31,6 @@ export function ruleToText(rule: RuleWithActions): string { conditions.push(`'Body' contains "${rule.body}"`); } - // if (rule.group) { - // conditions.push(`Sender is in group "${rule.group.name}"`); - // } - - if (rule.categoryFilterType && rule.categoryFilters?.length) { - const categoryNames = rule.categoryFilters.map((c) => c.name).join(", "); - if (rule.categoryFilterType === CategoryFilterType.INCLUDE) { - conditions.push(`Sender is in categories: ${categoryNames}`); - } else { - conditions.push(`Sender is NOT in categories: ${categoryNames}`); - } - } - // Build actions rule.actions.forEach((action) => { switch (action.type) { diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index 1f27d1959a..2a715f7577 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -1,13 +1,11 @@ -import type { CreateOrUpdateRuleSchemaWithCategories } from "@/utils/ai/rule/create-rule-schema"; +import type { CreateOrUpdateRuleSchema } from "@/utils/ai/rule/create-rule-schema"; import prisma from "@/utils/prisma"; import { isDuplicateError } from "@/utils/prisma-helpers"; import { createScopedLogger } from "@/utils/logger"; import { ActionType } from "@prisma/client"; import type { Prisma, Rule, SystemType } from "@prisma/client"; -import { getUserCategoriesForNames } from "@/utils/category.server"; import { getActionRiskLevel, type RiskAction } from "@/utils/risk"; import { hasExampleParams } from "@/app/(app)/[emailAccountId]/assistant/examples"; -import { SafeError } from "@/utils/error"; import { createRuleHistory } from "@/utils/rule/rule-history"; import { isMicrosoftProvider } from "@/utils/email/provider-types"; import { createEmailProvider } from "@/utils/email/provider"; @@ -25,16 +23,13 @@ export function partialUpdateRule({ return prisma.rule.update({ where: { id: ruleId }, data, - include: { actions: true, categoryFilters: true, group: true }, + include: { actions: true, group: true }, }); } // Extended type for system rules that can include labelId and folderId -type CreateRuleWithLabelId = Omit< - CreateOrUpdateRuleSchemaWithCategories, - "actions" -> & { - actions: (CreateOrUpdateRuleSchemaWithCategories["actions"][number] & { +type CreateRuleWithLabelId = Omit & { + actions: (CreateOrUpdateRuleSchema["actions"][number] & { labelId?: string | null; folderId?: string | null; })[]; @@ -44,7 +39,6 @@ export async function safeCreateRule({ result, emailAccountId, provider, - categoryNames, systemType, triggerType = "ai_creation", shouldCreateIfDuplicate, @@ -53,22 +47,15 @@ export async function safeCreateRule({ result: CreateRuleWithLabelId; emailAccountId: string; provider: string; - categoryNames?: string[] | null; systemType?: SystemType | null; triggerType?: "ai_creation" | "manual_creation" | "system_creation"; shouldCreateIfDuplicate: boolean; // maybe this should just always be false? runOnThreads: boolean; }) { - const categoryIds = await getUserCategoriesForNames({ - emailAccountId, - names: categoryNames || [], - }); - try { const rule = await createRule({ result, emailAccountId, - categoryIds, systemType, triggerType, provider, @@ -82,7 +69,6 @@ export async function safeCreateRule({ const rule = await createRule({ result: { ...result, name: `${result.name} - ${Date.now()}` }, emailAccountId, - categoryIds, triggerType, provider, runOnThreads, @@ -97,7 +83,7 @@ export async function safeCreateRule({ name: result.name, }, }, - include: { actions: true, categoryFilters: true, group: true }, + include: { actions: true, group: true }, }); // If we're creating a system rule and the existing rule doesn't have @@ -112,7 +98,6 @@ export async function safeCreateRule({ const rule = await createRule({ result: { ...result, name: `${result.name} - ${Date.now()}` }, emailAccountId, - categoryIds, systemType, triggerType, provider, @@ -140,14 +125,12 @@ export async function safeUpdateRule({ ruleId, result, emailAccountId, - categoryIds, triggerType = "ai_update", provider, }: { ruleId: string; - result: CreateOrUpdateRuleSchemaWithCategories; + result: CreateOrUpdateRuleSchema; emailAccountId: string; - categoryIds?: string[] | null; triggerType?: "ai_update" | "manual_update" | "system_update"; provider: string; }) { @@ -156,7 +139,6 @@ export async function safeUpdateRule({ ruleId, result, emailAccountId, - categoryIds, triggerType, provider, }); @@ -167,7 +149,6 @@ export async function safeUpdateRule({ const rule = await createRule({ result: { ...result, name: `${result.name} - ${Date.now()}` }, emailAccountId, - categoryIds, triggerType: "ai_creation", // Default for safeUpdateRule fallback provider, runOnThreads: true, @@ -190,15 +171,13 @@ export async function safeUpdateRule({ export async function createRule({ result, emailAccountId, - categoryIds, systemType, triggerType = "ai_creation", provider, runOnThreads, }: { - result: CreateOrUpdateRuleSchemaWithCategories; + result: CreateOrUpdateRuleSchema; emailAccountId: string; - categoryIds?: string[] | null; systemType?: SystemType | null; triggerType?: "ai_creation" | "manual_creation" | "system_creation"; provider: string; @@ -233,16 +212,8 @@ export async function createRule({ from: result.condition.static?.from, to: result.condition.static?.to, subject: result.condition.static?.subject, - categoryFilterType: result.condition.categories?.categoryFilterType, - categoryFilters: categoryIds - ? { - connect: categoryIds.map((id) => ({ - id, - })), - } - : undefined, }, - include: { actions: true, categoryFilters: true, group: true }, + include: { actions: true, group: true }, }); // Track rule creation in history @@ -255,14 +226,12 @@ async function updateRule({ ruleId, result, emailAccountId, - categoryIds, triggerType = "ai_update", provider, }: { ruleId: string; - result: CreateOrUpdateRuleSchemaWithCategories; + result: CreateOrUpdateRuleSchema; emailAccountId: string; - categoryIds?: string[] | null; triggerType?: "ai_update" | "manual_update" | "system_update"; provider: string; }) { @@ -284,16 +253,8 @@ async function updateRule({ from: result.condition.static?.from, to: result.condition.static?.to, subject: result.condition.static?.subject, - categoryFilterType: result.condition.categories?.categoryFilterType, - categoryFilters: categoryIds - ? { - set: categoryIds.map((id) => ({ - id, - })), - } - : undefined, }, - include: { actions: true, categoryFilters: true, group: true }, + include: { actions: true, group: true }, }); // Track rule update in history @@ -309,7 +270,7 @@ export async function updateRuleActions({ emailAccountId, }: { ruleId: string; - actions: CreateOrUpdateRuleSchemaWithCategories["actions"]; + actions: CreateOrUpdateRuleSchema["actions"]; provider: string; emailAccountId: string; }) { @@ -344,10 +305,7 @@ export async function deleteRule({ ]); } -function shouldEnable( - rule: CreateOrUpdateRuleSchemaWithCategories, - actions: RiskAction[], -) { +function shouldEnable(rule: CreateOrUpdateRuleSchema, actions: RiskAction[]) { // Don't automate if it's an example rule that should have been edited by the user if ( hasExampleParams({ @@ -372,47 +330,8 @@ function shouldEnable( return riskLevels.every((level) => level === "low"); } -export async function addRuleCategories(ruleId: string, categoryIds: string[]) { - const rule = await prisma.rule.findUnique({ - where: { id: ruleId }, - include: { categoryFilters: true }, - }); - - if (!rule) throw new SafeError("Rule not found"); - - const existingIds = rule.categoryFilters.map((c) => c.id) || []; - const newIds = [...new Set([...existingIds, ...categoryIds])]; - - return prisma.rule.update({ - where: { id: ruleId }, - data: { categoryFilters: { set: newIds.map((id) => ({ id })) } }, - include: { actions: true, categoryFilters: true, group: true }, - }); -} - -export async function removeRuleCategories( - ruleId: string, - categoryIds: string[], -) { - const rule = await prisma.rule.findUnique({ - where: { id: ruleId }, - include: { categoryFilters: true }, - }); - - if (!rule) throw new SafeError("Rule not found"); - - const existingIds = rule.categoryFilters.map((c) => c.id) || []; - const newIds = existingIds.filter((id) => !categoryIds.includes(id)); - - return prisma.rule.update({ - where: { id: ruleId }, - data: { categoryFilters: { set: newIds.map((id) => ({ id })) } }, - include: { actions: true, categoryFilters: true, group: true }, - }); -} - async function mapActionFields( - actions: (CreateOrUpdateRuleSchemaWithCategories["actions"][number] & { + actions: (CreateOrUpdateRuleSchema["actions"][number] & { labelId?: string | null; folderId?: string | null; })[], diff --git a/apps/web/utils/rule/types.ts b/apps/web/utils/rule/types.ts index 79881bbad8..53be21e760 100644 --- a/apps/web/utils/rule/types.ts +++ b/apps/web/utils/rule/types.ts @@ -1,5 +1,5 @@ import type { safeCreateRule } from "@/utils/rule/rule"; -import type { Action, Rule, Category, Prisma } from "@prisma/client"; +import type { Action, Rule, Prisma } from "@prisma/client"; export type CreateRuleResult = NonNullable< Awaited> @@ -7,7 +7,6 @@ export type CreateRuleResult = NonNullable< export type RuleWithRelations = Rule & { actions: Action[]; - categoryFilters?: Category[]; group?: | (Prisma.GroupGetPayload<{ select: { id: true; name: true }; diff --git a/apps/web/utils/scheduled-actions/scheduler.test.ts b/apps/web/utils/scheduled-actions/scheduler.test.ts index 4631f372f6..a5a625fa1f 100644 --- a/apps/web/utils/scheduled-actions/scheduler.test.ts +++ b/apps/web/utils/scheduled-actions/scheduler.test.ts @@ -33,13 +33,12 @@ describe("scheduler", () => { expect(canActionBeDelayed(ActionType.CALL_WEBHOOK)).toBe(false); expect(canActionBeDelayed(ActionType.DRAFT_EMAIL)).toBe(false); expect(canActionBeDelayed(ActionType.MARK_SPAM)).toBe(false); - expect(canActionBeDelayed(ActionType.TRACK_THREAD)).toBe(false); expect(canActionBeDelayed(ActionType.DIGEST)).toBe(false); }); }); describe("cancelScheduledActions", () => { - it("should cancel scheduled actions for a message", async () => { + it("should cancel scheduled actions for a specific rule", async () => { // Mock finding actions to cancel prisma.scheduledAction.findMany.mockResolvedValue([ { id: "action-1", scheduledId: "qstash-msg-1" }, @@ -52,6 +51,7 @@ describe("scheduler", () => { const result = await cancelScheduledActions({ messageId: "msg-123", emailAccountId: "account-123", + ruleId: "rule-123", }); expect(prisma.scheduledAction.findMany).toHaveBeenCalledWith({ @@ -59,6 +59,9 @@ describe("scheduler", () => { emailAccountId: "account-123", messageId: "msg-123", status: ScheduledActionStatus.PENDING, + executedRule: { + ruleId: "rule-123", + }, }, select: { id: true, @@ -71,6 +74,9 @@ describe("scheduler", () => { emailAccountId: "account-123", messageId: "msg-123", status: ScheduledActionStatus.PENDING, + executedRule: { + ruleId: "rule-123", + }, }, data: { status: ScheduledActionStatus.CANCELLED, @@ -87,6 +93,7 @@ describe("scheduler", () => { const result = await cancelScheduledActions({ messageId: "msg-456", emailAccountId: "account-123", + ruleId: "rule-456", }); expect(result).toBe(0); @@ -102,6 +109,7 @@ describe("scheduler", () => { messageId: "msg-123", emailAccountId: "account-123", threadId: "thread-123", + ruleId: "rule-123", reason: "Custom reason", }); @@ -111,6 +119,9 @@ describe("scheduler", () => { messageId: "msg-123", threadId: "thread-123", status: ScheduledActionStatus.PENDING, + executedRule: { + ruleId: "rule-123", + }, }, select: { id: true, @@ -124,6 +135,9 @@ describe("scheduler", () => { messageId: "msg-123", threadId: "thread-123", status: ScheduledActionStatus.PENDING, + executedRule: { + ruleId: "rule-123", + }, }, data: { status: ScheduledActionStatus.CANCELLED, diff --git a/apps/web/utils/scheduled-actions/scheduler.ts b/apps/web/utils/scheduled-actions/scheduler.ts index 75acfe9446..fa55985554 100644 --- a/apps/web/utils/scheduled-actions/scheduler.ts +++ b/apps/web/utils/scheduled-actions/scheduler.ts @@ -168,11 +168,13 @@ export async function cancelScheduledActions({ emailAccountId, messageId, threadId, + ruleId, reason = "Superseded by new rule", }: { emailAccountId: string; messageId: string; threadId?: string; + ruleId: string; reason?: string; }) { try { @@ -183,6 +185,9 @@ export async function cancelScheduledActions({ messageId, ...(threadId && { threadId }), status: ScheduledActionStatus.PENDING, + executedRule: { + ruleId, + }, }, select: { id: true, scheduledId: true }, }); @@ -220,6 +225,7 @@ export async function cancelScheduledActions({ messageId, ...(threadId && { threadId }), status: ScheduledActionStatus.PENDING, + executedRule: { ruleId }, }, data: { status: ScheduledActionStatus.CANCELLED, @@ -231,6 +237,7 @@ export async function cancelScheduledActions({ emailAccountId, messageId, threadId, + ruleId, reason, }); diff --git a/apps/web/utils/types.ts b/apps/web/utils/types.ts index ee3663ef69..a0a44caec5 100644 --- a/apps/web/utils/types.ts +++ b/apps/web/utils/types.ts @@ -13,14 +13,6 @@ export type RuleWithActions = Prisma.RuleGetPayload<{ include: { actions: true }; }>; -export type RuleWithActionsAndCategories = Prisma.RuleGetPayload<{ - include: { actions: true; categoryFilters: true }; -}>; - -export type AIRuleWithActionsAndCategories = RuleWithActionsAndCategories & { - instructions: string; -}; - export type BatchError = { error: { code: number; diff --git a/apps/web/utils/webhook/process-history-item.ts b/apps/web/utils/webhook/process-history-item.ts index a3640230e2..94b8ab2920 100644 --- a/apps/web/utils/webhook/process-history-item.ts +++ b/apps/web/utils/webhook/process-history-item.ts @@ -9,13 +9,13 @@ import { type EmailAccount, NewsletterStatus } from "@prisma/client"; import { extractEmailAddress } from "@/utils/email"; import { isIgnoredSender } from "@/utils/filter-ignored-senders"; import type { EmailProvider } from "@/utils/email/types"; -import type { RuleWithActionsAndCategories } from "@/utils/types"; +import type { RuleWithActions } from "@/utils/types"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { Logger } from "@/utils/logger"; export type SharedProcessHistoryOptions = { provider: EmailProvider; - rules: RuleWithActionsAndCategories[]; + rules: RuleWithActions[]; hasAutomationRules: boolean; hasAiAccess: boolean; emailAccount: EmailAccountWithAI & @@ -58,13 +58,11 @@ export async function processHistoryItem( const [parsedMessage, hasExistingRule] = await Promise.all([ provider.getMessage(messageId), threadId - ? prisma.executedRule.findUnique({ + ? prisma.executedRule.findFirst({ where: { - unique_emailAccount_thread_message: { - emailAccountId, - threadId, - messageId, - }, + emailAccountId, + threadId, + messageId, }, select: { id: true }, }) @@ -79,13 +77,11 @@ export async function processHistoryItem( hasExistingRule !== null ? hasExistingRule : actualThreadId - ? await prisma.executedRule.findUnique({ + ? await prisma.executedRule.findFirst({ where: { - unique_emailAccount_thread_message: { - emailAccountId, - threadId: actualThreadId, - messageId, - }, + emailAccountId, + threadId: actualThreadId, + messageId, }, select: { id: true }, }) diff --git a/apps/web/utils/webhook/validate-webhook-account.ts b/apps/web/utils/webhook/validate-webhook-account.ts index 4d7cd65373..6c839a15e7 100644 --- a/apps/web/utils/webhook/validate-webhook-account.ts +++ b/apps/web/utils/webhook/validate-webhook-account.ts @@ -28,7 +28,7 @@ export async function getWebhookEmailAccount( }, rules: { where: { enabled: true }, - include: { actions: true, categoryFilters: true }, + include: { actions: true }, }, user: { select: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a34a333ad7..a22eaacce8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -375,9 +375,6 @@ importers: fast-xml-parser: specifier: 5.2.5 version: 5.2.5 - framer-motion: - specifier: 12.23.12 - version: 12.23.12(@emotion/is-prop-valid@1.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) gmail-api-parse-message: specifier: 2.1.2 version: 2.1.2 @@ -417,6 +414,9 @@ importers: lucide-react: specifier: 0.542.0 version: 0.542.0(react@19.1.1) + motion: + specifier: 12.23.24 + version: 12.23.24(@emotion/is-prop-valid@1.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next: specifier: 15.5.2 version: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -592,6 +592,9 @@ importers: '@types/string-similarity': specifier: 4.0.2 version: 4.0.2 + '@vitest/coverage-v8': + specifier: 3.2.4 + version: 3.2.4(vitest@3.2.4) '@vitest/ui': specifier: 3.2.4 version: 3.2.4(vitest@3.2.4) @@ -1517,6 +1520,10 @@ packages: resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@better-auth/sso@1.3.7': resolution: {integrity: sha512-MTwBiNash7HN0nLtQiL1tvYgWBn6GjYj6EYvtrQeb0/+UW0tjBDgsl39ojiFFSWGuT0gxPv+ij8tQNaFmQ1+2g==} peerDependencies: @@ -2253,6 +2260,10 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2272,6 +2283,9 @@ packages: '@jridgewell/trace-mapping@0.3.30': resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} @@ -4954,6 +4968,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -5298,6 +5321,9 @@ packages: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} + ast-v8-to-istanbul@0.3.7: + resolution: {integrity: sha512-kr1Hy6YRZBkGQSb6puP+D6FQ59Cx4m0siYhAxygMCAgadiWQ6oxAxQXHOMvJx67SJ63jRoVIIg5eXzUbbct1ww==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -6882,6 +6908,20 @@ packages: react-dom: optional: true + framer-motion@12.23.24: + resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -7214,6 +7254,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -7569,6 +7612,22 @@ packages: resolution: {integrity: sha512-nZmoK4wKdzPs5USq4JHBiimjdKSVAOm2T1KyDoadtMPNXYHxiENd19ou4iU/V4juFM6LVgYQnpxCYmxqNP4Obw==} engines: {node: '>=18'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -7954,6 +8013,9 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-dir@1.3.0: resolution: {integrity: sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==} engines: {node: '>=4'} @@ -7966,6 +8028,10 @@ packages: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -8340,9 +8406,26 @@ packages: motion-dom@12.23.12: resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} + motion-dom@12.23.23: + resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + motion-utils@12.23.6: resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + motion@12.23.24: + resolution: {integrity: sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -10372,6 +10455,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} @@ -12360,6 +12447,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/sso@1.3.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(zod@3.25.46)': dependencies: '@better-fetch/fetch': 1.1.18 @@ -13045,6 +13134,8 @@ snapshots: dependencies: minipass: 7.1.2 + '@istanbuljs/schema@0.1.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -13053,7 +13144,7 @@ snapshots: '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 optional: true '@jridgewell/resolve-uri@3.1.2': {} @@ -13061,7 +13152,7 @@ snapshots: '@jridgewell/source-map@0.3.11': dependencies: '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/sourcemap-codec@1.5.5': {} @@ -13070,6 +13161,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -16273,6 +16369,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.7 + debug: 4.4.1(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.18 + magicast: 0.3.5 + std-env: 3.9.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(@vitest/ui@3.2.4)(jiti@2.5.1)(jsdom@26.1.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.2 @@ -16685,6 +16800,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@0.3.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + astring@1.9.0: {} async-mutex@0.5.0: @@ -18453,6 +18574,16 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) + framer-motion@12.23.24(@emotion/is-prop-valid@1.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.2.2 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + fresh@0.5.2: {} fresh@2.0.0: {} @@ -18925,6 +19056,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-escaper@2.0.2: {} + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -19269,6 +19402,27 @@ snapshots: - supports-color - utf-8-validate + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.30 + debug: 4.4.1(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -19663,6 +19817,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.3 + '@babel/types': 7.28.2 + source-map-js: 1.2.1 + make-dir@1.3.0: dependencies: pify: 3.0.0 @@ -19676,6 +19836,10 @@ snapshots: dependencies: semver: 6.3.1 + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + make-error@1.3.6: {} map-obj@1.0.1: {} @@ -20335,8 +20499,21 @@ snapshots: dependencies: motion-utils: 12.23.6 + motion-dom@12.23.23: + dependencies: + motion-utils: 12.23.6 + motion-utils@12.23.6: {} + motion@12.23.24(@emotion/is-prop-valid@1.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + framer-motion: 12.23.24(@emotion/is-prop-valid@1.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.2.2 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + mrmime@2.0.1: {} ms@2.0.0: {} @@ -22839,7 +23016,7 @@ snapshots: terser-webpack-plugin@5.3.14(esbuild@0.25.9)(webpack@5.101.3(esbuild@0.25.9)): dependencies: - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.2 serialize-javascript: 6.0.2 @@ -22855,6 +23032,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + text-decoder@1.2.3: dependencies: b4a: 1.6.7 diff --git a/version.txt b/version.txt index 7728c4ba0a..c0436dea50 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.16.11 +v2.17.0