diff --git a/apps/web/__tests__/helpers.ts b/apps/web/__tests__/helpers.ts index 55caeedcff..439f56ad34 100644 --- a/apps/web/__tests__/helpers.ts +++ b/apps/web/__tests__/helpers.ts @@ -50,7 +50,6 @@ export function getRule(instructions: string, actions: Action[] = []) { userId: "userId", createdAt: new Date(), updatedAt: new Date(), - automate: false, runOnThreads: false, groupId: null, from: null, diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/AddRuleDialog.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/AddRuleDialog.tsx index 4d37a085d8..34c572db10 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/AddRuleDialog.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/AddRuleDialog.tsx @@ -11,7 +11,7 @@ export function AddRuleDialog() { Add Rule - + diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/AssistantTabs.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/AssistantTabs.tsx index 2f563792b6..1b3c081192 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/AssistantTabs.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/AssistantTabs.tsx @@ -3,9 +3,7 @@ import { XIcon } from "lucide-react"; import { useCallback } from "react"; import { useQueryState } from "nuqs"; -import useSWR from "swr"; import { History } from "@/app/(app)/[emailAccountId]/assistant/History"; -import { Pending } from "@/app/(app)/[emailAccountId]/assistant/Pending"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Rules } from "@/app/(app)/[emailAccountId]/assistant/Rules"; import { Process } from "@/app/(app)/[emailAccountId]/assistant/Process"; @@ -13,15 +11,9 @@ import { RulesPrompt } from "@/app/(app)/[emailAccountId]/assistant/RulesPrompt" import { TabsToolbar } from "@/components/TabsToolbar"; import { TypographyP } from "@/components/Typography"; import { RuleTab } from "@/app/(app)/[emailAccountId]/assistant/RuleTab"; -import type { GetPendingRulesResponse } from "@/app/api/rules/pending/route"; import { Button } from "@/components/ui/button"; export function AssistantTabs() { - const { data: pendingData } = - useSWR("/api/rules/pending"); - - const hasPendingRule = pendingData?.hasPending ?? false; - return (
@@ -32,9 +24,6 @@ export function AssistantTabs() { Rules Test History - {hasPendingRule && ( - Pending - )}
@@ -61,11 +50,6 @@ export function AssistantTabs() { - {hasPendingRule && ( - - - - )} {/* Set via search params. Not a visible tab. */} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/CreatedRulesModal.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/CreatedRulesModal.tsx index 2c943316da..ba762ed5d8 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/CreatedRulesModal.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/CreatedRulesModal.tsx @@ -82,9 +82,6 @@ export function CreatedRulesContent({

{rule.name}

- {!rule.automate && ( - Requires Approval - )}
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx index 224de7582b..946d66465a 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 { PendingExecutedRules } from "@/app/api/user/planned/route"; +import type { PlanHistoryResponse } from "@/app/api/user/planned/history/route"; import { decodeSnippet } from "@/utils/gmail/decode"; import { ActionBadgeExpanded } from "@/components/PlanBadge"; import { Tooltip } from "@/components/Tooltip"; @@ -68,7 +68,7 @@ export function RuleCell({ message, setInput, }: { - rule: PendingExecutedRules["executedRules"][number]["rule"]; + rule: PlanHistoryResponse["executedRules"][number]["rule"]; status: ExecutedRuleStatus; reason?: string | null; message: ParsedMessage; @@ -147,7 +147,7 @@ export function ActionItemsCell({ actionItems, provider, }: { - actionItems: PendingExecutedRules["executedRules"][number]["actionItems"]; + actionItems: PlanHistoryResponse["executedRules"][number]["actionItems"]; provider: string; }) { return ( diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/Pending.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/Pending.tsx deleted file mode 100644 index 9ecfed0162..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/assistant/Pending.tsx +++ /dev/null @@ -1,275 +0,0 @@ -"use client"; - -import { useCallback, useState } from "react"; -import { useQueryState, parseAsInteger, parseAsString } from "nuqs"; -import useSWR from "swr"; -import { Loader2Icon } from "lucide-react"; -import { LoadingContent } from "@/components/LoadingContent"; -import type { PendingExecutedRules } from "@/app/api/user/planned/route"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Card } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { AlertBasic } from "@/components/Alert"; -import { approvePlanAction, rejectPlanAction } from "@/utils/actions/ai-rule"; -import { toastError } from "@/components/Toast"; -import type { ParsedMessage } from "@/utils/types"; -import { - ActionItemsCell, - EmailCell, - RuleCell, -} from "@/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable"; -import { TablePagination } from "@/components/TablePagination"; -import { Checkbox } from "@/components/Checkbox"; -import { useToggleSelect } from "@/hooks/useToggleSelect"; -import { RulesSelect } from "@/app/(app)/[emailAccountId]/assistant/RulesSelect"; -import { useAccount } from "@/providers/EmailAccountProvider"; -import { useChat } from "@/providers/ChatProvider"; - -export function Pending() { - const [page] = useQueryState("page", parseAsInteger.withDefault(1)); - const [ruleId] = useQueryState("ruleId", parseAsString.withDefault("all")); - - const { data, isLoading, error, mutate } = useSWR( - `/api/user/planned?page=${page}&ruleId=${ruleId}`, - ); - - return ( - <> - - - - {data?.executedRules.length ? ( - - ) : ( - - )} - - - - ); -} - -function PendingTable({ - pending, - totalPages, - mutate, -}: { - pending: PendingExecutedRules["executedRules"]; - totalPages: number; - mutate: () => void; -}) { - const { emailAccountId, userEmail, provider } = useAccount(); - const { selected, isAllSelected, onToggleSelect, onToggleSelectAll } = - useToggleSelect(pending); - - const [isApproving, setIsApproving] = useState(false); - const [isRejecting, setIsRejecting] = useState(false); - - const approveSelected = useCallback(async () => { - setIsApproving(true); - for (const id of Array.from(selected.keys())) { - const p = pending.find((p) => p.id === id); - if (!p) continue; - const result = await approvePlanAction(emailAccountId, { - executedRuleId: id, - message: p.message, - }); - if (result?.serverError) { - toastError({ - description: `Unable to execute plan. ${result.serverError}` || "", - }); - } - mutate(); - } - setIsApproving(false); - }, [selected, pending, mutate, emailAccountId]); - const rejectSelected = useCallback(async () => { - setIsRejecting(true); - for (const id of Array.from(selected.keys())) { - const p = pending.find((p) => p.id === id); - if (!p) continue; - const result = await rejectPlanAction(emailAccountId, { - executedRuleId: id, - }); - if (result?.serverError) { - toastError({ - description: `Error rejecting action. ${result.serverError}` || "", - }); - } - mutate(); - } - setIsRejecting(false); - }, [selected, pending, mutate, emailAccountId]); - - const { setInput } = useChat(); - - return ( -
- {Array.from(selected.values()).filter(Boolean).length > 0 && ( -
-
- -
-
- -
-
- )} - - - - - - - - Email - Rule - Actions - - {/* */} - - - - {pending.map((p) => ( - - - {(isApproving || isRejecting) && selected.get(p.id) ? ( - - ) : ( - onToggleSelect(p.id)} - /> - )} - - - - - - - - - - - - - - {/* - - */} - - ))} - -
- - -
- ); -} - -function ExecuteButtons({ - id, - message, - mutate, -}: { - id: string; - message: ParsedMessage; - mutate: () => void; -}) { - const [isApproving, setIsApproving] = useState(false); - const [isRejecting, setIsRejecting] = useState(false); - const { emailAccountId } = useAccount(); - - return ( -
- - -
- ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/RuleDialog.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/RuleDialog.tsx index 915f1676e2..ecee2bfd7f 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/RuleDialog.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/RuleDialog.tsx @@ -90,7 +90,6 @@ export function RuleDialog({ type: ActionType.LABEL, }, ], - automate: true, runOnThreads: true, conditionalOperator: LogicalOperator.AND, ...initialRule, diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx index 8891fdd34c..72d1e00336 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx @@ -268,7 +268,6 @@ export function RuleForm({ posthog.capture("User updated AI rule", { conditions: data.conditions.map((condition) => condition.type), actions: actionsToSubmit.map((action) => action.type), - automate: data.automate, runOnThreads: data.runOnThreads, digest: data.digest, }); @@ -296,7 +295,6 @@ export function RuleForm({ posthog.capture("User created AI rule", { conditions: data.conditions.map((condition) => condition.type), actions: actionsToSubmit.map((action) => action.type), - automate: data.automate, runOnThreads: data.runOnThreads, digest: data.digest, }); @@ -896,23 +894,6 @@ export function RuleForm({
Settings -
- { - setValue("automate", enabled); - }} - /> - - -
-
)} {rule.name} - {!rule.automate && ( - - - Requires Approval - - - - )} {size === "md" && ( diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/create/examples.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/create/examples.tsx index e19127fc8a..1d159e2dad 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/create/examples.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/create/examples.tsx @@ -115,7 +115,6 @@ export const examples: { }, }, ], - automate: true, runOnThreads: true, }, }, @@ -137,7 +136,6 @@ export const examples: { content: { value: "Pitch" }, }, ], - automate: true, runOnThreads: true, }, }, diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/rule/create/page.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/rule/create/page.tsx index 59374fb7d5..272ddc47ad 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/rule/create/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/rule/create/page.tsx @@ -35,7 +35,6 @@ export default async function CreateRulePage(props: { conditions: searchParams.type ? [getEmptyCondition(searchParams.type, searchParams.categoryId)] : [], - automate: true, runOnThreads: true, } } diff --git a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx index 3b2f229c90..ec98251c50 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx @@ -4,7 +4,6 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import prisma from "@/utils/prisma"; import { History } from "@/app/(app)/[emailAccountId]/assistant/History"; -import { Pending } from "@/app/(app)/[emailAccountId]/assistant/Pending"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Process } from "@/app/(app)/[emailAccountId]/assistant/Process"; import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; @@ -72,13 +71,6 @@ export default async function AutomationPage({ } } - const hasPendingRule = prisma.rule - .findFirst({ - where: { emailAccountId, automate: false }, - select: { id: true }, - }) - .then((rule) => rule !== null); - return ( @@ -105,20 +97,10 @@ export default async function AutomationPage({
- - } - > - - +
- - - ); } - -async function TabNavigation({ - emailAccountId, - tab, - hasPendingRule, -}: { - emailAccountId: string; - tab: string; - hasPendingRule: Promise; -}) { - return ( - - ); -} - -async function PendingTab({ - hasPendingRule, -}: { - hasPendingRule: Promise; -}) { - const hasPendingRuleValue = await hasPendingRule; - - if (!hasPendingRuleValue) return null; - - return ( - - - - ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/debug/rule-history/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/rule-history/page.tsx index 6782c0cd22..efc818e5f5 100644 --- a/apps/web/app/(app)/[emailAccountId]/debug/rule-history/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/debug/rule-history/page.tsx @@ -62,9 +62,6 @@ export default function RuleHistorySelectPage() { {rule.systemType} )} {!rule.enabled && Disabled} - {rule.automate && ( - Automated - )}
{rule.instructions && ( diff --git a/apps/web/app/api/resend/summary/route.ts b/apps/web/app/api/resend/summary/route.ts index 03a866d704..4398109594 100644 --- a/apps/web/app/api/resend/summary/route.ts +++ b/apps/web/app/api/resend/summary/route.ts @@ -7,7 +7,6 @@ import { env } from "@/env"; import { hasCronSecret } from "@/utils/cron"; import { captureException } from "@/utils/error"; import prisma from "@/utils/prisma"; -import { ExecutedRuleStatus } from "@prisma/client"; import { ThreadTrackerType } from "@prisma/client"; import { createScopedLogger } from "@/utils/logger"; import { getMessagesBatch } from "@/utils/gmail/message"; @@ -63,16 +62,6 @@ async function sendEmail({ select: { email: true, coldEmails: { where: { createdAt: { gt: cutOffDate } } }, - _count: { - select: { - executedRules: { - where: { - status: ExecutedRuleStatus.PENDING, - createdAt: { gt: cutOffDate }, - }, - }, - }, - }, account: { select: { access_token: true, @@ -159,7 +148,6 @@ async function sendEmail({ subject: "", sentAt: e.createdAt, })); - const pendingCount = emailAccount._count.executedRules; // get messages const messageIds = [ @@ -213,7 +201,6 @@ async function sendEmail({ const shouldSendEmail = !!( coldEmailers.length || - pendingCount || typeCounts[ThreadTrackerType.NEEDS_REPLY] || typeCounts[ThreadTrackerType.AWAITING] || typeCounts[ThreadTrackerType.NEEDS_ACTION] @@ -223,7 +210,6 @@ async function sendEmail({ ...loggerOptions, shouldSendEmail, coldEmailers: coldEmailers.length, - pendingCount, needsReplyCount: typeCounts[ThreadTrackerType.NEEDS_REPLY], awaitingReplyCount: typeCounts[ThreadTrackerType.AWAITING], needsActionCount: typeCounts[ThreadTrackerType.NEEDS_ACTION], @@ -244,7 +230,6 @@ async function sendEmail({ emailProps: { baseUrl: env.NEXT_PUBLIC_BASE_URL, coldEmailers, - pendingCount, needsReplyCount: typeCounts[ThreadTrackerType.NEEDS_REPLY], awaitingReplyCount: typeCounts[ThreadTrackerType.AWAITING], needsActionCount: typeCounts[ThreadTrackerType.NEEDS_ACTION], diff --git a/apps/web/app/api/rules/pending/route.ts b/apps/web/app/api/rules/pending/route.ts deleted file mode 100644 index 5a7266a5be..0000000000 --- a/apps/web/app/api/rules/pending/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextResponse } from "next/server"; -import prisma from "@/utils/prisma"; -import { withEmailAccount } from "@/utils/middleware"; -import type { RequestWithEmailAccount } from "@/utils/middleware"; - -export type GetPendingRulesResponse = Awaited< - ReturnType ->; - -export const GET = withEmailAccount(async (req: RequestWithEmailAccount) => { - const { emailAccountId } = req.auth; - const data = await getPendingRules({ emailAccountId }); - return NextResponse.json(data); -}); - -async function getPendingRules({ emailAccountId }: { emailAccountId: string }) { - const rule = await prisma.rule.findFirst({ - where: { emailAccountId, automate: false }, - select: { id: true }, - }); - - return { hasPending: Boolean(rule) }; -} diff --git a/apps/web/app/api/threads/route.ts b/apps/web/app/api/threads/route.ts index ffc5e4c4ed..e3a6a15c7a 100644 --- a/apps/web/app/api/threads/route.ts +++ b/apps/web/app/api/threads/route.ts @@ -76,15 +76,11 @@ async function getThreads({ pageToken: query.nextPageToken || undefined, }); - // Get executed rules for these threads const threadIds = threads.map((t) => t.id); const plans = await prisma.executedRule.findMany({ where: { emailAccountId, threadId: { in: threadIds }, - status: { - in: [ExecutedRuleStatus.PENDING, ExecutedRuleStatus.SKIPPED], - }, }, select: { id: true, diff --git a/apps/web/app/api/user/planned/route.ts b/apps/web/app/api/user/planned/route.ts deleted file mode 100644 index 0ec7ddef6c..0000000000 --- a/apps/web/app/api/user/planned/route.ts +++ /dev/null @@ -1,27 +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 const maxDuration = 30; // TODO not great if this is taking more than 15s - -export type PendingExecutedRules = 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.PENDING, - page, - ruleId, - emailAccountId, - emailProvider: request.emailProvider, - }); - - return NextResponse.json(messages); -}); diff --git a/apps/web/components/ActionButtonsBulk.tsx b/apps/web/components/ActionButtonsBulk.tsx index acc5faf21f..75ecfa4bbd 100644 --- a/apps/web/components/ActionButtonsBulk.tsx +++ b/apps/web/components/ActionButtonsBulk.tsx @@ -1,37 +1,23 @@ import { useMemo } from "react"; import { ButtonGroup } from "@/components/ButtonGroup"; import { LoadingMiniSpinner } from "@/components/Loading"; -import { - ArchiveIcon, - CheckCircleIcon, - SparklesIcon, - Trash2Icon, - XCircleIcon, -} from "lucide-react"; +import { ArchiveIcon, SparklesIcon, Trash2Icon } from "lucide-react"; export function ActionButtonsBulk(props: { isPlanning: boolean; isArchiving: boolean; isDeleting: boolean; - isApproving: boolean; - isRejecting: boolean; onPlanAiAction: () => void; onArchive: () => void; onDelete: () => void; - onApprove: () => void; - onReject: () => void; }) { const { isPlanning, isArchiving, isDeleting, - isApproving, - isRejecting, onPlanAiAction, onArchive, onDelete, - onApprove, - onReject, } = props; const buttons = useMemo( @@ -45,27 +31,6 @@ export function ActionButtonsBulk(props: {
{/*
@@ -487,10 +460,6 @@ export function EmailList({ onClick={onOpen} onPlanAiAction={onPlanAiAction} onArchive={onArchive} - executePlan={executePlan} - rejectPlan={rejectPlan} - executingPlan={executingPlan[thread.id]} - rejectingPlan={rejectingPlan[thread.id]} refetch={refetch} /> ); @@ -525,10 +494,6 @@ export function EmailList({ onArchive={onArchive} advanceToAdjacentThread={advanceToAdjacentThread} close={closePanel} - executePlan={executePlan} - rejectPlan={rejectPlan} - executingPlan={executingPlan[openThreadId]} - rejectingPlan={rejectingPlan[openThreadId]} refetch={refetch} /> ) diff --git a/apps/web/components/email-list/EmailListItem.tsx b/apps/web/components/email-list/EmailListItem.tsx index 73f1c364a8..f55d3fe441 100644 --- a/apps/web/components/email-list/EmailListItem.tsx +++ b/apps/web/components/email-list/EmailListItem.tsx @@ -10,7 +10,6 @@ import clsx from "clsx"; import { ActionButtons } from "@/components/ActionButtons"; import { PlanBadge } from "@/components/PlanBadge"; import type { Thread } from "@/components/email-list/types"; -import { PlanActions } from "@/components/email-list/PlanActions"; import { extractNameFromEmail, participant } from "@/utils/email"; import { CategoryBadge } from "@/components/CategoryBadge"; import { Checkbox } from "@/components/Checkbox"; @@ -36,11 +35,6 @@ export const EmailListItem = forwardRef( onSelected: (id: string) => void; onPlanAiAction: (thread: Thread) => void; onArchive: (thread: Thread) => void; - executingPlan: boolean; - rejectingPlan: boolean; - executePlan: (thread: Thread) => Promise; - rejectPlan: (thread: Thread) => Promise; - refetch: () => void; }, ref: ForwardedRef, @@ -179,14 +173,6 @@ export const EmailListItem = forwardRef( ) : null} - -
)} diff --git a/apps/web/components/email-list/EmailPanel.tsx b/apps/web/components/email-list/EmailPanel.tsx index 8dd2010d8b..d13cd978a6 100644 --- a/apps/web/components/email-list/EmailPanel.tsx +++ b/apps/web/components/email-list/EmailPanel.tsx @@ -14,10 +14,6 @@ export function EmailPanel({ onArchive, advanceToAdjacentThread, close, - executingPlan, - rejectingPlan, - executePlan, - rejectPlan, refetch, }: { row: Thread; @@ -25,10 +21,6 @@ export function EmailPanel({ onArchive: (thread: Thread) => void; advanceToAdjacentThread: () => void; close: () => void; - executingPlan: boolean; - rejectingPlan: boolean; - executePlan: (thread: Thread) => Promise; - rejectPlan: (thread: Thread) => Promise; refetch: () => void; }) { const { provider } = useAccount(); @@ -73,16 +65,7 @@ export function EmailPanel({
- {plan?.rule && ( - - )} + {plan?.rule && } void) { - const [executingPlan, setExecutingPlan] = useState({}); - const [rejectingPlan, setRejectingPlan] = useState({}); - const { emailAccountId } = useAccount(); - - const executePlan = useCallback( - async (thread: Thread) => { - if (!thread.plan?.rule) return; - - setExecutingPlan((s) => ({ ...s, [thread.id!]: true })); - - const lastMessage = thread.messages?.[thread.messages.length - 1]; - - const result = await approvePlanAction(emailAccountId, { - executedRuleId: thread.plan.id, - message: lastMessage, - }); - if (result?.serverError) { - toastError({ - description: `Unable to execute plan. ${result.serverError || ""}`, - }); - } else { - toastSuccess({ description: "Executed!" }); - } - - refetch(); - - setExecutingPlan((s) => ({ ...s, [thread.id!]: false })); - }, - [refetch, emailAccountId], - ); - - const rejectPlan = useCallback( - async (thread: Thread) => { - setRejectingPlan((s) => ({ ...s, [thread.id!]: true })); - - if (thread.plan?.id) { - const result = await rejectPlanAction(emailAccountId, { - executedRuleId: thread.plan.id, - }); - if (result?.serverError) { - toastError({ - description: `Error rejecting plan. ${result.serverError || ""}`, - }); - } else { - toastSuccess({ description: "Plan rejected" }); - } - } else { - toastError({ description: "Plan not found" }); - } - - refetch(); - - setRejectingPlan((s) => ({ ...s, [thread.id!]: false })); - }, - [refetch, emailAccountId], - ); - - return { - executingPlan, - rejectingPlan, - executePlan, - rejectPlan, - }; -} - -export function PlanActions(props: { - thread: Thread; - executingPlan: boolean; - rejectingPlan: boolean; - executePlan: (thread: Thread) => Promise; - rejectPlan: (thread: Thread) => Promise; - className?: string; -}) { - const { thread, executePlan, rejectPlan } = props; - - const execute = useCallback( - async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - executePlan(thread); - }, - [executePlan, thread], - ); - const reject = useCallback( - async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - rejectPlan(thread); - }, - [rejectPlan, thread], - ); - - if (!thread.plan?.rule) return null; - if (thread.plan?.status === "APPLIED" || thread.plan?.status === "REJECTED") - return null; - - return ( -
- {props.executingPlan ? ( - - ) : ( - - - - )} - - {props.rejectingPlan ? ( - - ) : ( - - - - )} -
- ); -} diff --git a/apps/web/components/email-list/PlanExplanation.tsx b/apps/web/components/email-list/PlanExplanation.tsx index 9bd5215936..51a4ecce2e 100644 --- a/apps/web/components/email-list/PlanExplanation.tsx +++ b/apps/web/components/email-list/PlanExplanation.tsx @@ -1,18 +1,10 @@ import { capitalCase } from "capital-case"; import { Badge } from "@/components/Badge"; import type { Thread } from "@/components/email-list/types"; -import { PlanActions } from "@/components/email-list/PlanActions"; import { PlanBadge, getActionColor } from "@/components/PlanBadge"; import { getActionFields } from "@/utils/action-item"; -export function PlanExplanation(props: { - provider: string; - thread: Thread; - executingPlan: boolean; - rejectingPlan: boolean; - executePlan: (thread: Thread) => Promise; - rejectPlan: (thread: Thread) => Promise; -}) { +export function PlanExplanation(props: { provider: string; thread: Thread }) { const { provider, thread } = props; if (!thread) return null; const { plan } = thread; @@ -50,16 +42,6 @@ export function PlanExplanation(props: { ); })}
- -
- -
); } diff --git a/apps/web/prisma/migrations/20251001203533_convert_automate_false_to_disabled/migration.sql b/apps/web/prisma/migrations/20251001203533_convert_automate_false_to_disabled/migration.sql new file mode 100644 index 0000000000..f600d2ef73 --- /dev/null +++ b/apps/web/prisma/migrations/20251001203533_convert_automate_false_to_disabled/migration.sql @@ -0,0 +1,25 @@ +-- ======================================== +-- Migration: Convert automate=false to disabled rules +-- ======================================== +-- This migration removes the pending tasks/approval feature. +-- All rules are now fully automated in the application logic. +-- +-- Changes: +-- 1. Disable all rules that had automate=false +-- 2. Mark any pending/rejected ExecutedRules as SKIPPED +-- +-- Note: We're keeping the 'automate' field in the database for now +-- but it's no longer used by the application. +-- ======================================== + +-- Step 1: Disable rules that required manual approval (automate=false) +-- These rules are converted to disabled since manual approval is no longer supported +UPDATE "Rule" +SET enabled = false +WHERE automate = false; + +-- Step 2: Clean up any pending or rejected ExecutedRules +-- Mark them as SKIPPED since they'll never be approved/rejected now +UPDATE "ExecutedRule" +SET status = 'SKIPPED' +WHERE status IN ('PENDING', 'REJECTED'); diff --git a/apps/web/prisma/migrations/20251003000636_default_rule_automate/migration.sql b/apps/web/prisma/migrations/20251003000636_default_rule_automate/migration.sql new file mode 100644 index 0000000000..9808933d70 --- /dev/null +++ b/apps/web/prisma/migrations/20251003000636_default_rule_automate/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Rule" ALTER COLUMN "automate" SET DEFAULT true; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 32487bb89a..fb485bb347 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -110,7 +110,7 @@ model EmailAccount { about String? writingStyle String? - signature String? + signature String? // User's email signature from provider or manually set includeReferralSignature Boolean @default(true) watchEmailsExpirationDate DateTime? watchEmailsSubscriptionId String? // For Outlook subscription ID @@ -375,7 +375,7 @@ model Rule { name String actions Action[] enabled Boolean @default(true) - automate Boolean @default(false) // if disabled, user must approve to execute + automate Boolean @default(true) // @deprecated - No longer used. All rules are now automated. Kept for historical data only. runOnThreads Boolean @default(false) // if disabled, only runs on individual emails emailAccountId String @@ -1021,8 +1021,8 @@ enum PremiumTier { enum ExecutedRuleStatus { APPLIED APPLYING - REJECTED - PENDING + REJECTED // @deprecated - No longer created. Kept for historical data only. + PENDING // @deprecated - No longer created. Kept for historical data only. SKIPPED ERROR } diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index ef68d28f30..0aedfe58bd 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -3,14 +3,12 @@ import { z } from "zod"; import prisma from "@/utils/prisma"; import { isNotFoundError } from "@/utils/prisma-helpers"; -import { ExecutedRuleStatus } from "@prisma/client"; import { aiCreateRule } from "@/utils/ai/rule/create-rule"; import { runRules, type RunRulesResult, } from "@/utils/ai/choose-rule/run-rules"; import { emailToContent } from "@/utils/mail"; -import { executeAct } from "@/utils/ai/choose-rule/execute"; import { createAutomationBody, runRulesBody, @@ -205,48 +203,6 @@ export const setRuleRunOnThreadsAction = actionClient }, ); -export const approvePlanAction = actionClient - .metadata({ name: "approvePlan" }) - .schema(z.object({ executedRuleId: z.string(), message: z.any() })) - .action( - async ({ - ctx: { emailAccountId, emailAccount, provider, userId }, - parsedInput: { executedRuleId, message }, - }) => { - const emailProvider = await createEmailProvider({ - emailAccountId, - provider, - }); - - const executedRule = await prisma.executedRule.findUnique({ - where: { id: executedRuleId }, - include: { actionItems: true }, - }); - if (!executedRule) throw new SafeError("Plan not found"); - - await executeAct({ - client: emailProvider, - message, - executedRule, - userEmail: emailAccount.email, - userId, - emailAccountId, - }); - }, - ); - -export const rejectPlanAction = actionClient - .metadata({ name: "rejectPlan" }) - .schema(z.object({ executedRuleId: z.string() })) - .action( - async ({ ctx: { emailAccountId }, parsedInput: { executedRuleId } }) => { - await prisma.executedRule.updateMany({ - where: { id: executedRuleId, emailAccountId }, - data: { status: ExecutedRuleStatus.REJECTED }, - }); - }, - ); - /** * Saves the user's rules prompt and updates the rules accordingly. * Flow: diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index 4f36adfb9a..08f3d430e3 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -119,7 +119,6 @@ export const createRuleAction = actionClient ctx: { emailAccountId, logger }, parsedInput: { name, - automate, runOnThreads, actions, conditions: conditionsInput, @@ -133,7 +132,6 @@ export const createRuleAction = actionClient const rule = await prisma.rule.create({ data: { name, - automate: automate ?? undefined, runOnThreads: runOnThreads ?? undefined, actions: actions ? { @@ -219,7 +217,6 @@ export const updateRuleAction = actionClient parsedInput: { id, name, - automate, runOnThreads, actions, conditions: conditionsInput, @@ -249,7 +246,6 @@ export const updateRuleAction = actionClient prisma.rule.update({ where: { id, emailAccountId }, data: { - automate: automate ?? undefined, runOnThreads: runOnThreads ?? undefined, name: name || undefined, conditionalOperator: conditionalOperator || LogicalOperator.AND, @@ -606,7 +602,6 @@ export const createRulesOnboardingAction = actionClient name, instructions, systemType: systemType ?? undefined, - automate: true, runOnThreads, actions: { createMany: { data: actions }, diff --git a/apps/web/utils/actions/rule.validation.ts b/apps/web/utils/actions/rule.validation.ts index 27ad9d6da5..c332d57cc7 100644 --- a/apps/web/utils/actions/rule.validation.ts +++ b/apps/web/utils/actions/rule.validation.ts @@ -125,7 +125,6 @@ export const createRuleBody = z.object({ name: z.string().min(1, "Please enter a name"), instructions: z.string().nullish(), groupId: z.string().nullish(), - automate: z.boolean().nullish(), runOnThreads: z.boolean().nullish(), digest: z.boolean().nullish(), actions: z.array(zodAction).min(1, "You must have at least one action"), diff --git a/apps/web/utils/ai/assistant/chat.ts b/apps/web/utils/ai/assistant/chat.ts index 30eca15051..2939aa2c20 100644 --- a/apps/web/utils/ai/assistant/chat.ts +++ b/apps/web/utils/ai/assistant/chat.ts @@ -55,7 +55,6 @@ const getUserRulesAndSettingsTool = ({ subject: true, conditionalOperator: true, enabled: true, - automate: true, runOnThreads: true, actions: { select: { @@ -113,7 +112,6 @@ const getUserRulesAndSettingsTool = ({ }), })), enabled: rule.enabled, - automate: rule.automate, runOnThreads: rule.runOnThreads, }; }), diff --git a/apps/web/utils/ai/choose-rule/execute.ts b/apps/web/utils/ai/choose-rule/execute.ts index c34b1c7553..95e98667c0 100644 --- a/apps/web/utils/ai/choose-rule/execute.ts +++ b/apps/web/utils/ai/choose-rule/execute.ts @@ -11,14 +11,6 @@ type ExecutedRuleWithActionItems = Prisma.ExecutedRuleGetPayload<{ include: { actionItems: true }; }>; -/** - * Executes actions for a rule that has been applied to an email message. - * This function: - * 1. Updates the executed rule status from PENDING to APPLYING - * 2. Processes each action item associated with the rule - * 3. Handles reply tracking if this is a reply tracking rule - * 4. Updates the rule status to APPLIED when complete - */ export async function executeAct({ client, executedRule, @@ -42,16 +34,6 @@ export async function executeAct({ messageId: executedRule.messageId, }); - const pendingRules = await prisma.executedRule.updateMany({ - where: { id: executedRule.id, status: ExecutedRuleStatus.PENDING }, - data: { status: ExecutedRuleStatus.APPLYING }, - }); - - if (pendingRules.count === 0) { - logger.info("Executed rule is not pending or does not exist"); - return; - } - for (const action of executedRule.actionItems) { try { const actionResult = await runActionFunction({ diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index 22f8f7c4c0..0c61b036c4 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -157,10 +157,8 @@ async function executeMatchedRule( }); } - const shouldExecute = - executedRule && rule.automate && immediateActions?.length > 0; - - if (shouldExecute) { + // Execute immediate actions if any + if (executedRule && immediateActions?.length > 0) { await executeAct({ client, userEmail: emailAccount.email, @@ -169,7 +167,15 @@ async function executeMatchedRule( executedRule, message, }); + } else if (executedRule && !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, @@ -241,8 +247,8 @@ async function saveExecutedRule( }, messageId, threadId, - automated: !!rule?.automate, - status: ExecutedRuleStatus.PENDING, + 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 } }, diff --git a/apps/web/utils/risk.test.ts b/apps/web/utils/risk.test.ts index c98d6e0307..bcdeb52345 100644 --- a/apps/web/utils/risk.test.ts +++ b/apps/web/utils/risk.test.ts @@ -16,7 +16,7 @@ vi.mock("server-only", () => ({})); describe("getActionRiskLevel", () => { const testCases = [ { - name: "returns very-high risk for fully dynamic content and recipient with automation", + name: "returns very-high risk for fully dynamic content and recipient with AI rule", action: { subject: "{{dynamic}}", content: "{{dynamic}}", @@ -25,13 +25,14 @@ describe("getActionRiskLevel", () => { bcc: "", type: ActionType.REPLY, }, - hasAutomation: true, - instructions: "String", + rule: { + instructions: "AI generated response", + }, expectedLevel: "very-high", expectedMessageContains: "Very High Risk", }, { - name: "returns high risk for fully dynamic recipient with automation", + name: "returns high risk for fully dynamic recipient with non-AI rule", action: { subject: "", content: "", @@ -40,13 +41,12 @@ describe("getActionRiskLevel", () => { bcc: "", type: ActionType.REPLY, }, - hasAutomation: true, - instructions: "String", + rule: {}, expectedLevel: "high", expectedMessageContains: "High Risk", }, { - name: "returns medium risk for partially dynamic content with automation", + name: "returns medium risk for partially dynamic content", action: { subject: "Hello {{name}}", content: "How are you {{name}}?", @@ -55,8 +55,7 @@ describe("getActionRiskLevel", () => { bcc: "", type: ActionType.REPLY, }, - hasAutomation: true, - instructions: "String", + rule: {}, expectedLevel: "medium", expectedMessageContains: "Medium Risk", }, @@ -70,13 +69,12 @@ describe("getActionRiskLevel", () => { bcc: "", type: ActionType.REPLY, }, - hasAutomation: false, - instructions: "String", + rule: {}, expectedLevel: "low", expectedMessageContains: "Low Risk", }, { - name: "returns medium risk for dynamic recipient without automation", + name: "returns high risk for dynamic recipient (all actions are automated)", action: { subject: "Static Subject", content: "Static Content", @@ -85,13 +83,12 @@ describe("getActionRiskLevel", () => { bcc: "", type: ActionType.REPLY, }, - hasAutomation: false, - instructions: "String", - expectedLevel: "low", - expectedMessageContains: "Low Risk", + rule: {}, + expectedLevel: "high", + expectedMessageContains: "High Risk", }, { - name: "returns medium risk for dynamic cc/bcc without automation", + name: "returns high risk for fully dynamic cc/bcc", action: { subject: "Static Subject", content: "Static Content", @@ -100,26 +97,30 @@ describe("getActionRiskLevel", () => { bcc: "", type: ActionType.REPLY, }, - hasAutomation: false, - instructions: "String", - expectedLevel: "low", - expectedMessageContains: "Low Risk", + rule: {}, + expectedLevel: "high", + expectedMessageContains: "High Risk", + }, + { + name: "returns medium risk for partially dynamic cc/bcc", + action: { + subject: "Static Subject", + content: "Static Content", + to: "static@example.com", + cc: "team-{{name}}@example.com", + bcc: "", + type: ActionType.REPLY, + }, + rule: {}, + expectedLevel: "medium", + expectedMessageContains: "Medium Risk", }, ]; testCases.forEach( - ({ - name, - action, - hasAutomation, - instructions, - expectedLevel, - expectedMessageContains, - }) => { + ({ name, action, rule, expectedLevel, expectedMessageContains }) => { it(name, () => { - const result = getActionRiskLevel(action, hasAutomation, { - instructions, - }); + const result = getActionRiskLevel(action, rule); expect(result.level).toBe(expectedLevel); expect(result.message).toContain(expectedMessageContains); }); @@ -150,7 +151,6 @@ describe("getRiskLevel", () => { type: ActionType.REPLY, }, ], - automate: true, instructions: "String", } as RulesResponse[number], expectedLevel: "high", @@ -177,7 +177,6 @@ describe("getRiskLevel", () => { type: ActionType.REPLY, }, ], - automate: true, instructions: "String", } as RulesResponse[number], expectedLevel: "high", @@ -204,8 +203,6 @@ describe("getRiskLevel", () => { type: ActionType.REPLY, }, ], - automate: false, - instructions: "String", } as RulesResponse[number], expectedLevel: "low", expectedMessageContains: "Low Risk", diff --git a/apps/web/utils/risk.ts b/apps/web/utils/risk.ts index e8c86d2149..1a450f20f0 100644 --- a/apps/web/utils/risk.ts +++ b/apps/web/utils/risk.ts @@ -23,7 +23,6 @@ export type RiskAction = { export function getActionRiskLevel( action: RiskAction, - isAutomated: boolean, rule: RuleConditions, ): { level: RiskLevel; @@ -37,8 +36,7 @@ export function getActionRiskLevel( if (!highRiskActions.some((type) => type === action.type)) { return { level: RISK_LEVELS.LOW, - message: - "Low Risk: No email sending action is performed without your review.", + message: "Low Risk: No email sending action is performed.", }; } @@ -65,38 +63,37 @@ export function getActionRiskLevel( "partially-dynamic", ); - if (isAutomated) { - if (hasFullyDynamicContent && hasFullyDynamicRecipient) { - const level = isAIRule(rule) ? RISK_LEVELS.VERY_HIGH : RISK_LEVELS.HIGH; - return { - level, - message: `${level === RISK_LEVELS.VERY_HIGH ? "Very High" : "High"} Risk: The AI can generate any content and send it to any address. A malicious actor could trick the AI to send spam or other unwanted emails on your behalf.`, - }; - } - - if (hasFullyDynamicRecipient) { - return { - level: RISK_LEVELS.HIGH, - message: - "High Risk: The AI can send emails to any address. A malicious actor could use this to send spam or other unwanted emails on your behalf.", - }; - } - - if (hasFullyDynamicContent) { - return { - level: RISK_LEVELS.HIGH, - message: - "High Risk: The AI can automatically generate and send any email content. A malicious actor could potentially trick the AI into generating unwanted or inappropriate content.", - }; - } - - if (hasPartiallyDynamicContent || hasPartiallyDynamicRecipient) { - return { - level: RISK_LEVELS.MEDIUM, - message: - "Medium Risk: The AI can generate content or recipients using templates. While more constrained than fully dynamic content, review the templates carefully.", - }; - } + // All rules are now automated, so we always check for dynamic content risks + if (hasFullyDynamicContent && hasFullyDynamicRecipient) { + const level = isAIRule(rule) ? RISK_LEVELS.VERY_HIGH : RISK_LEVELS.HIGH; + return { + level, + message: `${level === RISK_LEVELS.VERY_HIGH ? "Very High" : "High"} Risk: The AI can generate any content and send it to any address. A malicious actor could trick the AI to send spam or other unwanted emails on your behalf.`, + }; + } + + if (hasFullyDynamicRecipient) { + return { + level: RISK_LEVELS.HIGH, + message: + "High Risk: The AI can send emails to any address. A malicious actor could use this to send spam or other unwanted emails on your behalf.", + }; + } + + if (hasFullyDynamicContent) { + return { + level: RISK_LEVELS.HIGH, + message: + "High Risk: The AI can automatically generate and send any email content. A malicious actor could potentially trick the AI into generating unwanted or inappropriate content.", + }; + } + + if (hasPartiallyDynamicContent || hasPartiallyDynamicRecipient) { + return { + level: RISK_LEVELS.MEDIUM, + message: + "Medium Risk: The AI can generate content or recipients using templates. While more constrained than fully dynamic content, review the templates carefully.", + }; } return { @@ -123,7 +120,7 @@ function compareRiskLevels(a: RiskLevel, b: RiskLevel): RiskLevel { } export function getRiskLevel( - rule: Pick & RuleConditions, + rule: Pick & RuleConditions, ): { level: RiskLevel; message: string; @@ -131,7 +128,7 @@ export function getRiskLevel( // Get risk level for each action and return the highest risk return rule.actions.reduce<{ level: RiskLevel; message: string }>( (highestRisk, action) => { - const actionRisk = getActionRiskLevel(action, rule.automate, rule); + const actionRisk = getActionRiskLevel(action, rule); if ( compareRiskLevels(actionRisk.level, highestRisk.level) === actionRisk.level diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index ac1558f852..593089d6dc 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -172,7 +172,7 @@ export async function createRule({ emailAccountId, systemType, actions: { createMany: { data: mappedActions } }, - automate: shouldAutomate( + enabled: shouldEnable( result, mappedActions.map((a) => ({ type: a.type, @@ -294,8 +294,7 @@ export async function deleteRule({ ]); } -// TODO: in cases that we don't automate we should really let the user know in the UI so that they can turn it on themselves -function shouldAutomate( +function shouldEnable( rule: CreateOrUpdateRuleSchemaWithCategories, actions: RiskAction[], ) { @@ -317,10 +316,9 @@ function shouldAutomate( return false; const riskLevels = actions.map( - (action) => getActionRiskLevel(action, false, {}).level, + (action) => getActionRiskLevel(action, {}).level, ); - // Only automate if all actions are low risk - // User can manually enable in other cases + // Only enable if all actions are low risk return riskLevels.every((level) => level === "low"); } diff --git a/packages/resend/emails/summary.tsx b/packages/resend/emails/summary.tsx index cce65f9716..107695b4b3 100644 --- a/packages/resend/emails/summary.tsx +++ b/packages/resend/emails/summary.tsx @@ -23,7 +23,6 @@ type EmailItem = { export interface SummaryEmailProps { baseUrl: string; - pendingCount: number; coldEmailers: EmailItem[]; // Reply tracker stats needsReplyCount?: number; @@ -39,7 +38,6 @@ export default function SummaryEmail(props: SummaryEmailProps) { const { baseUrl = "https://www.getinboxzero.com", coldEmailers, - pendingCount, needsReplyCount, awaitingReplyCount, needsActionCount, @@ -95,8 +93,6 @@ export default function SummaryEmail(props: SummaryEmailProps) { - -