diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index 60aa557aaa..16ac616406 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -31,8 +31,9 @@ import { ActionType, CategoryFilterType, LogicalOperator, + SystemType, } from "@prisma/client"; -import { ConditionType } from "@/utils/config"; +import { ConditionType, type CoreConditionType } from "@/utils/config"; import { createRuleAction, updateRuleAction } from "@/utils/actions/rule"; import { type CreateRuleBody, @@ -195,7 +196,7 @@ export function RuleForm({ ConditionType.STATIC, ConditionType.CATEGORY, ].find((type) => !usedConditions.has(type)) as - | Exclude + | CoreConditionType | undefined; }, [conditions]); @@ -265,8 +266,17 @@ export function RuleForm({ /> + {showSystemTypeBadge(rule.systemType) && ( +
+ + This rule has special preset logic that may impact your conditions + +
+ )} +
Conditions +
@@ -801,9 +811,10 @@ function ReplyTrackerAction() { return (
- Tracks conversations this rule applies to.{" "} - {NEEDS_REPLY_LABEL_NAME} will be - automatically removed after you reply. + Used for reply tracking (Reply Zero). This action tracks emails this + rule is applied to and removes the{" "} + {NEEDS_REPLY_LABEL_NAME} label after you + reply to the email.
); @@ -963,3 +974,9 @@ function ActionField({
); } + +function showSystemTypeBadge(systemType?: SystemType | null): boolean { + if (systemType === SystemType.TO_REPLY) return true; + if (systemType === SystemType.CALENDAR) return true; + return false; +} diff --git a/apps/web/app/(app)/automation/examples.ts b/apps/web/app/(app)/automation/examples.ts index c049d19b3e..42ab3b95b2 100644 --- a/apps/web/app/(app)/automation/examples.ts +++ b/apps/web/app/(app)/automation/examples.ts @@ -19,10 +19,7 @@ export function hasExampleParams(rule: { const commonPrompts = [ "Label urgent emails as 'Urgent'", - "Label newsletters as 'Newsletter' and archive them", - "Label marketing emails as 'Marketing' and archive them", "Label emails from @mycompany.com addresses as 'Team'", - "Label receipts as 'Receipt' and archive them", ]; const common = `${commonPrompts.map((prompt) => `* ${prompt}`).join(".\n")}.`; @@ -53,9 +50,7 @@ const founderPrompt = `* If someone asks to set up a call, draft a reply with my * Label emails from investors as "Investor". * Label legal documents as "Legal". * Label emails about travel as "Travel". -* Label recruitment related emails as "Hiring". -* Label marketing and newsletter emails as "Marketing" and archive them. -* Label receipts as 'Receipt' and archive them.`; +* Label recruitment related emails as "Hiring".`; export const personas = { founder: { @@ -76,8 +71,7 @@ I've attached my media kit and pricing. * Label emails about affiliate programs as "Affiliate" and archive them. * Label collaboration requests as "Collab" and draft a reply asking about their audience size and engagement rates. * Label brand partnership emails as "Brand Deal" and forward to manager@example.com -* Label media inquiries as "Press" and draft a reply a polite reply. -* Label marketing and newsletter emails as "Marketing" and archive them.`, +* Label media inquiries as "Press" and draft a reply a polite reply.`, }, investor: { label: "💰 Investor", @@ -94,8 +88,7 @@ I've attached my media kit and pricing. * Forward emails about industry research reports to research@vc.com * If someone asks for a warm intro to a portfolio company, draft a reply asking for more context about why they want to connect. * Label emails about fund administration as "Fund Admin". -* Label emails about speaking at investment conferences as "Speaking Opportunity". -* Label marketing and newsletter emails as "Marketing" and archive them.`, +* Label emails about speaking at investment conferences as "Speaking Opportunity".`, }, assistant: { label: "📋 Assistant", @@ -197,10 +190,7 @@ I've attached my media kit and pricing. * Label emails about exam schedules as "Exam". * Label emails about campus events as "Event" and archive them. * If someone asks for class notes, draft a reply with our shared Google Drive folder link: https://drive.google.com/drive/u/0/folders/1234567890. -* Label emails about tutoring opportunities as "Tutoring" and draft a reply with that my rate is $70/hour or $40/hour for group tutoring. - -* Label newsletters as 'Newsletter' and archive them -* Label marketing emails as 'Marketing' and archive them`, +* Label emails about tutoring opportunities as "Tutoring" and draft a reply with that my rate is $70/hour or $40/hour for group tutoring.`, }, reachout: { label: "💬 Reachout", diff --git a/apps/web/app/(app)/automation/onboarding/CategoriesSetup.tsx b/apps/web/app/(app)/automation/onboarding/CategoriesSetup.tsx index 2dd99c34a5..b42662bcc5 100644 --- a/apps/web/app/(app)/automation/onboarding/CategoriesSetup.tsx +++ b/apps/web/app/(app)/automation/onboarding/CategoriesSetup.tsx @@ -32,8 +32,10 @@ import { type CreateRulesOnboardingBody, } from "@/utils/actions/rule.validation"; import { TooltipExplanation } from "@/components/TooltipExplanation"; -import { ASSISTANT_ONBOARDING_COOKIE } from "@/utils/cookies"; -import { markOnboardingAsCompleted } from "@/utils/cookies"; +import { + ASSISTANT_ONBOARDING_COOKIE, + markOnboardingAsCompleted, +} from "@/utils/cookies"; const NEXT_URL = "/automation/onboarding/draft-replies"; diff --git a/apps/web/app/(app)/automation/onboarding/page.tsx b/apps/web/app/(app)/automation/onboarding/page.tsx index 59b1bb5c45..9278b2bddc 100644 --- a/apps/web/app/(app)/automation/onboarding/page.tsx +++ b/apps/web/app/(app)/automation/onboarding/page.tsx @@ -2,9 +2,13 @@ import { Card } from "@/components/ui/card"; import { CategoriesSetup } from "./CategoriesSetup"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { ActionType, ColdEmailSetting, type Prisma } from "@prisma/client"; +import { + ActionType, + ColdEmailSetting, + SystemType, + type Prisma, +} from "@prisma/client"; import type { CategoryAction } from "@/utils/actions/rule.validation"; -import { RuleName } from "@/utils/rule/consts"; export default async function OnboardingPage() { const session = await auth(); @@ -23,7 +27,7 @@ type UserPreferences = Prisma.UserGetPayload<{ select: { rules: { select: { - name: true; + systemType: true; actions: { select: { type: true }; }; @@ -39,7 +43,7 @@ async function getUserPreferences(userId: string) { select: { rules: { select: { - name: true, + systemType: true, actions: { select: { type: true, @@ -55,11 +59,11 @@ async function getUserPreferences(userId: string) { return { toReply: getToReplySetting(user.rules), coldEmails: getColdEmailSetting(user.coldEmailBlocker), - newsletter: getRuleSetting(RuleName.Newsletter, user.rules), - marketing: getRuleSetting(RuleName.Marketing, user.rules), - calendar: getRuleSetting(RuleName.Calendar, user.rules), - receipt: getRuleSetting(RuleName.Receipt, user.rules), - notification: getRuleSetting(RuleName.Notification, user.rules), + newsletter: getRuleSetting(SystemType.NEWSLETTER, user.rules), + marketing: getRuleSetting(SystemType.MARKETING, user.rules), + calendar: getRuleSetting(SystemType.CALENDAR, user.rules), + receipt: getRuleSetting(SystemType.RECEIPT, user.rules), + notification: getRuleSetting(SystemType.NOTIFICATION, user.rules), }; } @@ -75,10 +79,10 @@ function getToReplySetting( } function getRuleSetting( - name: string, + systemType: SystemType, rules?: UserPreferences["rules"], ): CategoryAction | undefined { - const rule = rules?.find((rule) => rule.name === name); + const rule = rules?.find((rule) => rule.systemType === systemType); if (!rule) return undefined; if (rule.actions.some((action) => action.type === ActionType.ARCHIVE)) diff --git a/apps/web/app/(app)/automation/rule/create/page.tsx b/apps/web/app/(app)/automation/rule/create/page.tsx index a202fb4a4e..85d38d79f2 100644 --- a/apps/web/app/(app)/automation/rule/create/page.tsx +++ b/apps/web/app/(app)/automation/rule/create/page.tsx @@ -2,13 +2,13 @@ import { RuleForm } from "@/app/(app)/automation/RuleForm"; import { examples } from "@/app/(app)/automation/create/examples"; import { getEmptyCondition } from "@/utils/condition"; import { ActionType } from "@prisma/client"; -import type { ConditionType } from "@/utils/config"; +import type { CoreConditionType } from "@/utils/config"; export default async function CreateRulePage(props: { searchParams: Promise<{ example?: string; groupId?: string; - type?: Exclude; + type?: CoreConditionType; categoryId?: string; label?: string; }>; diff --git a/apps/web/app/api/user/rules/route.ts b/apps/web/app/api/user/rules/route.ts index ef24b77c34..20475d9ae2 100644 --- a/apps/web/app/api/user/rules/route.ts +++ b/apps/web/app/api/user/rules/route.ts @@ -5,9 +5,9 @@ import prisma from "@/utils/prisma"; export type RulesResponse = Awaited>; -async function getRules(options: { userId: string }) { +async function getRules({ userId }: { userId: string }) { return await prisma.rule.findMany({ - where: { userId: options.userId }, + where: { userId }, include: { actions: true, group: { select: { name: true } }, diff --git a/apps/web/prisma/migrations/20250414091625_rule_system_type/migration.sql b/apps/web/prisma/migrations/20250414091625_rule_system_type/migration.sql new file mode 100644 index 0000000000..8fe665c503 --- /dev/null +++ b/apps/web/prisma/migrations/20250414091625_rule_system_type/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId,systemType]` on the table `Rule` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "SystemType" AS ENUM ('TO_REPLY', 'NEWSLETTER', 'MARKETING', 'CALENDAR', 'RECEIPT', 'NOTIFICATION'); + +-- AlterTable +ALTER TABLE "Rule" ADD COLUMN "systemType" "SystemType"; + +-- CreateIndex +CREATE UNIQUE INDEX "Rule_userId_systemType_key" ON "Rule"("userId", "systemType"); diff --git a/apps/web/prisma/migrations/20250414103126_migrate_system_rule_types/migration.sql b/apps/web/prisma/migrations/20250414103126_migrate_system_rule_types/migration.sql new file mode 100644 index 0000000000..3e6736bc93 --- /dev/null +++ b/apps/web/prisma/migrations/20250414103126_migrate_system_rule_types/migration.sql @@ -0,0 +1,6 @@ +UPDATE "Rule" SET "systemType" = 'TO_REPLY' WHERE "name" = 'To Reply'; +UPDATE "Rule" SET "systemType" = 'NEWSLETTER' WHERE "name" = 'Newsletter'; +UPDATE "Rule" SET "systemType" = 'MARKETING' WHERE "name" = 'Marketing'; +UPDATE "Rule" SET "systemType" = 'CALENDAR' WHERE "name" = 'Calendar'; +UPDATE "Rule" SET "systemType" = 'RECEIPT' WHERE "name" = 'Receipt'; +UPDATE "Rule" SET "systemType" = 'NOTIFICATION' WHERE "name" = 'Notification'; \ No newline at end of file diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 2a7dd5589c..fd1b5d69dc 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -206,7 +206,10 @@ model Rule { categoryFilterType CategoryFilterType? categoryFilters Category[] + systemType SystemType? + @@unique([name, userId]) + @@unique([userId, systemType]) } model Action { @@ -601,3 +604,13 @@ enum CleanAction { ARCHIVE MARK_READ } + +enum SystemType { + TO_REPLY + NEWSLETTER + MARKETING + CALENDAR + RECEIPT + NOTIFICATION + // COLD_EMAIL // handled separately. we may unify in the future +} diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index c5e7f387ed..accca81185 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -23,7 +23,12 @@ import { getGmailAccessToken, getGmailClient } from "@/utils/gmail/client"; import { aiFindExampleMatches } from "@/utils/ai/example-matches/find-example-matches"; import { withActionInstrumentation } from "@/utils/actions/middleware"; import { flattenConditions } from "@/utils/condition"; -import { ActionType, ColdEmailSetting, LogicalOperator } from "@prisma/client"; +import { + ActionType, + ColdEmailSetting, + LogicalOperator, + SystemType, +} from "@prisma/client"; import { updatePromptFileOnRuleUpdated, updateRuleInstructionsAndPromptFile, @@ -85,6 +90,7 @@ export const createRuleAction = withActionInstrumentation( : undefined, userId: session.user.id, conditionalOperator: body.conditionalOperator || LogicalOperator.AND, + systemType: body.systemType || undefined, // conditions instructions: conditions.instructions || null, from: conditions.from || null, @@ -159,6 +165,7 @@ export const updateRuleAction = withActionInstrumentation( name: body.name || undefined, conditionalOperator: body.conditionalOperator || LogicalOperator.AND, + systemType: body.systemType || undefined, // conditions instructions: conditions.instructions || null, from: conditions.from || null, @@ -419,13 +426,14 @@ export const createRulesOnboardingAction = withActionInstrumentation( "createRulesOnboarding", async (options: CreateRulesOnboardingBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const userId = session?.user.id; + if (!userId) return { error: "Not logged in" }; const { data, error } = createRulesOnboardingBody.safeParse(options); if (error) return { error: error.message }; const user = await prisma.user.findUnique({ - where: { id: session.user.id }, + where: { id: userId }, select: { rulesPrompt: true }, }); if (!user) return { error: "User not found" }; @@ -438,7 +446,7 @@ export const createRulesOnboardingAction = withActionInstrumentation( // cold email blocker if (isSet(data.coldEmail)) { const promise = prisma.user.update({ - where: { id: session.user.id }, + where: { id: userId }, data: { coldEmailBlocker: data.coldEmail === "label" @@ -483,9 +491,11 @@ export const createRulesOnboardingAction = withActionInstrumentation( runOnThreads: boolean, categoryAction: "label" | "label_archive", label: string, + systemType: SystemType, + userId: string, ) { const existingRule = await prisma.rule.findUnique({ - where: { name_userId: { name, userId: session?.user.id! } }, + where: { userId_systemType: { userId, systemType } }, }); if (existingRule) { @@ -520,9 +530,10 @@ export const createRulesOnboardingAction = withActionInstrumentation( const promise = prisma.rule .create({ data: { - userId: session?.user.id!, + userId, name, instructions, + systemType, automate: true, runOnThreads, actions: { @@ -553,10 +564,10 @@ export const createRulesOnboardingAction = withActionInstrumentation( } } - async function deleteRule(name: string) { + async function deleteRule(systemType: SystemType, userId: string) { const promise = async () => { const rule = await prisma.rule.findUnique({ - where: { name_userId: { name, userId: session?.user.id! } }, + where: { userId_systemType: { userId, systemType } }, }); if (!rule) return; await prisma.rule.delete({ where: { id: rule.id } }); @@ -573,9 +584,11 @@ export const createRulesOnboardingAction = withActionInstrumentation( false, data.newsletter, "Newsletter", + SystemType.NEWSLETTER, + userId, ); } else { - deleteRule(RuleName.Newsletter); + deleteRule(SystemType.NEWSLETTER, userId); } // marketing @@ -587,9 +600,11 @@ export const createRulesOnboardingAction = withActionInstrumentation( false, data.marketing, "Marketing", + SystemType.MARKETING, + userId, ); } else { - deleteRule(RuleName.Marketing); + deleteRule(SystemType.MARKETING, userId); } // calendar @@ -601,9 +616,11 @@ export const createRulesOnboardingAction = withActionInstrumentation( false, data.calendar, "Calendar", + SystemType.CALENDAR, + userId, ); } else { - deleteRule(RuleName.Calendar); + deleteRule(SystemType.CALENDAR, userId); } // receipt @@ -615,9 +632,11 @@ export const createRulesOnboardingAction = withActionInstrumentation( false, data.receipt, "Receipt", + SystemType.RECEIPT, + userId, ); } else { - deleteRule(RuleName.Receipt); + deleteRule(SystemType.RECEIPT, userId); } // notification @@ -629,9 +648,11 @@ export const createRulesOnboardingAction = withActionInstrumentation( false, data.notification, "Notification", + SystemType.NOTIFICATION, + userId, ); } else { - deleteRule(RuleName.Notification); + deleteRule(SystemType.NOTIFICATION, userId); } await Promise.allSettled(promises); diff --git a/apps/web/utils/actions/rule.validation.ts b/apps/web/utils/actions/rule.validation.ts index d271466de5..75282a42a9 100644 --- a/apps/web/utils/actions/rule.validation.ts +++ b/apps/web/utils/actions/rule.validation.ts @@ -3,6 +3,7 @@ import { ActionType, CategoryFilterType, LogicalOperator, + SystemType, } from "@prisma/client"; import { ConditionType } from "@/utils/config"; @@ -120,6 +121,16 @@ export const createRuleBody = z.object({ .enum([LogicalOperator.AND, LogicalOperator.OR]) .default(LogicalOperator.AND) .optional(), + systemType: z + .enum([ + SystemType.TO_REPLY, + SystemType.NEWSLETTER, + SystemType.MARKETING, + SystemType.CALENDAR, + SystemType.RECEIPT, + SystemType.NOTIFICATION, + ]) + .nullish(), }); export type CreateRuleBody = z.infer; 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 248ec1c1f5..f358a5eb3b 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { gmail_v1 } from "@googleapis/gmail"; import { findMatchingRule, matchesStaticRule } from "./match-rules"; import { type Category, @@ -20,6 +21,8 @@ import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; // Run with: // pnpm test match-rules.test.ts +const gmail = {} as gmail_v1.Gmail; + vi.mock("server-only", () => ({})); vi.mock("@/utils/prisma"); vi.mock("@/utils/ai/choose-rule/ai-choose-rule", () => ({ @@ -104,8 +107,7 @@ describe("findMatchingRule", () => { headers: getHeaders({ from: "test@example.com" }), }); const user = getUser(); - - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe("Matched static conditions"); @@ -119,7 +121,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe("Matched static conditions"); @@ -133,7 +135,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule?.id).toBeUndefined(); expect(result.reason).toBeUndefined(); @@ -158,7 +160,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe(`Matched group item: "FROM: test@example.com"`); @@ -177,7 +179,7 @@ describe("findMatchingRule", () => { const message = getMessage(); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe('Matched category: "category"'); @@ -196,7 +198,7 @@ describe("findMatchingRule", () => { const message = getMessage(); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule?.id).toBeUndefined(); expect(result.reason).toBeUndefined(); @@ -232,7 +234,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe(`Matched group item: "FROM: test@example.com"`); @@ -262,7 +264,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBeDefined(); @@ -293,7 +295,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule).toBeUndefined(); expect(result.reason).toBeDefined(); @@ -315,7 +317,7 @@ describe("findMatchingRule", () => { const message = getMessage(); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe('Matched category: "category"'); @@ -336,7 +338,7 @@ describe("findMatchingRule", () => { const message = getMessage(); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe('Matched category: "category"'); @@ -378,7 +380,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule).toBeUndefined(); expect(result.reason).toBeUndefined(); @@ -418,7 +420,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toContain("test@example.com"); @@ -459,7 +461,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); // Should match the first rule only expect(result.rule?.id).toBe("rule1"); diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index 9180426bb9..004ebe9f01 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -1,3 +1,4 @@ +import type { gmail_v1 } from "@googleapis/gmail"; import { getConditionTypes, isAIRule } from "@/utils/condition"; import { findMatchingGroup, @@ -8,7 +9,12 @@ import type { RuleWithActions, RuleWithActionsAndCategories, } from "@/utils/types"; -import { CategoryFilterType, LogicalOperator, type User } from "@prisma/client"; +import { + CategoryFilterType, + LogicalOperator, + type User, + SystemType, +} from "@prisma/client"; import { ConditionType } from "@/utils/config"; import prisma from "@/utils/prisma"; import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; @@ -21,9 +27,13 @@ import type { MatchingRuleResult, } from "@/utils/ai/choose-rule/types"; import { extractEmailAddress } from "@/utils/email"; +import { hasIcsAttachment } from "@/utils/parse/calender-event"; +import { checkSenderReplyHistory } from "@/utils/reply-tracker/check-sender-reply-history"; 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 @@ -32,15 +42,37 @@ async function findPotentialMatchingRules({ rules, message, isThread, + gmail, }: { rules: RuleWithActionsAndCategories[]; message: ParsedMessage; isThread: boolean; + gmail: gmail_v1.Gmail; }): Promise { const potentialMatches: (RuleWithActionsAndCategories & { instructions: string; })[] = []; + // Check for calendar preset match + 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, + }); + return { + match: calendarRule, + matchReasons: [ + { type: ConditionType.PRESET, systemType: SystemType.CALENDAR }, + ], + }; + } + } + // groups singleton let groups: Awaited>; // only load once and only when needed @@ -143,7 +175,14 @@ async function findPotentialMatchingRules({ } } - return { potentialMatches }; + // Apply TO_REPLY preset filter before returning potential matches + const filteredPotentialMatches = await filterToReplyPreset( + potentialMatches, + message, + gmail, + ); + + return { potentialMatches: filteredPotentialMatches }; } function getMatchReason(matchReasons?: MatchReason[]): string | undefined { @@ -158,6 +197,8 @@ function getMatchReason(matchReasons?: MatchReason[]): string | undefined { return `Matched group item: "${reason.groupItem.type}: ${reason.groupItem.value}"`; case ConditionType.CATEGORY: return `Matched category: "${reason.category.name}"`; + case ConditionType.PRESET: + return "Matched a system preset"; } }) .join(", "); @@ -167,8 +208,9 @@ export async function findMatchingRule( rules: RuleWithActionsAndCategories[], message: ParsedMessage, user: Pick & UserAIFields, + gmail: gmail_v1.Gmail, ) { - const result = await findMatchingRuleWithReasons(rules, message, user); + const result = await findMatchingRuleWithReasons(rules, message, user, gmail); return { ...result, reason: result.reason || getMatchReason(result.matchReasons || []), @@ -179,6 +221,7 @@ async function findMatchingRuleWithReasons( rules: RuleWithActionsAndCategories[], message: ParsedMessage, user: Pick & UserAIFields, + gmail: gmail_v1.Gmail, ): Promise<{ rule?: RuleWithActionsAndCategories; matchReasons?: MatchReason[]; @@ -190,6 +233,7 @@ async function findMatchingRuleWithReasons( rules, message, isThread, + gmail, }); if (match) return { rule: match, matchReasons }; @@ -272,3 +316,52 @@ async function matchesCategoryRule( return matchedFilter; } + +// Helper function to filter out TO_REPLY preset if conditions met +async function filterToReplyPreset( + potentialMatches: (RuleWithActionsAndCategories & { instructions: string })[], + message: ParsedMessage, + gmail: gmail_v1.Gmail, +): Promise<(RuleWithActionsAndCategories & { instructions: string })[]> { + const toReplyRuleIndex = potentialMatches.findIndex( + (r) => r.systemType === SystemType.TO_REPLY, + ); + + if (toReplyRuleIndex === -1) { + return potentialMatches; // No TO_REPLY rule found + } + + const senderEmail = message.headers.from; + if (!senderEmail) { + return potentialMatches; // Cannot check history without sender email + } + + try { + const { hasReplied, receivedCount } = await checkSenderReplyHistory( + gmail, + senderEmail, + TO_REPLY_RECEIVED_THRESHOLD, + ); + + // If user hasn't replied and received count meets/exceeds the threshold, filter out the rule. + if (!hasReplied && receivedCount >= TO_REPLY_RECEIVED_THRESHOLD) { + logger.info( + "Filtering out TO_REPLY rule due to no prior reply and high received count", + { + ruleId: potentialMatches[toReplyRuleIndex].id, + senderEmail, + receivedCount, + }, + ); + return potentialMatches.filter((_, index) => index !== toReplyRuleIndex); + } + } catch (error) { + // Log the error but proceed without filtering in case of failure + logger.error("Error checking reply history for TO_REPLY filter", { + senderEmail, + error, + }); + } + + return potentialMatches; +} diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index d31183d2e7..d5035c7d03 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -45,7 +45,7 @@ export async function runRules({ user: Pick & UserAIFields; isTest: boolean; }): Promise { - const result = await findMatchingRule(rules, message, user); + const result = await findMatchingRule(rules, message, user, gmail); analyzeSenderPatternIfAiMatch(isTest, result, message, user); diff --git a/apps/web/utils/ai/choose-rule/types.ts b/apps/web/utils/ai/choose-rule/types.ts index adbb97e001..3cbd7fb80c 100644 --- a/apps/web/utils/ai/choose-rule/types.ts +++ b/apps/web/utils/ai/choose-rule/types.ts @@ -1,6 +1,6 @@ -import type { RuleWithActionsAndCategories } from "@/utils/types"; -import type { Category, Group, GroupItem } from "@prisma/client"; +import type { Category, Group, GroupItem, SystemType } from "@prisma/client"; import type { ConditionType } from "@/utils/config"; +import type { RuleWithActionsAndCategories } from "@/utils/types"; export type StaticMatch = { type: Extract; @@ -21,7 +21,17 @@ export type AiMatch = { type: Extract; }; -export type MatchReason = StaticMatch | GroupMatch | CategoryMatch | AiMatch; +export type PresetMatch = { + type: Extract; + systemType: SystemType; +}; + +export type MatchReason = + | StaticMatch + | GroupMatch + | CategoryMatch + | AiMatch + | PresetMatch; export type MatchingRuleResult = { match?: RuleWithActionsAndCategories; diff --git a/apps/web/utils/ai/reply/check-reply-tracking.ts b/apps/web/utils/ai/reply/check-reply-tracking.ts deleted file mode 100644 index ff9b100141..0000000000 --- a/apps/web/utils/ai/reply/check-reply-tracking.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { z } from "zod"; -import type { Rule } from "@prisma/client"; -import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; -import { createScopedLogger } from "@/utils/logger"; - -const logger = createScopedLogger("check-reply-tracking"); - -const schema = z.object({ - replyTrackingRuleId: z.string().nullable(), -}); - -export async function aiFindReplyTrackingRule({ - rules, - user, -}: { - rules: Pick[]; - user: UserEmailWithAI; -}) { - const system = `You are an AI assistant that finds a rule that is designed to track email replies. -If no such rule is found, return null.`; - - const prompt = ` -${rules - .map( - (rule) => - ` - ${rule.id} - ${rule.instructions} -`, - ) - .join("\n")} -`; - - logger.trace("Input", { system, prompt }); - - const aiResponse = await chatCompletionObject({ - userAi: user, - system, - prompt, - schema, - userEmail: user.email || "", - usageLabel: "Check reply tracking", - }); - - logger.trace("Result", { response: aiResponse.object }); - - return aiResponse.object; -} diff --git a/apps/web/utils/condition.ts b/apps/web/utils/condition.ts index 90ec42d84b..fed871ee14 100644 --- a/apps/web/utils/condition.ts +++ b/apps/web/utils/condition.ts @@ -4,7 +4,7 @@ import { type Category, type Rule, } from "@prisma/client"; -import { ConditionType } from "@/utils/config"; +import { ConditionType, type CoreConditionType } from "@/utils/config"; import type { CreateRuleBody, ZodCondition, @@ -91,7 +91,7 @@ export function getConditionTypes( } export function getEmptyCondition( - type: Exclude, + type: CoreConditionType, category?: string, ): ZodCondition { switch (type) { @@ -179,6 +179,8 @@ function conditionTypeToString(conditionType: ConditionType): string { return "Group"; case ConditionType.CATEGORY: return "Category"; + case ConditionType.PRESET: + return "Preset"; default: // biome-ignore lint/correctness/noSwitchDeclarations: intentional exhaustive check const exhaustiveCheck: never = conditionType; diff --git a/apps/web/utils/config.ts b/apps/web/utils/config.ts index 928a81cdb2..311f1dd8f5 100644 --- a/apps/web/utils/config.ts +++ b/apps/web/utils/config.ts @@ -7,6 +7,8 @@ export const ConditionType = { STATIC: "STATIC", GROUP: "GROUP", CATEGORY: "CATEGORY", + PRESET: "PRESET", } as const; export type ConditionType = (typeof ConditionType)[keyof typeof ConditionType]; +export type CoreConditionType = Exclude; diff --git a/apps/web/utils/parse/calender-event.ts b/apps/web/utils/parse/calender-event.ts index 9ecbbc9c33..1c3b3214ef 100644 --- a/apps/web/utils/parse/calender-event.ts +++ b/apps/web/utils/parse/calender-event.ts @@ -48,14 +48,8 @@ export function analyzeCalendarEvent(email: ParsedMessage): CalendarEventInfo { // Check body for calendar event indicators const body = email.textHtml || ""; - // Check for iCalendar data - const hasiCalData = - body.includes("BEGIN:VCALENDAR") || - body.includes("BEGIN:VEVENT") || - body.includes("method=REQUEST"); - // Determine if it's a calendar event based on checks - result.isCalendarEvent = hasCalendarSubject || hasiCalData; + result.isCalendarEvent = hasCalendarSubject || hasIcsAttachment(email); if (result.isCalendarEvent) { // Extract event title @@ -239,6 +233,22 @@ export function analyzeCalendarEvent(email: ParsedMessage): CalendarEventInfo { return result; } +/** + * Checks if an email has a .ics file attachment. + * @param email The email to check. + * @returns True if a .ics attachment is found, false otherwise. + */ +export function hasIcsAttachment(email: ParsedMessage): boolean { + if (!email.attachments || email.attachments.length === 0) { + return false; + } + + return email.attachments.some( + (attachment) => + attachment.filename?.toLowerCase().endsWith(".ics"), + ); +} + export function isCalendarEventInPast(email: ParsedMessage) { const calendarEvent = analyzeCalendarEvent(email); diff --git a/apps/web/utils/reply-tracker/check-sender-reply-history.ts b/apps/web/utils/reply-tracker/check-sender-reply-history.ts new file mode 100644 index 0000000000..d5bbce83ec --- /dev/null +++ b/apps/web/utils/reply-tracker/check-sender-reply-history.ts @@ -0,0 +1,113 @@ +import type { gmail_v1 } from "@googleapis/gmail"; +import { extractEmailAddress } from "@/utils/email"; +import { createScopedLogger } from "@/utils/logger"; +import { getMessages } from "@/utils/gmail/message"; + +const logger = createScopedLogger("reply-tracker/query"); + +/** + * Checks if a user has ever sent a reply to a specific sender and counts received emails + * using the Gmail API. + * @param gmail The authenticated Gmail API client instance. + * @param senderEmail The email address of the sender. + * @param receivedThreshold The number of received emails to check against. + * @returns An object containing `hasReplied` (boolean) and `receivedCount` (number, capped at receivedThreshold). + */ +export async function checkSenderReplyHistory( + gmail: gmail_v1.Gmail, + senderEmail: string, + receivedThreshold: number, +): Promise<{ hasReplied: boolean; receivedCount: number }> { + const cleanSenderEmail = extractEmailAddress(senderEmail); + if (!cleanSenderEmail) { + logger.warn("Could not extract email from sender", { senderEmail }); + // Default to assuming a reply might be needed if email is invalid + return { hasReplied: true, receivedCount: 0 }; + } + + try { + // Run checks in parallel for efficiency + const [hasReplied, receivedCount] = await Promise.all([ + checkIfReplySent(gmail, cleanSenderEmail), + countReceivedMessages(gmail, cleanSenderEmail, receivedThreshold), + ]).catch((error) => { + logger.error("Timeout or error in parallel operations", { + error, + cleanSenderEmail, + }); + return [true, 0] as const; // Safe defaults + }); + + logger.info("Sender reply history check final result", { + senderEmail, + cleanSenderEmail, + hasReplied, + receivedCount, + }); + + return { hasReplied, receivedCount }; + } catch (error) { + // Catch potential errors from Promise.all or other unexpected issues + logger.error("Overall error checking sender reply history", { + error, + senderEmail, + cleanSenderEmail, + }); + // Default to assuming a reply might be needed on error + return { hasReplied: true, receivedCount: 0 }; + } +} + +// Helper to check if a reply was sent to the sender +async function checkIfReplySent( + gmail: gmail_v1.Gmail, + cleanSenderEmail: string, +): Promise { + try { + const query = `from:me to:${cleanSenderEmail} label:sent`; + const response = await getMessages(gmail, { query, maxResults: 1 }); + const sent = (response.messages?.length ?? 0) > 0; + logger.info("Checked for sent reply", { cleanSenderEmail, sent }); + return sent; + } catch (error) { + logger.error("Error checking if reply was sent", { + error, + cleanSenderEmail, + }); + return true; // Default to true on error (safer for TO_REPLY filtering) + } +} + +// Helper to count messages received from the sender up to a threshold +async function countReceivedMessages( + gmail: gmail_v1.Gmail, + cleanSenderEmail: string, + threshold: number, +): Promise { + try { + const query = `from:${cleanSenderEmail}`; + logger.info(`Checking received message count (up to ${threshold})`, { + cleanSenderEmail, + threshold, + }); + + // Fetch up to the threshold number of message IDs. + const response = await getMessages(gmail, { + query, + maxResults: threshold, + }); + const count = response.messages?.length ?? 0; + + logger.info("Received message count check result", { + cleanSenderEmail, + count, + }); + return count; + } catch (error) { + logger.error("Error counting received messages", { + error, + cleanSenderEmail, + }); + return 0; // Default to 0 on error + } +} diff --git a/apps/web/utils/reply-tracker/enable.ts b/apps/web/utils/reply-tracker/enable.ts index 804311fab3..8ac97e1f34 100644 --- a/apps/web/utils/reply-tracker/enable.ts +++ b/apps/web/utils/reply-tracker/enable.ts @@ -1,7 +1,6 @@ import prisma from "@/utils/prisma"; -import { aiFindReplyTrackingRule } from "@/utils/ai/reply/check-reply-tracking"; import { safeCreateRule } from "@/utils/rule/rule"; -import { ActionType, type Prisma } from "@prisma/client"; +import { ActionType, SystemType, type Prisma } from "@prisma/client"; import { defaultReplyTrackerInstructions, NEEDS_REPLY_LABEL_NAME, @@ -31,9 +30,12 @@ export async function enableReplyTracker(userId: string) { about: true, rulesPrompt: true, rules: { + where: { + systemType: SystemType.TO_REPLY, + }, select: { id: true, - instructions: true, + systemType: true, actions: { select: { id: true, @@ -48,14 +50,7 @@ export async function enableReplyTracker(userId: string) { if (!user) return { error: "User not found" }; - const result = user.rules.length - ? await aiFindReplyTrackingRule({ - rules: user.rules, - user, - }) - : null; - - const rule = user.rules.find((r) => r.id === result?.replyTrackingRuleId); + const rule = user.rules.find((r) => r.systemType === SystemType.TO_REPLY); let ruleId: string | null = rule?.id || null; @@ -103,6 +98,8 @@ export async function enableReplyTracker(userId: string) { ], }, userId, + null, + SystemType.TO_REPLY, ); if ("error" in newRule) { diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index ba6b64a552..4c97f54c94 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -1,7 +1,12 @@ import type { CreateOrUpdateRuleSchemaWithCategories } from "@/utils/ai/rule/create-rule-schema"; import prisma, { isDuplicateError } from "@/utils/prisma"; import { createScopedLogger } from "@/utils/logger"; -import { ActionType, type Prisma, type Rule } from "@prisma/client"; +import { + ActionType, + type SystemType, + type Prisma, + type Rule, +} from "@prisma/client"; import { getUserCategoriesForNames } from "@/utils/category.server"; import { getActionRiskLevel, type RiskAction } from "@/utils/risk"; import { hasExampleParams } from "@/app/(app)/automation/examples"; @@ -26,6 +31,7 @@ export async function safeCreateRule( result: CreateOrUpdateRuleSchemaWithCategories, userId: string, categoryNames?: string[] | null, + systemType?: SystemType | null, ) { const categoryIds = await getUserCategoriesForNames( userId, @@ -37,6 +43,7 @@ export async function safeCreateRule( result, userId, categoryIds, + systemType, }); return rule; } catch (error) { @@ -98,10 +105,12 @@ export async function createRule({ result, userId, categoryIds, + systemType, }: { result: CreateOrUpdateRuleSchemaWithCategories; userId: string; categoryIds?: string[] | null; + systemType?: SystemType | null; }) { const mappedActions = mapActionFields(result.actions); @@ -109,6 +118,7 @@ export async function createRule({ data: { name: result.name, userId, + systemType, actions: { createMany: { data: mappedActions } }, automate: shouldAutomate( result,