diff --git a/apps/web/app/(app)/automation/TestRules.tsx b/apps/web/app/(app)/automation/TestRules.tsx
index 31a75f4ee0..2a1635fd87 100644
--- a/apps/web/app/(app)/automation/TestRules.tsx
+++ b/apps/web/app/(app)/automation/TestRules.tsx
@@ -396,7 +396,7 @@ export function TestResultDisplay({
)}
{!!result.reason && (
- AI Reasoning:
+ Reason:
{result.reason}
)}
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 7aa619432b..2d2804746b 100644
--- a/apps/web/utils/ai/choose-rule/match-rules.test.ts
+++ b/apps/web/utils/ai/choose-rule/match-rules.test.ts
@@ -42,7 +42,7 @@ describe("findMatchingRule", () => {
const result = await findMatchingRule(rules, message, user);
expect(result.rule?.id).toBe(rule.id);
- expect(result.reason).toBeUndefined();
+ expect(result.reason).toBe("Matched static conditions");
});
it("matches a static domain", async () => {
@@ -56,7 +56,7 @@ describe("findMatchingRule", () => {
const result = await findMatchingRule(rules, message, user);
expect(result.rule?.id).toBe(rule.id);
- expect(result.reason).toBeUndefined();
+ expect(result.reason).toBe("Matched static conditions");
});
it("doens't match wrong static domain", async () => {
@@ -93,7 +93,7 @@ describe("findMatchingRule", () => {
const result = await findMatchingRule(rules, message, user);
expect(result.rule?.id).toBe(rule.id);
- expect(result.reason).toBeUndefined();
+ expect(result.reason).toBe(`Matched group item: "FROM: test@example.com"`);
});
it("matches a smart category rule", async () => {
@@ -112,7 +112,7 @@ describe("findMatchingRule", () => {
const result = await findMatchingRule(rules, message, user);
expect(result.rule?.id).toBe(rule.id);
- expect(result.reason).toBeUndefined();
+ expect(result.reason).toBe('Matched category: "category"');
});
it("matches a smart category rule with exclude", async () => {
@@ -161,7 +161,9 @@ describe("findMatchingRule", () => {
const result = await findMatchingRule(rules, message, user);
expect(result.rule?.id).toBe(rule.id);
- expect(result.reason).toBeUndefined();
+ expect(result.reason).toBe(
+ `Matched group item: "FROM: test@example.com", Matched category: "category"`,
+ );
});
it("matches a rule with multiple conditions AND (category and AI)", async () => {
@@ -265,7 +267,7 @@ describe("findMatchingRule", () => {
const result = await findMatchingRule(rules, message, user);
expect(result.rule?.id).toBe(rule.id);
- expect(result.reason).toBeUndefined();
+ expect(result.reason).toBe('Matched category: "category"');
});
});
diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts
index 13ef314b38..9648d19350 100644
--- a/apps/web/utils/ai/choose-rule/match-rules.ts
+++ b/apps/web/utils/ai/choose-rule/match-rules.ts
@@ -20,6 +20,7 @@ import type { UserAIFields } from "@/utils/llms/types";
// ai rules need further processing to determine if they match
type MatchingRuleResult = {
match?: RuleWithActionsAndCategories;
+ reason?: string;
potentialMatches?: (RuleWithActionsAndCategories & {
instructions: string;
})[];
@@ -69,14 +70,16 @@ async function findPotentialMatchingRules({
const conditionTypes = getConditionTypes(rule);
const unmatchedConditions = new Set(Object.keys(conditionTypes));
+ const matchReasons: string[] = [];
// static
if (conditionTypes.STATIC) {
const match = matchesStaticRule(rule, message);
if (match) {
unmatchedConditions.delete("STATIC");
+ matchReasons.push("Matched static conditions");
if (operator === LogicalOperator.OR || !unmatchedConditions.size)
- return { match: rule };
+ return { match: rule, reason: matchReasons.join(", ") };
} else {
// no match, so can't be a match with AND
if (operator === LogicalOperator.AND) continue;
@@ -85,11 +88,21 @@ async function findPotentialMatchingRules({
// group
if (conditionTypes.GROUP) {
- const match = await matchesGroupRule(await getGroups(rule), message);
- if (match) {
+ const { matchingItem } = await matchesGroupRule(
+ await getGroups(rule),
+ message,
+ );
+ if (matchingItem) {
unmatchedConditions.delete("GROUP");
- if (operator === LogicalOperator.OR || !unmatchedConditions.size)
- return { match: rule };
+ matchReasons.push(
+ `Matched group item: "${matchingItem.type}: ${matchingItem.value}"`,
+ );
+ if (operator === LogicalOperator.OR || !unmatchedConditions.size) {
+ return {
+ match: rule,
+ reason: matchReasons.join(", "),
+ };
+ }
} else {
// no match, so can't be a match with AND
if (operator === LogicalOperator.AND) continue;
@@ -101,8 +114,10 @@ async function findPotentialMatchingRules({
const match = await matchesCategoryRule(rule, await getSender(rule));
if (match) {
unmatchedConditions.delete("CATEGORY");
+ if (typeof match !== "boolean")
+ matchReasons.push(`Matched category: "${match.name}"`);
if (operator === LogicalOperator.OR || !unmatchedConditions.size)
- return { match: rule };
+ return { match: rule, reason: matchReasons.join(", ") };
} else {
// no match, so can't be a match with AND
if (operator === LogicalOperator.AND) continue;
@@ -125,13 +140,13 @@ export async function findMatchingRule(
user: Pick & UserAIFields,
): Promise<{ rule?: RuleWithActionsAndCategories; reason?: string }> {
const isThread = isReplyInThread(message.id, message.threadId);
- const { match, potentialMatches } = await findPotentialMatchingRules({
+ const { match, reason, potentialMatches } = await findPotentialMatchingRules({
rules,
message,
isThread,
});
- if (match) return { rule: match, reason: undefined };
+ if (match) return { rule: match, reason };
if (potentialMatches?.length) {
const result = await aiChooseRule({
@@ -177,8 +192,7 @@ async function matchesGroupRule(
groups: Awaited>,
message: ParsedMessage,
) {
- const match = findMatchingGroup(message, groups);
- return !!match;
+ return findMatchingGroup(message, groups);
}
async function matchesCategoryRule(
@@ -190,16 +204,17 @@ async function matchesCategoryRule(
if (!sender) return false;
- const isIncluded = rule.categoryFilters.some(
+ const matchedFilter = rule.categoryFilters.find(
(c) => c.id === sender.categoryId,
);
if (
- (rule.categoryFilterType === CategoryFilterType.INCLUDE && !isIncluded) ||
- (rule.categoryFilterType === CategoryFilterType.EXCLUDE && isIncluded)
+ (rule.categoryFilterType === CategoryFilterType.INCLUDE &&
+ !matchedFilter) ||
+ (rule.categoryFilterType === CategoryFilterType.EXCLUDE && matchedFilter)
) {
return false;
}
- return true;
+ return matchedFilter;
}
diff --git a/apps/web/utils/group/find-matching-group.test.ts b/apps/web/utils/group/find-matching-group.test.ts
new file mode 100644
index 0000000000..4f3a8d2847
--- /dev/null
+++ b/apps/web/utils/group/find-matching-group.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest";
+import { findMatchingGroupItem } from "./find-matching-group";
+import { GroupItemType } from "@prisma/client";
+
+// Run with:
+// pnpm test utils/group/find-matching-group.test.ts
+
+describe("findMatchingGroupItem", () => {
+ it("should match FROM rules", () => {
+ const groupItems = [
+ { type: GroupItemType.FROM, value: "newsletter@company.com" },
+ { type: GroupItemType.FROM, value: "@company.com" },
+ ];
+
+ // Full email match
+ expect(
+ findMatchingGroupItem(
+ { from: "newsletter@company.com", subject: "" },
+ groupItems,
+ ),
+ ).toBe(groupItems[0]);
+
+ // Partial domain match
+ expect(
+ findMatchingGroupItem(
+ { from: "support@company.com", subject: "" },
+ groupItems,
+ ),
+ ).toBe(groupItems[1]);
+
+ // No match
+ expect(
+ findMatchingGroupItem(
+ { from: "someone@other.com", subject: "" },
+ groupItems,
+ ),
+ ).toBeUndefined();
+ });
+
+ it("should match SUBJECT rules", () => {
+ const groupItems = [
+ { type: GroupItemType.SUBJECT, value: "Invoice" },
+ { type: GroupItemType.SUBJECT, value: "[GitHub]" },
+ ];
+
+ // Exact subject match
+ expect(
+ findMatchingGroupItem({ from: "", subject: "Invoice #123" }, groupItems),
+ ).toBe(groupItems[0]);
+
+ // Match after number removal
+ expect(
+ findMatchingGroupItem(
+ { from: "", subject: "Invoice INV-2023-001 from Company" },
+ groupItems,
+ ),
+ ).toBe(groupItems[0]);
+
+ // GitHub notification match
+ expect(
+ findMatchingGroupItem(
+ { from: "", subject: "[GitHub] PR #456: Fix bug" },
+ groupItems,
+ ),
+ ).toBe(groupItems[1]);
+
+ // No match
+ expect(
+ findMatchingGroupItem(
+ { from: "", subject: "Welcome to our service" },
+ groupItems,
+ ),
+ ).toBeUndefined();
+ });
+
+ it("should handle empty inputs", () => {
+ const groupItems = [
+ { type: GroupItemType.FROM, value: "test@example.com" },
+ { type: GroupItemType.SUBJECT, value: "Test" },
+ ];
+
+ expect(
+ findMatchingGroupItem({ from: "", subject: "" }, groupItems),
+ ).toBeUndefined();
+
+ expect(
+ findMatchingGroupItem(
+ { from: "test@example.com", subject: "" },
+ groupItems,
+ ),
+ ).toBe(groupItems[0]);
+ });
+
+ it("should prioritize first matching rule", () => {
+ const groupItems = [
+ { type: GroupItemType.SUBJECT, value: "Invoice" },
+ { type: GroupItemType.SUBJECT, value: "Company" },
+ ];
+
+ // Should return first matching rule even though both would match
+ expect(
+ findMatchingGroupItem(
+ { from: "", subject: "Invoice from Company" },
+ groupItems,
+ ),
+ ).toBe(groupItems[0]);
+ });
+});
diff --git a/apps/web/utils/group/find-matching-group.ts b/apps/web/utils/group/find-matching-group.ts
index fe91df4d3e..4b7fc87003 100644
--- a/apps/web/utils/group/find-matching-group.ts
+++ b/apps/web/utils/group/find-matching-group.ts
@@ -15,10 +15,13 @@ export function findMatchingGroup(
message: ParsedMessage,
groups: GroupsWithRules,
) {
- const group = groups.find((group) =>
- findMatchingGroupItem(message.headers, group.items),
- );
- return group;
+ for (const group of groups) {
+ const matchingItem = findMatchingGroupItem(message.headers, group.items);
+ if (matchingItem) {
+ return { group, matchingItem };
+ }
+ }
+ return { group: null, matchingItem: null };
}
export function findMatchingGroupItem<
@@ -26,7 +29,7 @@ export function findMatchingGroupItem<
>(headers: { from: string; subject: string }, groupItems: T[]) {
const { from, subject } = headers;
- return groupItems.find((item) => {
+ const matchingItem = groupItems.find((item) => {
if (item.type === GroupItemType.FROM && from) {
return item.value.includes(from) || from.includes(item.value);
}
@@ -36,9 +39,7 @@ export function findMatchingGroupItem<
const valueWithoutNumbers = generalizeSubject(item.value);
return (
- item.value.includes(subject) ||
subject.includes(item.value) ||
- valueWithoutNumbers.includes(subjectWithoutNumbers) ||
subjectWithoutNumbers.includes(valueWithoutNumbers)
);
}
@@ -50,4 +51,6 @@ export function findMatchingGroupItem<
return false;
});
+
+ return matchingItem;
}
diff --git a/apps/web/utils/string.test.ts b/apps/web/utils/string.test.ts
index ff2e9e8c60..0218ba502f 100644
--- a/apps/web/utils/string.test.ts
+++ b/apps/web/utils/string.test.ts
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
-import { removeExcessiveWhitespace, truncate } from "./string";
+import {
+ removeExcessiveWhitespace,
+ truncate,
+ generalizeSubject,
+} from "./string";
// Run with:
// pnpm test utils/string.test.ts
@@ -63,4 +67,22 @@ describe("string utils", () => {
expect(removeExcessiveWhitespace(input)).toBe("hello world\n\ntest");
});
});
+ describe("generalizeSubject", () => {
+ it("should remove numbers and IDs", () => {
+ expect(generalizeSubject("Order #123")).toBe("Order");
+ expect(generalizeSubject("Invoice 456")).toBe("Invoice");
+ expect(generalizeSubject("[org/repo] PR #789: Fix bug (abc123)")).toBe(
+ "[org/repo] PR : Fix bug",
+ );
+ });
+
+ it("should preserve normal text", () => {
+ expect(generalizeSubject("Welcome to our service")).toBe(
+ "Welcome to our service",
+ );
+ expect(generalizeSubject("Your account has been created")).toBe(
+ "Your account has been created",
+ );
+ });
+ });
});
diff --git a/apps/web/utils/string.ts b/apps/web/utils/string.ts
index fcb7054c2a..8e617f765c 100644
--- a/apps/web/utils/string.ts
+++ b/apps/web/utils/string.ts
@@ -25,13 +25,14 @@ export function removeExcessiveWhitespace(str: string) {
}
export function generalizeSubject(subject = "") {
- // replace numbers to make subject more generic
- // also removes [], () ,and words that start with #
- const regex =
- /(\b\d+(\.\d+)?(-\d+(\.\d+)?)?(\b|[A-Za-z])|\[.*?\]|\(.*?\)|\b#\w+)/g;
-
- // remove any words that contain numbers
- const regexRemoveNumberWords = /\b\w*\d\w*\b/g;
-
- return subject.replaceAll(regexRemoveNumberWords, "").replaceAll(regex, "");
+ return (
+ subject
+ // Remove content in parentheses
+ .replace(/\([^)]*\)/g, "")
+ // Remove numbers and IDs
+ .replace(/(?:#\d+|\b\d+\b)/g, "")
+ // Clean up extra whitespace
+ .replace(/\s+/g, " ")
+ .trim()
+ );
}