diff --git a/apps/web/app/(app)/automation/BulkRunRules.tsx b/apps/web/app/(app)/automation/BulkRunRules.tsx index 048c493f88..2f59f9fa2b 100644 --- a/apps/web/app/(app)/automation/BulkRunRules.tsx +++ b/apps/web/app/(app)/automation/BulkRunRules.tsx @@ -37,10 +37,10 @@ export function BulkRunRules() { return (
- + - To process specific emails: -
    -
  1. - Go to the{" "} - - Mail - {" "} - page -
  2. -
  3. Select the desired emails
  4. -
  5. Click the "Run AI Rules" button
  6. -
+ You can also process specific emails by visiting the{" "} + + Mail + {" "} + page.
)} diff --git a/apps/web/app/(app)/automation/Pending.tsx b/apps/web/app/(app)/automation/Pending.tsx index 51d541e23a..be2e7b1a83 100644 --- a/apps/web/app/(app)/automation/Pending.tsx +++ b/apps/web/app/(app)/automation/Pending.tsx @@ -178,7 +178,7 @@ function PendingTable({ /> - + diff --git a/apps/web/app/(app)/automation/Process.tsx b/apps/web/app/(app)/automation/Process.tsx new file mode 100644 index 0000000000..64b141a48c --- /dev/null +++ b/apps/web/app/(app)/automation/Process.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; +import { TestRulesContent } from "@/app/(app)/automation/TestRules"; +import { Toggle } from "@/components/Toggle"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export function Process() { + const [applyMode, setApplyMode] = useState(false); + + return ( + + + Process your emails + + + {applyMode + ? "Run your rules on previous emails." + : "Check how your rules perform against previous emails."} + + +
+ +
+
+ +
+ ); +} diff --git a/apps/web/app/(app)/automation/ProcessingPromptFileDialog.tsx b/apps/web/app/(app)/automation/ProcessingPromptFileDialog.tsx index f09e9699f6..3ae2a8cf6a 100644 --- a/apps/web/app/(app)/automation/ProcessingPromptFileDialog.tsx +++ b/apps/web/app/(app)/automation/ProcessingPromptFileDialog.tsx @@ -20,7 +20,6 @@ import { } from "@/components/ui/carousel"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { TestRulesContent } from "@/app/(app)/automation/TestRules"; import { Loading } from "@/components/Loading"; /* diff --git a/apps/web/app/(app)/automation/ReportMistake.tsx b/apps/web/app/(app)/automation/ReportMistake.tsx index 5ad637249f..74a7e628d6 100644 --- a/apps/web/app/(app)/automation/ReportMistake.tsx +++ b/apps/web/app/(app)/automation/ReportMistake.tsx @@ -16,7 +16,7 @@ import { Button } from "@/components/ui/button"; import { Label } from "@/components/Input"; import { toastError, toastSuccess } from "@/components/Toast"; import { isActionError } from "@/utils/error"; -import type { TestResult } from "@/utils/ai/choose-rule/run-rules"; +import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; import { Dialog, DialogContent, @@ -24,7 +24,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { reportAiMistakeAction, testAiAction } from "@/utils/actions/ai-rule"; +import { reportAiMistakeAction, runRulesAction } from "@/utils/actions/ai-rule"; import type { MessagesResponse } from "@/app/api/google/messages/route"; import { zodResolver } from "@hookform/resolvers/zod"; import { @@ -45,6 +45,7 @@ import { TestResultDisplay } from "@/app/(app)/automation/TestRules"; import { isReplyInThread } from "@/utils/thread"; import { isAIRule, isGroupRule, isStaticRule } from "@/utils/condition"; import { Loading } from "@/components/Loading"; +import type { ParsedMessage } from "@/utils/types"; type ReportMistakeView = "select-expected-rule" | "ai-fix" | "manual-fix"; @@ -55,7 +56,7 @@ export function ReportMistake({ result, }: { message: MessagesResponse["messages"][number]; - result: TestResult | null; + result: RunRulesResult | null; }) { const { data, isLoading, error } = useSWR( "/api/user/rules", @@ -99,7 +100,7 @@ function Content({ }: { rules: RulesResponse; message: MessagesResponse["messages"][number]; - result: TestResult | null; + result: RunRulesResult | null; actualRule: Rule | null; }) { const [loadingAiFix, setLoadingAiFix] = useState(false); @@ -244,7 +245,7 @@ function Content({ loadingAiFix={loadingAiFix} fixedInstructions={fixedInstructions ?? null} fixedInstructionsRule={fixedInstructionsRule ?? null} - messageId={message.id} + message={message} onBack={onBack} onReject={() => onSetView("manual-fix")} /> @@ -271,7 +272,7 @@ function AIFixView({ loadingAiFix, fixedInstructions, fixedInstructionsRule, - messageId, + message, onBack, onReject, }: { @@ -281,7 +282,7 @@ function AIFixView({ fixedInstructions: string; } | null; fixedInstructionsRule: Rule | null; - messageId: string; + message: ParsedMessage; onBack: () => void; onReject: () => void; }) { @@ -310,7 +311,7 @@ function AIFixView({ {fixedInstructions?.ruleId && ( void; }) { @@ -454,7 +455,7 @@ function ManualFixView({ actualRule?: Rule | null; expectedRule?: Rule | null; message: MessagesResponse["messages"][number]; - result: TestResult | null; + result: RunRulesResult | null; onBack: () => void; }) { return ( @@ -495,7 +496,7 @@ function ManualFixView({ )} - + ); @@ -559,7 +560,7 @@ function AIFixForm({ expectedRuleId, }: { message: MessagesResponse["messages"][number]; - result: TestResult | null; + result: RunRulesResult | null; expectedRuleId: string | null; }) { const [fixedInstructions, setFixedInstructions] = useState<{ @@ -642,7 +643,7 @@ function AIFixForm({ {fixedInstructions && ( setFixedInstructions(undefined)} @@ -654,13 +655,13 @@ function AIFixForm({ } function SuggestedFix({ - messageId, + message, ruleId, fixedInstructions, onReject, showRerunTestButton, }: { - messageId: string; + message: ParsedMessage; ruleId: string; fixedInstructions: string; onReject: () => void; @@ -676,7 +677,7 @@ function SuggestedFix({ {accepted ? ( showRerunTestButton && (
- +
) ) : ( @@ -729,9 +730,9 @@ function Instructions({ ); } -function RerunTestButton({ messageId }: { messageId: string }) { +function RerunTestButton({ message }: { message: ParsedMessage }) { const [checking, setChecking] = useState(false); - const [testResult, setTestResult] = useState(); + const [testResult, setTestResult] = useState(); return ( <> @@ -740,14 +741,18 @@ function RerunTestButton({ messageId }: { messageId: string }) { onClick={async () => { setChecking(true); - const result = await testAiAction({ messageId }); + const result = await runRulesAction({ + isTest: true, + messageId: message.id, + threadId: message.threadId, + }); if (isActionError(result)) { toastError({ title: "There was an error testing the email", description: result.error, }); } else { - setTestResult(result); + setTestResult(result as RunRulesResult); } setChecking(false); }} diff --git a/apps/web/app/(app)/automation/TestRules.tsx b/apps/web/app/(app)/automation/TestRules.tsx index 6dbcbdda0c..5aa9607e8a 100644 --- a/apps/web/app/(app)/automation/TestRules.tsx +++ b/apps/web/app/(app)/automation/TestRules.tsx @@ -17,6 +17,7 @@ import { EyeIcon, ExternalLinkIcon, ChevronsDownIcon, + RefreshCcwIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/Input"; @@ -27,14 +28,14 @@ import type { MessagesResponse } from "@/app/api/google/messages/route"; import { Separator } from "@/components/ui/separator"; import { TestRulesMessage } from "@/app/(app)/cold-email-blocker/TestRulesMessage"; import { - testAiAction, + runRulesAction, testAiCustomContentAction, } from "@/utils/actions/ai-rule"; import type { RulesResponse } from "@/app/api/user/rules/route"; import { Table, TableBody, TableRow, TableCell } from "@/components/ui/table"; import { CardContent } from "@/components/ui/card"; import { isActionError } from "@/utils/error"; -import type { TestResult } from "@/utils/ai/choose-rule/run-rules"; +import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; import { SearchForm } from "@/components/SearchForm"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { ReportMistake } from "@/app/(app)/automation/ReportMistake"; @@ -46,7 +47,8 @@ import { isGroupRule, isStaticRule, } from "@/utils/condition"; -import { ButtonLoader } from "@/components/Loading"; +import { BulkRunRules } from "@/app/(app)/automation/BulkRunRules"; +import { cn } from "@/utils"; type Message = MessagesResponse["messages"][number]; @@ -57,7 +59,7 @@ export function TestRules(props: { disabled?: boolean }) { description="Test how your rules perform against real emails." content={
- +
} > @@ -69,7 +71,7 @@ export function TestRules(props: { disabled?: boolean }) { ); } -export function TestRulesContent() { +export function TestRulesContent({ testMode }: { testMode: boolean }) { const [searchQuery, setSearchQuery] = useQueryState("search"); const [showCustomForm, setShowCustomForm] = useQueryState( "custom", @@ -110,67 +112,77 @@ export function TestRulesContent() { !isCategoryRule(rule), ); - const isTestingAllRef = useRef(false); - const [isTestingAll, setIsTestingAll] = useState(false); - const [isTesting, setIsTesting] = useState>({}); - const [testResults, setTestResults] = useState>( - {}, - ); + const isRunningAllRef = useRef(false); + const [isRunningAll, setIsRunningAll] = useState(false); + const [isRunning, setIsRunning] = useState>({}); + const [results, setResults] = useState>({}); - const onTest = useCallback(async (message: Message) => { - setIsTesting((prev) => ({ ...prev, [message.id]: true })); + const onRun = useCallback( + async (message: Message, rerun?: boolean) => { + setIsRunning((prev) => ({ ...prev, [message.id]: true })); - const result = await testAiAction({ messageId: message.id }); - if (isActionError(result)) { - toastError({ - title: "There was an error testing the email", - description: result.error, + const result = await runRulesAction({ + messageId: message.id, + threadId: message.threadId, + isTest: testMode, + rerun, }); - } else { - setTestResults((prev) => ({ ...prev, [message.id]: result })); - } - setIsTesting((prev) => ({ ...prev, [message.id]: false })); - }, []); + if (isActionError(result)) { + toastError({ + title: "There was an error processing the email", + description: result.error, + }); + } else { + setResults((prev) => ({ ...prev, [message.id]: result })); + } + setIsRunning((prev) => ({ ...prev, [message.id]: false })); + }, + [testMode], + ); - const handleTestAll = async () => { + const handleRunAll = async () => { handleStart(); for (const message of messages) { - if (!isTestingAllRef.current) break; - if (testResults[message.id]) continue; - await onTest(message); + if (!isRunningAllRef.current) break; + if (results[message.id]) continue; + await onRun(message); } handleStop(); }; const handleStart = () => { - setIsTestingAll(true); - isTestingAllRef.current = true; + setIsRunningAll(true); + isRunningAllRef.current = true; }; const handleStop = () => { - isTestingAllRef.current = false; - setIsTestingAll(false); + isRunningAllRef.current = false; + setIsRunningAll(false); }; return (
- {isTestingAll ? ( - - ) : ( - - )} +
+ {isRunningAll ? ( + + ) : ( + + )} + + {!testMode && } +
- {hasAiRules && ( + {hasAiRules && testMode && (
- {hasAiRules && showCustomForm && ( + {hasAiRules && showCustomForm && testMode && (
@@ -206,9 +218,10 @@ export function TestRulesContent() { key={message.id} message={message} userEmail={email!} - isTesting={isTesting[message.id]} - testResult={testResults[message.id]} - onTest={() => onTest(message)} + isRunning={isRunning[message.id]} + result={results[message.id]} + onRun={(rerun) => onRun(message, rerun)} + testMode={testMode} /> ))} @@ -235,7 +248,7 @@ export function TestRulesContent() { type TestRulesInputs = { message: string }; const TestRulesForm = () => { - const [testResult, setTestResult] = useState(); + const [testResult, setTestResult] = useState(); const { register, @@ -284,20 +297,22 @@ const TestRulesForm = () => { function TestRulesContentRow({ message, userEmail, - isTesting, - testResult, - onTest, + isRunning, + result, + onRun, + testMode, }: { message: Message; userEmail: string; - isTesting: boolean; - testResult: TestResult; - onTest: () => void; + isRunning: boolean; + result: RunRulesResult; + onRun: (rerun?: boolean) => void; + testMode: boolean; }) { return ( @@ -309,18 +324,35 @@ function TestRulesContentRow({ userEmail={userEmail} messageId={message.id} /> -
- {testResult ? ( +
+ {result ? ( <> -
- +
+ {result.existing && ( + Already processed + )} +
- + + ) : ( - )}
@@ -334,7 +366,7 @@ export function TestResultDisplay({ result, prefix, }: { - result: TestResult; + result: RunRulesResult; prefix?: string; }) { if (!result) return null; diff --git a/apps/web/app/(app)/automation/page.tsx b/apps/web/app/(app)/automation/page.tsx index cb5da14cd1..943a5e4ecc 100644 --- a/apps/web/app/(app)/automation/page.tsx +++ b/apps/web/app/(app)/automation/page.tsx @@ -5,14 +5,7 @@ import { Pending } from "@/app/(app)/automation/Pending"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { Rules } from "@/app/(app)/automation/Rules"; -import { TestRulesContent } from "@/app/(app)/automation/TestRules"; -import { - Card, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { BulkRunRules } from "@/app/(app)/automation/BulkRunRules"; +import { Process } from "@/app/(app)/automation/Process"; import { Groups } from "@/app/(app)/automation/group/Groups"; import { RulesPrompt } from "@/app/(app)/automation/RulesPrompt"; import { OnboardingModal } from "@/components/OnboardingModal"; @@ -34,26 +27,23 @@ export default async function AutomationPage() { Prompt Rules - Test + Process History Pending Groups
-
- - - Learn how to use the AI Personal Assistant to automatically - label, archive, and more. - - } - videoId="1LSt3dyyZtQ" - /> -
+ + Learn how to use the AI Personal Assistant to automatically + label, archive, and more. + + } + videoId="1LSt3dyyZtQ" + />
@@ -63,16 +53,7 @@ export default async function AutomationPage() { - - - Test your rules - - Check how your rules perform against previous emails or custom - content. - - - - + diff --git a/apps/web/components/CrispChat.tsx b/apps/web/components/CrispChat.tsx index daff62f50e..67d2d30c96 100644 --- a/apps/web/components/CrispChat.tsx +++ b/apps/web/components/CrispChat.tsx @@ -8,6 +8,7 @@ const CrispChat = ({ email }: { email?: string }) => { useEffect(() => { if (env.NEXT_PUBLIC_CRISP_WEBSITE_ID) { Crisp.configure(env.NEXT_PUBLIC_CRISP_WEBSITE_ID); + Crisp.setHideOnMobile(true); if (email) Crisp.user.setEmail(email); } }, [email]); diff --git a/apps/web/components/Toggle.tsx b/apps/web/components/Toggle.tsx index 5823312034..9869e22a85 100644 --- a/apps/web/components/Toggle.tsx +++ b/apps/web/components/Toggle.tsx @@ -1,4 +1,4 @@ -import { Switch, SwitchGroup } from "@headlessui/react"; +import { Switch, Field } from "@headlessui/react"; import clsx from "clsx"; import type { FieldError } from "react-hook-form"; import { ErrorMessage, ExplainText, Label } from "./Input"; @@ -6,6 +6,7 @@ import { ErrorMessage, ExplainText, Label } from "./Input"; export interface ToggleProps { name: string; label?: string; + labelRight?: string; enabled: boolean; explainText?: string; error?: FieldError; @@ -13,11 +14,11 @@ export interface ToggleProps { } export const Toggle = (props: ToggleProps) => { - const { label, enabled, onChange } = props; + const { label, labelRight, enabled, onChange } = props; return (
- + {label && ( + {labelRight && ( + + + )} + {props.explainText ? ( {props.explainText} ) : null} diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index d7be451af3..34a17cfbfc 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -13,7 +13,7 @@ import { getGmailClient } from "@/utils/gmail/client"; import { aiCreateRule } from "@/utils/ai/rule/create-rule"; import { runRulesOnMessage, - testRulesOnMessage, + type RunRulesResult, } from "@/utils/ai/choose-rule/run-rules"; import { emailToContent, parseMessage } from "@/utils/mail"; import { getMessage, getMessages } from "@/utils/gmail/message"; @@ -22,18 +22,17 @@ import { createReceiptGroupAction, } from "@/utils/actions/group"; import { GroupName } from "@/utils/config"; -import type { EmailForAction } from "@/utils/ai/actions"; import { executeAct } from "@/utils/ai/choose-rule/execute"; import { isDefined, type ParsedMessage } from "@/utils/types"; import { getSessionAndGmailClient } from "@/utils/actions/helpers"; -import { isActionError } from "@/utils/error"; +import { type ActionError, isActionError } from "@/utils/error"; import { reportAiMistakeBody, type ReportAiMistakeBody, saveRulesPromptBody, type SaveRulesPromptBody, - testAiBody, - type TestAiBody, + runRulesBody, + type RunRulesBody, } from "@/utils/actions/validation"; import { aiPromptToRules } from "@/utils/ai/rule/prompt-to-rules"; import { aiDiffRules } from "@/utils/ai/rule/diff-rules"; @@ -50,7 +49,12 @@ const logger = createScopedLogger("ai-rule"); export const runRulesAction = withActionInstrumentation( "runRules", - async ({ email, force }: { email: EmailForAction; force: boolean }) => { + async (unsafeBody: RunRulesBody): Promise => { + const { success, data, error } = runRulesBody.safeParse(unsafeBody); + if (!success) return { error: error.message }; + + const { messageId, threadId, rerun, isTest } = data; + const sessionResult = await getSessionAndGmailClient(); if (isActionError(sessionResult)) return sessionResult; const { gmail, user: u } = sessionResult; @@ -72,75 +76,49 @@ export const runRulesAction = withActionInstrumentation( }); if (!user?.email) return { error: "User email not found" }; - const [gmailMessage, hasExistingRule] = await Promise.all([ - getMessage(email.messageId, gmail, "full"), - prisma.executedRule.findUnique({ - where: { - unique_user_thread_message: { - userId: user.id, - threadId: email.threadId, - messageId: email.messageId, - }, - }, - select: { id: true }, - }), + const fetchExecutedRule = !isTest && !rerun; + + const [gmailMessage, executedRule] = await Promise.all([ + getMessage(messageId, gmail, "full"), + fetchExecutedRule + ? prisma.executedRule.findUnique({ + where: { + unique_user_thread_message: { + userId: user.id, + threadId, + messageId, + }, + }, + select: { + id: true, + reason: true, + actionItems: true, + rule: true, + }, + }) + : null, ]); - if (hasExistingRule && !force) { + if (executedRule) { logger.info("Skipping. Rule already exists.", { email: user.email, - messageId: email.messageId, - threadId: email.threadId, + messageId, + threadId, }); - return; - } - - const message = parseMessage(gmailMessage); - - await runRulesOnMessage({ - gmail, - message, - rules: user.rules, - user: { ...user, email: user.email }, - isTest: false, - }); - }, -); - -export const testAiAction = withActionInstrumentation( - "testAi", - async (unsafeBody: TestAiBody) => { - const sessionResult = await getSessionAndGmailClient(); - if (isActionError(sessionResult)) return sessionResult; - - const { success, data, error } = testAiBody.safeParse(unsafeBody); - if (!success) return { error: error.message }; - const { messageId } = data; - const { gmail, user: u } = sessionResult; - - const user = await prisma.user.findUnique({ - where: { id: u.id }, - select: { - id: true, - email: true, - about: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, - rules: { - where: { enabled: true }, - include: { actions: true, categoryFilters: true }, - }, - }, - }); - if (!user) return { error: "User not found" }; - - const gmailMessage = await getMessage(messageId, gmail, "full"); + return { + rule: executedRule.rule, + actionItems: executedRule.actionItems, + reason: executedRule.reason, + existing: true, + error: undefined, + }; + } const message = parseMessage(gmailMessage); - const result = await testRulesOnMessage({ + const result = await runRulesOnMessage({ + isTest, gmail, message, rules: user.rules, @@ -175,7 +153,8 @@ export const testAiCustomContentAction = withActionInstrumentation( }); if (!user) return { error: "User not found" }; - const result = await testRulesOnMessage({ + const result = await runRulesOnMessage({ + isTest: true, gmail, message: { id: "testMessageId", @@ -542,7 +521,10 @@ export const saveRulesPromptAction = withActionInstrumentation( const { data, success, error } = saveRulesPromptBody.safeParse(unsafeData); if (!success) { - console.error("Input validation failed:", error.message); + logger.error("Input validation failed", { + email: session.user.email, + error: error.message, + }); return { error: error.message }; } @@ -558,11 +540,11 @@ export const saveRulesPromptAction = withActionInstrumentation( }); if (!user) { - console.error("User not found"); + logger.error("User not found"); return { error: "User not found" }; } if (!user.email) { - console.error("User email not found"); + logger.error("User email not found"); return { error: "User email not found" }; } @@ -706,7 +688,10 @@ export const saveRulesPromptAction = withActionInstrumentation( for (const rule of editedRules) { if (!rule.ruleId) { - console.error(`Rule ID not found for rule. Prompt: ${rule.name}`); + logger.error("Rule ID not found for rule", { + email: user.email, + promptRule: rule.name, + }); continue; } diff --git a/apps/web/utils/actions/group.ts b/apps/web/utils/actions/group.ts index 0300263e86..3219fc7cc8 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -104,23 +104,25 @@ async function generateGroupItemsFromPrompt( }); await prisma.$transaction([ - ...result.senders.map((sender) => - prisma.groupItem.upsert({ - where: { - groupId_type_value: { - groupId, + ...result.senders + .filter((sender) => !sender.includes(user.email!)) + .map((sender) => + prisma.groupItem.upsert({ + where: { + groupId_type_value: { + groupId, + type: GroupItemType.FROM, + value: sender, + }, + }, + update: {}, // No update needed if it exists + create: { type: GroupItemType.FROM, value: sender, + groupId, }, - }, - update: {}, // No update needed if it exists - create: { - type: GroupItemType.FROM, - value: sender, - groupId, - }, - }), - ), + }), + ), ...result.subjects.map((subject) => prisma.groupItem.upsert({ where: { @@ -159,7 +161,7 @@ export const createNewsletterGroupAction = withActionInstrumentation( "createNewsletterGroup", async () => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + if (!session?.user.email) return { error: "Not logged in" }; const name = GroupName.NEWSLETTER; const existingGroup = await prisma.group.findFirst({ @@ -173,7 +175,11 @@ export const createNewsletterGroupAction = withActionInstrumentation( if (!token.token) return { error: "No access token" }; - const newsletters = await findNewsletters(gmail, token.token); + const newsletters = await findNewsletters( + gmail, + token.token, + session.user.email, + ); const group = await prisma.group.create({ data: { @@ -198,7 +204,7 @@ export const createReceiptGroupAction = withActionInstrumentation( "createReceiptGroup", async () => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + if (!session?.user.email) return { error: "Not logged in" }; const name = GroupName.RECEIPT; const existingGroup = await prisma.group.findFirst({ @@ -212,7 +218,7 @@ export const createReceiptGroupAction = withActionInstrumentation( if (!token.token) return { error: "No access token" }; - const receipts = await findReceipts(gmail, token.token); + const receipts = await findReceipts(gmail, token.token, session.user.email); const group = await prisma.group.create({ data: { @@ -241,7 +247,7 @@ export const regenerateGroupAction = withActionInstrumentation( "regenerateGroup", async (groupId: string) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + if (!session?.user.email) return { error: "Not logged in" }; const existingGroup = await prisma.group.findUnique({ where: { id: groupId, userId: session.user.id }, @@ -260,9 +266,19 @@ export const regenerateGroupAction = withActionInstrumentation( if (!token.token) return { error: "No access token" }; if (existingGroup.name === GroupName.NEWSLETTER) { - await regenerateNewsletterGroup(existingGroup, gmail, token.token); + await regenerateNewsletterGroup( + existingGroup, + gmail, + token.token, + session.user.email, + ); } else if (existingGroup.name === GroupName.RECEIPT) { - await regenerateReceiptGroup(existingGroup, gmail, token.token); + await regenerateReceiptGroup( + existingGroup, + gmail, + token.token, + session.user.email, + ); } else if (existingGroup.prompt) { const user = await prisma.user.findUnique({ where: { id: session.user.id }, @@ -295,8 +311,9 @@ async function regenerateNewsletterGroup( existingGroup: ExistingGroup, gmail: gmail_v1.Gmail, token: string, + userEmail: string, ) { - const newsletters = await findNewsletters(gmail, token); + const newsletters = await findNewsletters(gmail, token, userEmail); const items = newsletters.map((item) => ({ type: GroupItemType.FROM, @@ -314,8 +331,9 @@ async function regenerateReceiptGroup( existingGroup: ExistingGroup, gmail: gmail_v1.Gmail, token: string, + userEmail: string, ) { - const receipts = await findReceipts(gmail, token); + const receipts = await findReceipts(gmail, token, userEmail); const newItems = filterOutExisting(receipts, existingGroup.items); await createGroupItems( diff --git a/apps/web/utils/actions/validation.ts b/apps/web/utils/actions/validation.ts index 3df5f30e49..4de8390381 100644 --- a/apps/web/utils/actions/validation.ts +++ b/apps/web/utils/actions/validation.ts @@ -159,6 +159,14 @@ export type RulesExamplesBody = z.infer; export const testAiBody = z.object({ messageId: z.string() }); export type TestAiBody = z.infer; +export const runRulesBody = z.object({ + messageId: z.string(), + threadId: z.string(), + rerun: z.boolean().nullish(), + isTest: z.boolean(), +}); +export type RunRulesBody = z.infer; + export const reportAiMistakeBody = z .object({ email: z.object({ diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index 3aeb017a27..861ed15de0 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -20,10 +20,11 @@ import { createScopedLogger } from "@/utils/logger"; const logger = createScopedLogger("ai-run-rules"); -export type TestResult = { +export type RunRulesResult = { rule?: Rule | null; actionItems?: ActionItem[]; reason?: string | null; + existing?: boolean; }; export async function runRulesOnMessage({ @@ -38,7 +39,7 @@ export async function runRulesOnMessage({ rules: RuleWithActionsAndCategories[]; user: Pick & UserAIFields; isTest: boolean; -}) { +}): Promise { const result = await findMatchingRule(rules, message, user); if (result.rule) { return await runRule( @@ -135,28 +136,6 @@ async function saveSkippedExecutedRule({ }); } -export async function testRulesOnMessage({ - gmail, - message, - rules, - user, -}: { - gmail: gmail_v1.Gmail; - message: ParsedMessage; - rules: RuleWithActionsAndCategories[]; - user: Pick & UserAIFields; -}): Promise { - const result = await runRulesOnMessage({ - gmail, - message, - rules, - user, - isTest: true, - }); - - return result; -} - async function saveExecutedRule( { userId, diff --git a/apps/web/utils/ai/group/find-newsletters.ts b/apps/web/utils/ai/group/find-newsletters.ts index c842dd0095..5f9bd6df7f 100644 --- a/apps/web/utils/ai/group/find-newsletters.ts +++ b/apps/web/utils/ai/group/find-newsletters.ts @@ -12,6 +12,7 @@ const ignoreList = ["@github.com", "@google.com", "@gmail.com", "@slack.com"]; export async function findNewsletters( gmail: gmail_v1.Gmail, accessToken: string, + userEmail: string, ) { const messages = await queryBatchMessagesPages(gmail, accessToken, { query: "newsletter", @@ -25,7 +26,11 @@ export async function findNewsletters( return uniq( [...messages, ...messages2] .map((message) => message.headers.from) - .filter((from) => !ignoreList.find((ignore) => from.includes(ignore))), + .filter( + (from) => + !ignoreList.find((ignore) => from.includes(ignore)) && + !from.includes(userEmail), + ), ); } diff --git a/apps/web/utils/ai/group/find-receipts.ts b/apps/web/utils/ai/group/find-receipts.ts index 272a4aacbd..11127e99ae 100644 --- a/apps/web/utils/ai/group/find-receipts.ts +++ b/apps/web/utils/ai/group/find-receipts.ts @@ -36,7 +36,11 @@ const defaultReceiptSubjects = [ ]; // Find additional receipts from the user's inbox that don't match the predefined lists -export async function findReceipts(gmail: gmail_v1.Gmail, accessToken: string) { +export async function findReceipts( + gmail: gmail_v1.Gmail, + accessToken: string, + userEmail: string, +) { const senders = await findReceiptSenders(gmail, accessToken); const subjects = await findReceiptSubjects(gmail, accessToken); @@ -49,7 +53,7 @@ export async function findReceipts(gmail: gmail_v1.Gmail, accessToken: string) { type: GroupItemType.FROM, value: sender, })), - ), + ) && !sender.includes(userEmail), ); const sendersList = uniq([...filteredSenders, ...defaultReceiptSenders]); diff --git a/apps/web/utils/queue/email-actions.ts b/apps/web/utils/queue/email-actions.ts index c188e70ecc..ca69b8a190 100644 --- a/apps/web/utils/queue/email-actions.ts +++ b/apps/web/utils/queue/email-actions.ts @@ -1,39 +1,27 @@ "use client"; import { runRulesAction } from "@/utils/actions/ai-rule"; -import type { EmailForAction } from "@/utils/ai/actions"; import { pushToAiQueueAtom, removeFromAiQueueAtom } from "@/store/ai-queue"; import type { Thread } from "@/components/email-list/types"; import { isDefined } from "@/utils/types"; import { aiQueue } from "@/utils/queue/ai-queue"; -export const runAiRules = async (threadsArray: Thread[], force: boolean) => { +export const runAiRules = async (threadsArray: Thread[], rerun: boolean) => { const threads = threadsArray.filter(isDefined); const threadIds = threads.map((t) => t.id); pushToAiQueueAtom(threadIds); aiQueue.addAll( threads.map((thread) => async () => { - const message = threadToRunRulesEmail(thread); + const message = thread.messages?.[thread.messages.length - 1]; if (!message) return; - await runRulesAction({ email: message, force }); + await runRulesAction({ + messageId: message.id, + threadId: thread.id, + rerun, + isTest: false, + }); removeFromAiQueueAtom(thread.id); }), ); }; - -function threadToRunRulesEmail(thread: Thread): EmailForAction | undefined { - const message = thread.messages?.[thread.messages.length - 1]; - if (!message) return; - const email: EmailForAction = { - from: message.headers.from, - replyTo: message.headers["reply-to"], - subject: message.headers.subject, - threadId: message.threadId || "", - messageId: message.id || "", - headerMessageId: message.headers["message-id"] || "", - references: message.headers.references, - }; - - return email; -}