diff --git a/.vscode/typescriptreact.code-snippets b/.vscode/typescriptreact.code-snippets index bfed51a42e..4032f4a61b 100644 --- a/.vscode/typescriptreact.code-snippets +++ b/.vscode/typescriptreact.code-snippets @@ -50,7 +50,7 @@ "", "export const GET = withError(async (request) => {", " const session = await auth();", - " if (!session) return NextResponse.json({ error: \"Not authenticated\" });", + " if (!session?.user) return NextResponse.json({ error: \"Not authenticated\" });", "", " const result = await get${1:ApiName}({ userId: session.user.id });", "", @@ -63,7 +63,7 @@ "", "export const POST = withError(async (request) => {", " const session = await auth();", - " if (!session) return NextResponse.json({ error: \"Not authenticated\" });", + " if (!session?.user) return NextResponse.json({ error: \"Not authenticated\" });", "", " const json = await request.json();", " const body = ${1/(.*)/${1:/downcase}/}Body.parse(json);", @@ -82,7 +82,7 @@ "", "export const DELETE = withError(async (_request, { params }) => {", " const session = await auth();", - " if (!session) return NextResponse.json({ error: \"Not authenticated\" });", + " if (!session?.user) return NextResponse.json({ error: \"Not authenticated\" });", "", " const result = await prisma.${2:table}.delete({", " where: {", @@ -222,7 +222,7 @@ "", "export const POST = withError(async (request: Request) => {", " const session = await auth();", - " if (!session) return NextResponse.json({ error: \"Not authenticated\" });", + " if (!session?.user) return NextResponse.json({ error: \"Not authenticated\" });", "", " const json = await request.json();", " const body = saveSettingsBody.parse(json);", diff --git a/apps/web/.env.example b/apps/web/.env.example index 8b004ff83d..1a054c7c84 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -11,7 +11,7 @@ OPENAI_API_KEY= BEDROCK_ACCESS_KEY= BEDROCK_SECRET_KEY= -BEDROCK_REGION=us-east-1 +BEDROCK_REGION=us-west-2 #redis config UPSTASH_REDIS_URL="http://localhost:8079" diff --git a/apps/web/__tests__/ai-categorize-senders.test.ts b/apps/web/__tests__/ai-categorize-senders.test.ts new file mode 100644 index 0000000000..d4ac833d3b --- /dev/null +++ b/apps/web/__tests__/ai-categorize-senders.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi } from "vitest"; +import { aiCategorizeSenders } from "@/utils/ai/categorize-sender/ai-categorize-senders"; +import { defaultCategory } from "@/utils/categories"; + +vi.mock("server-only", () => ({})); + +describe("aiCategorizeSenders", () => { + const user = { + email: "user@test.com", + aiProvider: null, + aiModel: null, + aiApiKey: null, + }; + + it("should categorize senders using AI", async () => { + const senders = [ + "newsletter@company.com", + "support@service.com", + "unknown@example.com", + "sales@business.com", + "noreply@socialnetwork.com", + ]; + + const result = await aiCategorizeSenders({ + user, + senders: senders.map((sender) => ({ emailAddress: sender, snippet: "" })), + categories: getEnabledCategories(), + }); + + expect(result).toHaveLength(senders.length); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sender: expect.any(String), + category: expect.any(String), + }), + ]), + ); + + // Check specific senders + const newsletterResult = result.find( + (r) => r.sender === "newsletter@company.com", + ); + expect(newsletterResult?.category).toBe("newsletter"); + + const supportResult = result.find( + (r) => r.sender === "support@service.com", + ); + expect(supportResult?.category).toBe("support"); + + // The unknown sender might be categorized as "RequestMoreInformation" + const unknownResult = result.find( + (r) => r.sender === "unknown@example.com", + ); + expect(unknownResult?.category).toBe("RequestMoreInformation"); + }, 15_000); // Increased timeout for AI call + + it("should handle empty senders list", async () => { + const result = await aiCategorizeSenders({ + user, + senders: [], + categories: [], + }); + + expect(result).toEqual([]); + }); + + it("should categorize senders for all valid SenderCategory values", async () => { + const senders = getEnabledCategories() + .filter((category) => category.name !== "Unknown") + .map((category) => `${category.name}@example.com`); + + const result = await aiCategorizeSenders({ + user, + senders: senders.map((sender) => ({ emailAddress: sender, snippet: "" })), + categories: getEnabledCategories(), + }); + + expect(result).toHaveLength(senders.length); + + for (const sender of senders) { + const category = sender.split("@")[0]; + const senderResult = result.find((r) => r.sender === sender); + expect(senderResult).toBeDefined(); + expect(senderResult?.category).toBe(category); + } + }, 15_000); +}); + +const getEnabledCategories = () => { + return Object.entries(defaultCategory) + .filter(([_, value]) => value.enabled) + .map(([_, value]) => ({ + name: value.name, + description: value.description, + })); +}; diff --git a/apps/web/app/(app)/automation/BulkRunRules.tsx b/apps/web/app/(app)/automation/BulkRunRules.tsx index b2ae75d891..75308b4b44 100644 --- a/apps/web/app/(app)/automation/BulkRunRules.tsx +++ b/apps/web/app/(app)/automation/BulkRunRules.tsx @@ -2,8 +2,6 @@ import { useRef, useState } from "react"; import Link from "next/link"; -import useSWR from "swr"; -import { useAtomValue } from "jotai"; import { HistoryIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useModal, Modal } from "@/components/Modal"; @@ -12,23 +10,21 @@ import type { ThreadsResponse } from "@/app/api/google/threads/controller"; import type { ThreadsQuery } from "@/app/api/google/threads/validation"; import { LoadingContent } from "@/components/LoadingContent"; import { runAiRules } from "@/utils/queue/email-actions"; -import { aiQueueAtom } from "@/store/queue"; import { sleep } from "@/utils/sleep"; import { PremiumAlertWithData, usePremium } from "@/components/PremiumAlert"; import { SetDateDropdown } from "@/app/(app)/automation/SetDateDropdown"; import { dateToSeconds } from "@/utils/date"; import { Tooltip } from "@/components/Tooltip"; +import { useThreads } from "@/hooks/useThreads"; +import { useAiQueueState } from "@/store/ai-queue"; export function BulkRunRules() { const { isModalOpen, openModal, closeModal } = useModal(); const [totalThreads, setTotalThreads] = useState(0); - const query: ThreadsQuery = { type: "inbox" }; - const { data, isLoading, error } = useSWR( - `/api/google/threads?${new URLSearchParams(query as any).toString()}`, - ); + const { data, isLoading, error } = useThreads({ type: "inbox" }); - const queue = useAtomValue(aiQueueAtom); + const queue = useAiQueueState(); const { hasAiAccess, isLoading: isLoadingPremium } = usePremium(); diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index 4a1b8d44f4..c526d2cb9d 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -17,7 +17,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner"; import { capitalCase } from "capital-case"; import { usePostHog } from "posthog-js/react"; -import { HelpCircleIcon, PlusIcon } from "lucide-react"; +import { ExternalLinkIcon, PlusIcon } from "lucide-react"; import { Card } from "@/components/Card"; import { Button } from "@/components/ui/button"; import { ErrorMessage, Input, Label } from "@/components/Input"; @@ -27,7 +27,7 @@ import { SectionDescription, TypographyH3, } from "@/components/Typography"; -import { ActionType, RuleType } from "@prisma/client"; +import { ActionType, CategoryFilterType, RuleType } from "@prisma/client"; import { createRuleAction, updateRuleAction } from "@/utils/actions/rule"; import { type CreateRuleBody, @@ -36,7 +36,6 @@ import { import { actionInputs } from "@/utils/actionType"; import { Select } from "@/components/Select"; import { Toggle } from "@/components/Toggle"; -import { Tooltip } from "@/components/Tooltip"; import type { GroupsResponse } from "@/app/api/user/group/route"; import { LoadingContent } from "@/components/LoadingContent"; import { TooltipExplanation } from "@/components/TooltipExplanation"; @@ -52,6 +51,9 @@ import { Combobox } from "@/components/Combobox"; import { useLabels } from "@/hooks/useLabels"; import { createLabelAction } from "@/utils/actions/mail"; import type { LabelsResponse } from "@/app/api/google/labels/route"; +import { MultiSelectFilter } from "@/components/MultiSelectFilter"; +import { useCategories } from "@/hooks/useCategories"; +import { useSmartCategoriesEnabled } from "@/hooks/useFeatureFlags"; export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { const { @@ -69,7 +71,11 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { const { append, remove } = useFieldArray({ control, name: "actions" }); const { userLabels, data: gmailLabelsData, isLoading, mutate } = useLabels(); - + const { + categories, + isLoading: categoriesLoading, + error: categoriesError, + } = useCategories(); const router = useRouter(); const posthog = usePostHog(); @@ -136,6 +142,8 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { [gmailLabelsData?.labels, router, posthog], ); + const showSmartCategories = useSmartCategoriesEnabled(); + return (
@@ -178,6 +186,52 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { placeholder='e.g. Apply this rule to all "receipts"' tooltipText="The instructions that will be passed to the AI." /> + + {showSmartCategories && ( +
+
+ + + +
+
Examples
+
+ {EXAMPLE_CATEGORIES.map((category) => ( + + ))} +
+
+ + + ); +} diff --git a/apps/web/app/(app)/smart-categories/Uncategorized.tsx b/apps/web/app/(app)/smart-categories/Uncategorized.tsx new file mode 100644 index 0000000000..2ab3048203 --- /dev/null +++ b/apps/web/app/(app)/smart-categories/Uncategorized.tsx @@ -0,0 +1,187 @@ +"use client"; + +import useSWRInfinite from "swr/infinite"; +import { useMemo, useCallback } from "react"; +import { ChevronsDownIcon, SparklesIcon, StopCircleIcon } from "lucide-react"; +import { ClientOnly } from "@/components/ClientOnly"; +import { SendersTable } from "@/components/GroupedTable"; +import { LoadingContent } from "@/components/LoadingContent"; +import { Button } from "@/components/ui/button"; +import type { UncategorizedSendersResponse } from "@/app/api/user/categorize/senders/uncategorized/route"; +import type { Category } from "@prisma/client"; +import { TopBar } from "@/components/TopBar"; +import { toastError } from "@/components/Toast"; +import { + useHasProcessingItems, + pushToAiCategorizeSenderQueueAtom, + stopAiCategorizeSenderQueue, +} from "@/store/ai-categorize-sender-queue"; +import { SectionDescription } from "@/components/Typography"; +import { ButtonLoader } from "@/components/Loading"; +import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; +import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; +import { Toggle } from "@/components/Toggle"; +import { setAutoCategorizeAction } from "@/utils/actions/categorize"; +import { TooltipExplanation } from "@/components/TooltipExplanation"; + +function useSenders() { + const getKey = ( + pageIndex: number, + previousPageData: UncategorizedSendersResponse | null, + ) => { + // Reached the end + if (previousPageData && !previousPageData.nextOffset) return null; + + const baseUrl = "/api/user/categorize/senders/uncategorized"; + const offset = pageIndex === 0 ? 0 : previousPageData?.nextOffset; + + return `${baseUrl}?offset=${offset}`; + }; + + const { data, size, setSize, isLoading } = + useSWRInfinite(getKey, { + revalidateOnFocus: false, + revalidateFirstPage: false, + persistSize: true, + revalidateOnMount: true, + }); + + const loadMore = useCallback(() => { + setSize(size + 1); + }, [setSize, size]); + + // Combine all senders from all pages + const allSenders = useMemo(() => { + if (!data) return []; + return data.flatMap((page) => page.uncategorizedSenders); + }, [data]); + + // Check if there's more data to load by looking at the last page + const hasMore = !!data?.[data.length - 1]?.nextOffset; + + return { + data: allSenders, + loadMore, + isLoading, + hasMore, + }; +} + +export function Uncategorized({ + categories, + autoCategorizeSenders, +}: { + categories: Category[]; + autoCategorizeSenders: boolean; +}) { + const { hasAiAccess } = usePremium(); + const { PremiumModal, openModal: openPremiumModal } = usePremiumModal(); + + const { data: senderAddresses, loadMore, isLoading, hasMore } = useSenders(); + const hasProcessingItems = useHasProcessingItems(); + + const senders = useMemo( + () => + senderAddresses.map((address) => ({ + address, + category: null, + })), + [senderAddresses], + ); + + return ( + + +
+ + + + + {hasProcessingItems && ( + + )} +
+ +
+
+ +
+ +
+
+ + {senders.length ? ( + <> + + {hasMore && ( + + )} + + ) : ( + !isLoading && ( + + No senders left to categorize! + + ) + )} + + +
+ ); +} + +function AutoCategorizeToggle({ + autoCategorizeSenders, +}: { + autoCategorizeSenders: boolean; +}) { + return ( + { + await setAutoCategorizeAction(enabled); + }} + /> + ); +} diff --git a/apps/web/app/(app)/smart-categories/board/page.tsx b/apps/web/app/(app)/smart-categories/board/page.tsx new file mode 100644 index 0000000000..b02649f432 --- /dev/null +++ b/apps/web/app/(app)/smart-categories/board/page.tsx @@ -0,0 +1,60 @@ +import { capitalCase } from "capital-case"; +import { KanbanBoard } from "@/components/kanban/KanbanBoard"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import prisma from "@/utils/prisma"; +import { ClientOnly } from "@/components/ClientOnly"; +import { isDefined } from "@/utils/types"; +import { getUserCategories } from "@/utils/category.server"; + +export const dynamic = "force-dynamic"; + +const CATEGORY_ORDER = [ + "Unknown", + "RequestMoreInformation", + "Newsletter", + "Marketing", + "Receipts", + "Support", +]; + +export default async function CategoriesPage() { + const session = await auth(); + const email = session?.user.email; + if (!email) throw new Error("Not authenticated"); + + const [categories, senders] = await Promise.all([ + getUserCategories(session.user.id), + prisma.newsletter.findMany({ + where: { userId: session.user.id, categoryId: { not: null } }, + select: { id: true, email: true, categoryId: true }, + }), + ]); + + if (!categories.length) return
No categories found
; + + // Order categories + const orderedCategories = [ + ...CATEGORY_ORDER.map((name) => + categories.find((c) => c.name === name), + ).filter(isDefined), + ...categories.filter((c) => !CATEGORY_ORDER.includes(c.name)), + ]; + + return ( +
+ + ({ + id: c.id, + title: capitalCase(c.name), + }))} + items={senders.map((s) => ({ + id: s.id, + columnId: s.categoryId || "Uncategorized", + content: s.email, + }))} + /> + +
+ ); +} diff --git a/apps/web/app/(app)/smart-categories/page.tsx b/apps/web/app/(app)/smart-categories/page.tsx new file mode 100644 index 0000000000..2a1733c0fd --- /dev/null +++ b/apps/web/app/(app)/smart-categories/page.tsx @@ -0,0 +1,125 @@ +import { Suspense } from "react"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { PenIcon } from "lucide-react"; +import sortBy from "lodash/sortBy"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import prisma from "@/utils/prisma"; +import { ClientOnly } from "@/components/ClientOnly"; +import { GroupedTable } from "@/components/GroupedTable"; +import { TopBar } from "@/components/TopBar"; +import { CreateCategoryButton } from "@/app/(app)/smart-categories/CreateCategoryButton"; +import { getUserCategories } from "@/utils/category.server"; +import { CategorizeWithAiButton } from "@/app/(app)/smart-categories/CategorizeWithAiButton"; +import { + Card, + CardContent, + CardTitle, + CardHeader, + CardDescription, +} from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Uncategorized } from "@/app/(app)/smart-categories/Uncategorized"; +import { PermissionsCheck } from "@/app/(app)/PermissionsCheck"; +import { ArchiveProgress } from "@/app/(app)/bulk-unsubscribe/ArchiveProgress"; +import { PremiumAlertWithData } from "@/components/PremiumAlert"; +import { Button } from "@/components/ui/button"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +export default async function CategoriesPage() { + const session = await auth(); + const email = session?.user.email; + if (!email) throw new Error("Not authenticated"); + + const [senders, categories, user] = await Promise.all([ + prisma.newsletter.findMany({ + where: { userId: session.user.id, categoryId: { not: null } }, + select: { + id: true, + email: true, + category: { select: { id: true, name: true } }, + }, + }), + getUserCategories(session.user.id), + prisma.user.findUnique({ + where: { id: session.user.id }, + select: { autoCategorizeSenders: true }, + }), + ]); + + if (!(senders.length > 0 || categories.length > 0)) + redirect("/smart-categories/setup"); + + return ( + + + + + + + + + + + + + + Categories + Uncategorized + + +
+ + +
+
+ + + {senders.length === 0 && ( + + + Categorize with AI + + Now that you have some categories, our AI can categorize + senders for you automatically. + + + + + + + )} + + + sender.category?.name, + ).map((sender) => ({ + address: sender.email, + category: sender.category, + }))} + categories={categories} + /> + + + + + + +
+
+
+ ); +} diff --git a/apps/web/app/(app)/smart-categories/setup/SetUpCategories.tsx b/apps/web/app/(app)/smart-categories/setup/SetUpCategories.tsx new file mode 100644 index 0000000000..4b4f7027d4 --- /dev/null +++ b/apps/web/app/(app)/smart-categories/setup/SetUpCategories.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useEffect, useState } from "react"; +import uniqBy from "lodash/uniqBy"; +import { useRouter } from "next/navigation"; +import { useQueryState } from "nuqs"; +import { PenIcon, PlusIcon, TagsIcon, TrashIcon } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { TypographyH4 } from "@/components/Typography"; +import { Button } from "@/components/ui/button"; +import { defaultCategory } from "@/utils/categories"; +import { + upsertDefaultCategoriesAction, + deleteCategoryAction, +} from "@/utils/actions/categorize"; +import { cn } from "@/utils"; +import { + CreateCategoryButton, + CreateCategoryDialog, +} from "@/app/(app)/smart-categories/CreateCategoryButton"; +import type { Category } from "@prisma/client"; + +type CardCategory = Pick & { + id?: string; + enabled?: boolean; + isDefault?: boolean; +}; + +const defaultCategories = Object.values(defaultCategory).map((c) => ({ + name: c.name, + description: c.description, + enabled: c.enabled, + isDefault: true, +})); + +export function SetUpCategories({ + existingCategories, +}: { + existingCategories: CardCategory[]; +}) { + const [isCreating, setIsCreating] = useState(false); + const router = useRouter(); + const [selectedCategoryName, setSelectedCategoryName] = + useQueryState("categoryName"); + + const combinedCategories = uniqBy( + [ + ...defaultCategories.map((c) => { + const existing = existingCategories.find((e) => e.name === c.name); + + if (existing) { + return { + ...existing, + enabled: true, + isDefault: false, + }; + } + + return { + ...c, + id: undefined, + // only enable on first set up + enabled: c.enabled && !existingCategories.length, + }; + }), + ...existingCategories, + ], + (c) => c.name, + ); + + const [categories, setCategories] = useState>( + new Map( + combinedCategories.map((c) => [c.name, !c.isDefault || !!c.enabled]), + ), + ); + + // Update categories when existingCategories changes + // This is a bit messy that we need to do this + useEffect(() => { + setCategories((prevCategories) => { + const newCategories = new Map(prevCategories); + + // Enable any new categories from existingCategories that aren't in the current map + for (const category of existingCategories) { + if (!prevCategories.has(category.name)) { + newCategories.set(category.name, true); + } + } + + // Disable any categories that aren't in existingCategories + if (existingCategories.length) { + for (const category of prevCategories.keys()) { + if (!existingCategories.some((c) => c.name === category)) { + newCategories.set(category, false); + } + } + } + + return newCategories; + }); + }, [existingCategories]); + + return ( + <> + + + Set up categories + + Automatically categorize who emails you for better inbox management + and smarter automation. This allows you to bulk archive by category + and optimize AI automation based on sender types. + + + + + Choose categories + +
+ {combinedCategories.map((category) => { + return ( + + setCategories( + new Map(categories.entries()).set(category.name, true), + ) + } + onRemove={async () => { + if (category.id) { + await deleteCategoryAction(category.id); + } else { + setCategories( + new Map(categories.entries()).set(category.name, false), + ); + } + }} + onEdit={() => setSelectedCategoryName(category.name)} + /> + ); + })} +
+ +
+ + + Add your own + + ), + }} + /> + +
+
+
+ + setSelectedCategoryName(open ? selectedCategoryName : null) + } + closeModal={() => setSelectedCategoryName(null)} + category={ + selectedCategoryName + ? combinedCategories.find((c) => c.name === selectedCategoryName) + : undefined + } + /> + + ); +} + +function CategoryCard({ + category, + isEnabled, + onAdd, + onRemove, + onEdit, +}: { + category: CardCategory; + isEnabled: boolean; + onAdd: () => void; + onRemove: () => void; + onEdit: () => void; +}) { + return ( + +
+
{category.name}
+
+ {category.description} +
+
+ {isEnabled ? ( +
+ + +
+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/app/(app)/smart-categories/setup/SmartCategoriesOnboarding.tsx b/apps/web/app/(app)/smart-categories/setup/SmartCategoriesOnboarding.tsx new file mode 100644 index 0000000000..f730554abe --- /dev/null +++ b/apps/web/app/(app)/smart-categories/setup/SmartCategoriesOnboarding.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useOnboarding } from "@/components/OnboardingModal"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Card } from "@/components/Card"; +import { Button } from "@/components/ui/button"; +import { TagsIcon, ArchiveIcon, ZapIcon } from "lucide-react"; + +export function SmartCategoriesOnboarding() { + const { isOpen, setIsOpen, onClose } = useOnboarding("SmartCategories"); + + return ( + + + + Welcome to Smart Categories + + Automatically categorize who emails you for better inbox management + and smarter automation. + + + +
+ + + Auto-categorize who emails you + + + + Bulk archive by category + + + + Use categories to optimize AI automation + +
+
+ +
+
+
+ ); +} diff --git a/apps/web/app/(app)/smart-categories/setup/page.tsx b/apps/web/app/(app)/smart-categories/setup/page.tsx new file mode 100644 index 0000000000..a259ecbaa8 --- /dev/null +++ b/apps/web/app/(app)/smart-categories/setup/page.tsx @@ -0,0 +1,23 @@ +import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { SetUpCategories } from "@/app/(app)/smart-categories/setup/SetUpCategories"; +import { SmartCategoriesOnboarding } from "@/app/(app)/smart-categories/setup/SmartCategoriesOnboarding"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { ClientOnly } from "@/components/ClientOnly"; +import { getUserCategories } from "@/utils/category.server"; + +export default async function SetupCategoriesPage() { + const session = await auth(); + const email = session?.user.email; + if (!email) throw new Error("Not authenticated"); + + const categories = await getUserCategories(session.user.id); + + return ( + + + + + + + ); +} diff --git a/apps/web/app/(app)/stats/NewsletterModal.tsx b/apps/web/app/(app)/stats/NewsletterModal.tsx index be3d50ed4f..0d3834c556 100644 --- a/apps/web/app/(app)/stats/NewsletterModal.tsx +++ b/apps/web/app/(app)/stats/NewsletterModal.tsx @@ -30,6 +30,7 @@ import { MoreDropdown } from "@/app/(app)/bulk-unsubscribe/common"; import { useLabels } from "@/hooks/useLabels"; import type { Row } from "@/app/(app)/bulk-unsubscribe/types"; import { usePostHog } from "posthog-js/react"; +import { useThreads } from "@/hooks/useThreads"; export function NewsletterModal(props: { newsletter?: Pick; @@ -109,7 +110,7 @@ export function NewsletterModal(props: { ); } -function EmailsChart(props: { +function useSenderEmails(props: { fromEmail: string; dateRange?: DateRange | undefined; period: ZodPeriod; @@ -126,6 +127,17 @@ function EmailsChart(props: { refreshInterval: props.refreshInterval, }); + return { data, isLoading, error }; +} + +function EmailsChart(props: { + fromEmail: string; + dateRange?: DateRange | undefined; + period: ZodPeriod; + refreshInterval?: number; +}) { + const { data, isLoading, error } = useSenderEmails(props); + return ( {data && ( @@ -171,8 +183,8 @@ function UnarchivedEmails({ fromEmail: string; refreshInterval?: number; }) { - const url = `/api/google/threads?fromEmail=${encodeURIComponent(fromEmail)}`; - const { data, isLoading, error, mutate } = useSWR(url, { + const { data, isLoading, error, mutate } = useThreads({ + fromEmail, refreshInterval, }); @@ -202,10 +214,9 @@ function AllEmails({ fromEmail: string; refreshInterval?: number; }) { - const url = `/api/google/threads?fromEmail=${encodeURIComponent( + const { data, isLoading, error, mutate } = useThreads({ fromEmail, - )}&type=all`; - const { data, isLoading, error, mutate } = useSWR(url, { + type: "all", refreshInterval, }); diff --git a/apps/web/app/(app)/stats/StatsOnboarding.tsx b/apps/web/app/(app)/stats/StatsOnboarding.tsx index 220e35abba..c0acd63104 100644 --- a/apps/web/app/(app)/stats/StatsOnboarding.tsx +++ b/apps/web/app/(app)/stats/StatsOnboarding.tsx @@ -1,9 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { useLocalStorage } from "usehooks-ts"; import { ArchiveIcon, Layers3Icon, BarChartBigIcon } from "lucide-react"; -import { Button } from "@/components/Button"; +import { Button } from "@/components/ui/button"; import { Card } from "@/components/Card"; import { Dialog, @@ -12,21 +10,10 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { useOnboarding } from "@/components/OnboardingModal"; export function StatsOnboarding() { - const [isOpen, setIsOpen] = useState(false); - - const [viewedStatsOnboarding, setViewedStatsOnboarding] = useLocalStorage( - "viewedStatsOnboarding", - false, - ); - - useEffect(() => { - if (!viewedStatsOnboarding) { - setIsOpen(true); - setViewedStatsOnboarding(true); - } - }, [setViewedStatsOnboarding, viewedStatsOnboarding]); + const { isOpen, setIsOpen, onClose } = useOnboarding("Stats"); return ( @@ -54,13 +41,7 @@ export function StatsOnboarding() {
-
diff --git a/apps/web/app/(landing)/components/page.tsx b/apps/web/app/(landing)/components/page.tsx index 25929fbd26..cf6ff96355 100644 --- a/apps/web/app/(landing)/components/page.tsx +++ b/apps/web/app/(landing)/components/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Suspense } from "react"; import { SparklesIcon } from "lucide-react"; import { Card } from "@/components/Card"; @@ -19,10 +21,19 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { AlertBasic } from "@/components/Alert"; import { TestErrorButton } from "@/app/(landing)/components/TestError"; import { TestActionButton } from "@/app/(landing)/components/TestAction"; +import { + MultiSelectFilter, + useMultiSelectFilter, +} from "@/components/MultiSelectFilter"; +import { TooltipExplanation } from "@/components/TooltipExplanation"; export const maxDuration = 3; export default function Components() { + const { selectedValues, setSelectedValues } = useMultiSelectFilter([ + "alerts", + ]); + return (
@@ -144,6 +155,31 @@ export default function Components() {
+
+
TooltipExplanation
+
+ + +
+
+ +
+
MultiSelectFilter
+
+ +
+
+
diff --git a/apps/web/app/(landing)/home/Features.tsx b/apps/web/app/(landing)/home/Features.tsx index 10a0a837e5..2db3596b03 100644 --- a/apps/web/app/(landing)/home/Features.tsx +++ b/apps/web/app/(landing)/home/Features.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLandingPageVariant } from "@/hooks/useFeatureFlags"; import clsx from "clsx"; import { BarChart2Icon, @@ -16,7 +17,6 @@ import { ListStartIcon, } from "lucide-react"; import Image from "next/image"; -import { useFeatureFlagVariantKey } from "posthog-js/react"; export function FeaturesPrivacy() { return ( @@ -137,7 +137,7 @@ const featuresAutomations = [ ]; export function FeaturesAutomation() { - const variant = useFeatureFlagVariantKey("landing-page-features"); + const variant = useLandingPageVariant(); const variants: Record< string, @@ -193,7 +193,7 @@ const featuresColdEmailBlocker = [ ]; export function FeaturesColdEmailBlocker() { - const variant = useFeatureFlagVariantKey("landing-page-features"); + const variant = useLandingPageVariant(); const variants: Record< string, @@ -284,7 +284,7 @@ const featuresUnsubscribe = [ ]; export function FeaturesUnsubscribe() { - const variant = useFeatureFlagVariantKey("landing-page-features"); + const variant = useLandingPageVariant(); const variants: Record< string, diff --git a/apps/web/app/(landing)/welcome/form.tsx b/apps/web/app/(landing)/welcome/form.tsx index b8dab11373..7b2dbfe2bd 100644 --- a/apps/web/app/(landing)/welcome/form.tsx +++ b/apps/web/app/(landing)/welcome/form.tsx @@ -3,11 +3,7 @@ import { useCallback, useState } from "react"; import { type SubmitHandler, useForm } from "react-hook-form"; import { useRouter, useSearchParams } from "next/navigation"; -import { - type PostHog, - useFeatureFlagVariantKey, - usePostHog, -} from "posthog-js/react"; +import { type PostHog, usePostHog } from "posthog-js/react"; import type { Properties } from "posthog-js"; import { survey } from "@/app/(landing)/welcome/survey"; import { Button } from "@/components/ui/button"; @@ -19,6 +15,7 @@ import { } from "@/utils/actions/user"; import { appHomePath } from "@/utils/config"; import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; +import { useAppOnboardingVariant } from "@/hooks/useFeatureFlags"; const surveyId = env.NEXT_PUBLIC_POSTHOG_ONBOARDING_SURVEY_ID; @@ -55,7 +52,7 @@ export const OnboardingForm = (props: { questionIndex: number }) => { [posthog], ); - const variant = useFeatureFlagVariantKey("app-onboarding"); + const variant = useAppOnboardingVariant(); const onSubmit: SubmitHandler = useCallback( async (data) => { diff --git a/apps/web/app/api/ai/categorise/controller.ts b/apps/web/app/api/ai/categorize/controller.ts similarity index 87% rename from apps/web/app/api/ai/categorise/controller.ts rename to apps/web/app/api/ai/categorize/controller.ts index cc09cfed16..dd482ab924 100644 --- a/apps/web/app/api/ai/categorise/controller.ts +++ b/apps/web/app/api/ai/categorize/controller.ts @@ -2,10 +2,10 @@ import { z } from "zod"; import { chatCompletionObject } from "@/utils/llms"; import type { UserAIFields } from "@/utils/llms/types"; import { getCategory, saveCategory } from "@/utils/redis/category"; -import type { CategoriseBody } from "@/app/api/ai/categorise/validation"; +import type { CategorizeBody } from "@/app/api/ai/categorize/validation"; import { truncate } from "@/utils/string"; -export type CategoriseResponse = Awaited>; +export type CategorizeResponse = Awaited>; const aiResponseSchema = z.object({ requiresMoreInformation: z.boolean(), @@ -29,8 +29,8 @@ const aiResponseSchema = z.object({ .optional(), }); -async function aiCategorise( - body: CategoriseBody & { content: string } & UserAIFields, +async function aicategorize( + body: CategorizeBody & { content: string } & UserAIFields, expanded: boolean, userEmail: string, ) { @@ -90,8 +90,8 @@ ${expanded ? truncate(body.content, 2000) : body.snippet} return response; } -export async function categorise( - body: CategoriseBody & { content: string } & UserAIFields, +export async function categorize( + body: CategorizeBody & { content: string } & UserAIFields, options: { email: string }, ): Promise<{ category: string } | undefined> { // 1. check redis cache @@ -100,11 +100,11 @@ export async function categorise( threadId: body.threadId, }); if (existingCategory) return existingCategory; - // 2. ai categorise - let category = await aiCategorise(body, false, options.email); + // 2. ai categorize + let category = await aicategorize(body, false, options.email); if (category.object.requiresMoreInformation) { console.log("Not enough information, expanding email and trying again"); - category = await aiCategorise(body, true, options.email); + category = await aicategorize(body, true, options.email); } if (!category.object.category) return; diff --git a/apps/web/app/api/ai/categorise/validation.ts b/apps/web/app/api/ai/categorize/validation.ts similarity index 59% rename from apps/web/app/api/ai/categorise/validation.ts rename to apps/web/app/api/ai/categorize/validation.ts index ddb4e7a043..35f11f24a9 100644 --- a/apps/web/app/api/ai/categorise/validation.ts +++ b/apps/web/app/api/ai/categorize/validation.ts @@ -1,21 +1,21 @@ import { z } from "zod"; -export const categoriseBody = z.object({ +export const categorizeBody = z.object({ threadId: z.string(), from: z.string(), subject: z.string(), }); -export type CategoriseBody = z.infer & { +export type CategorizeBody = z.infer & { content: string; snippet: string; unsubscribeLink?: string; hasPreviousEmail: boolean; }; -export const categoriseBodyWithHtml = categoriseBody.extend({ +export const categorizeBodyWithHtml = categorizeBody.extend({ textPlain: z.string().nullable(), textHtml: z.string().nullable(), snippet: z.string().nullable(), date: z.string(), }); -export type CategoriseBodyWithHtml = z.infer; +export type CategorizeBodyWithHtml = z.infer; diff --git a/apps/web/app/api/ai/cold-email/route.ts b/apps/web/app/api/ai/cold-email/route.ts index e673e90676..587597c29b 100644 --- a/apps/web/app/api/ai/cold-email/route.ts +++ b/apps/web/app/api/ai/cold-email/route.ts @@ -5,7 +5,7 @@ import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; import { withError } from "@/utils/middleware"; import { isColdEmail } from "@/app/api/ai/cold-email/controller"; -import { hasPreviousEmailsFromDomain } from "@/utils/gmail/message"; +import { hasPreviousEmailsFromSenderOrDomain } from "@/utils/gmail/message"; import { getGmailClient } from "@/utils/gmail/client"; import { emailToContent } from "@/utils/mail"; @@ -43,7 +43,7 @@ async function checkColdEmail( const hasPreviousEmail = body.email.date && body.email.threadId - ? await hasPreviousEmailsFromDomain(gmail, { + ? await hasPreviousEmailsFromSenderOrDomain(gmail, { from: body.email.from, date: body.email.date, threadId: body.email.threadId, diff --git a/apps/web/app/api/google/messages/route.ts b/apps/web/app/api/google/messages/route.ts index 1d5061a6eb..4df047eaf4 100644 --- a/apps/web/app/api/google/messages/route.ts +++ b/apps/web/app/api/google/messages/route.ts @@ -10,7 +10,7 @@ export type MessagesResponse = Awaited>; async function getMessages() { const session = await auth(); - if (!session) throw new SafeError("Not authenticated"); + if (!session?.user) throw new SafeError("Not authenticated"); const gmail = getGmailClient(session); diff --git a/apps/web/app/api/google/threads/controller.ts b/apps/web/app/api/google/threads/controller.ts index 81b04283e2..761490703a 100644 --- a/apps/web/app/api/google/threads/controller.ts +++ b/apps/web/app/api/google/threads/controller.ts @@ -33,17 +33,24 @@ export async function getThreads(query: ThreadsQuery) { if (!accessToken) throw new SafeError("Missing access token"); + function getQuery() { + if (query.q) { + return query.q; + } + if (query.fromEmail) { + return `from:${query.fromEmail}`; + } + if (query.type === "archive") { + return `-label:${INBOX_LABEL_ID}`; + } + return undefined; + } + const gmailThreads = await gmail.users.threads.list({ userId: "me", labelIds: getLabelIds(query.type), maxResults: query.limit || 50, - q: - query.q || - (query.fromEmail - ? `from:${query.fromEmail}` - : query.type === "archive" - ? `-label:${INBOX_LABEL_ID}` - : undefined), + q: getQuery(), pageToken: query.nextPageToken || undefined, }); diff --git a/apps/web/app/api/google/watch/all/route.ts b/apps/web/app/api/google/watch/all/route.ts index 3cf574c6fc..efc40146a5 100644 --- a/apps/web/app/api/google/watch/all/route.ts +++ b/apps/web/app/api/google/watch/all/route.ts @@ -49,7 +49,7 @@ async function watchAllEmails() { console.log(`Watching emails for ${user.email}`); const userHasAiAccess = hasAiAccess( - user.premium.coldEmailBlockerAccess, + user.premium.aiAutomationAccess, user.aiApiKey, ); const userHasColdEmailAccess = hasColdEmailAccess( diff --git a/apps/web/app/api/google/webhook/process-history.ts b/apps/web/app/api/google/webhook/process-history.ts index 2180de7859..61d187aeef 100644 --- a/apps/web/app/api/google/webhook/process-history.ts +++ b/apps/web/app/api/google/webhook/process-history.ts @@ -9,8 +9,11 @@ import { INBOX_LABEL_ID, SENT_LABEL_ID, } from "@/utils/gmail/label"; -import type { RuleWithActions } from "@/utils/types"; -import { getMessage, hasPreviousEmailsFromDomain } from "@/utils/gmail/message"; +import type { RuleWithActionsAndCategories } from "@/utils/types"; +import { + getMessage, + hasPreviousEmailsFromSenderOrDomain, +} from "@/utils/gmail/message"; import { getThread } from "@/utils/gmail/thread"; import type { UserAIFields } from "@/utils/llms/types"; import { hasAiAccess, hasColdEmailAccess, isPremium } from "@/utils/premium"; @@ -19,6 +22,7 @@ import { runColdEmailBlocker } from "@/app/api/ai/cold-email/controller"; import { captureException } from "@/utils/error"; import { runRulesOnMessage } from "@/utils/ai/choose-rule/run-rules"; import { blockUnsubscribedEmails } from "@/app/api/google/webhook/block-unsubscribed-emails"; +import { categorizeSender } from "@/utils/actions/categorize"; export async function processHistoryForUser( decodedData: { @@ -44,7 +48,7 @@ export async function processHistoryForUser( lastSyncedHistoryId: true, rules: { where: { enabled: true }, - include: { actions: true }, + include: { actions: true, categoryFilters: true }, }, coldEmailBlocker: true, coldEmailPrompt: true, @@ -58,6 +62,7 @@ export async function processHistoryForUser( aiAutomationAccess: true, }, }, + autoCategorizeSenders: true, }, }, }, @@ -157,6 +162,7 @@ export async function processHistoryForUser( history: history.data.history, email, gmail, + accessToken: account.access_token, hasAutomationRules, rules: account.user.rules, hasColdEmailAccess: userHasColdEmailAccess, @@ -170,6 +176,7 @@ export async function processHistoryForUser( aiApiKey: account.user.aiApiKey, coldEmailPrompt: account.user.coldEmailPrompt, coldEmailBlocker: account.user.coldEmailBlocker, + autoCategorizeSenders: account.user.autoCategorizeSenders, }, }); } else { @@ -200,13 +207,19 @@ type ProcessHistoryOptions = { history: gmail_v1.Schema$History[]; email: string; gmail: gmail_v1.Gmail; - rules: RuleWithActions[]; + accessToken: string; + rules: RuleWithActionsAndCategories[]; hasAutomationRules: boolean; hasColdEmailAccess: boolean; hasAiAutomationAccess: boolean; user: Pick< User, - "id" | "email" | "about" | "coldEmailPrompt" | "coldEmailBlocker" + | "id" + | "email" + | "about" + | "coldEmailPrompt" + | "coldEmailBlocker" + | "autoCategorizeSenders" > & UserAIFields; }; @@ -258,6 +271,7 @@ async function processHistoryItem( m: gmail_v1.Schema$HistoryMessageAdded | gmail_v1.Schema$HistoryLabelAdded, { gmail, + accessToken, user, hasColdEmailAccess, hasAutomationRules, @@ -313,18 +327,6 @@ async function processHistoryItem( const isThread = !!gmailThread.messages && gmailThread.messages.length > 1; - if (hasAutomationRules && hasAiAutomationAccess) { - console.log("Running rules..."); - - await runRulesOnMessage({ - gmail, - message, - rules, - user, - isThread, - }); - } - const shouldRunBlocker = shouldRunColdEmailBlocker( user.coldEmailBlocker, hasColdEmailAccess, @@ -334,11 +336,14 @@ async function processHistoryItem( if (shouldRunBlocker) { console.log("Running cold email blocker..."); - const hasPreviousEmail = await hasPreviousEmailsFromDomain(gmail, { - from: message.headers.from, - date: message.headers.date, - threadId, - }); + const hasPreviousEmail = await hasPreviousEmailsFromSenderOrDomain( + gmail, + { + from: message.headers.from, + date: message.headers.date, + threadId, + }, + ); const content = emailToContent({ textHtml: message.textHtml || null, @@ -359,6 +364,31 @@ async function processHistoryItem( user, }); } + + // categorize a sender if we haven't already + // this is used for category filters in ai rules + if (user.autoCategorizeSenders) { + const sender = message.headers.from; + const existingSender = await prisma.newsletter.findUnique({ + where: { email_userId: { email: sender, userId: user.id } }, + select: { category: true }, + }); + if (!existingSender?.category) { + await categorizeSender(sender, user, gmail, accessToken); + } + } + + if (hasAutomationRules && hasAiAutomationAccess) { + console.log("Running rules..."); + + await runRulesOnMessage({ + gmail, + message, + rules, + user, + isThread, + }); + } } catch (error: any) { // gmail bug or snoozed email: https://stackoverflow.com/questions/65290987/gmail-api-getmessage-method-returns-404-for-message-gotten-from-listhistory-meth if (error.message === "Requested entity was not found.") { diff --git a/apps/web/app/api/user/categories/route.ts b/apps/web/app/api/user/categories/route.ts new file mode 100644 index 0000000000..e232c7347c --- /dev/null +++ b/apps/web/app/api/user/categories/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { withError } from "@/utils/middleware"; +import { getUserCategories } from "@/utils/category.server"; + +export type UserCategoriesResponse = Awaited>; + +async function getCategories({ userId }: { userId: string }) { + const result = await getUserCategories(userId); + return { result }; +} + +export const GET = withError(async () => { + const session = await auth(); + if (!session?.user.id) + return NextResponse.json({ error: "Not authenticated" }); + + const result = await getCategories({ userId: session.user.id }); + + return NextResponse.json(result); +}); diff --git a/apps/web/app/api/user/categorize/senders/find-senders.ts b/apps/web/app/api/user/categorize/senders/find-senders.ts new file mode 100644 index 0000000000..289824d6a5 --- /dev/null +++ b/apps/web/app/api/user/categorize/senders/find-senders.ts @@ -0,0 +1,78 @@ +import type { gmail_v1 } from "@googleapis/gmail"; +import { getMessagesBatch } from "@/utils/gmail/message"; +import { getThreadsWithNextPageToken } from "@/utils/gmail/thread"; +import { isDefined } from "@/utils/types"; +import type { SenderMap } from "@/app/api/user/categorize/senders/types"; + +export async function findSendersWithPagination( + gmail: gmail_v1.Gmail, + accessToken: string, + maxPages: number, +) { + const allSenders: SenderMap = new Map(); + let nextPageToken: string | undefined = undefined; + let currentPage = 0; + + while (currentPage < maxPages) { + const { senders, nextPageToken: newNextPageToken } = await findSenders( + gmail, + accessToken, + nextPageToken, + ); + + for (const [sender, messages] of Object.entries(senders)) { + const existingMessages = allSenders.get(sender) ?? []; + allSenders.set(sender, [...existingMessages, ...messages]); + } + + if (!newNextPageToken) break; // No more pages + + nextPageToken = newNextPageToken; + currentPage++; + } + + return { senders: allSenders, nextPageToken }; +} + +export async function findSenders( + gmail: gmail_v1.Gmail, + accessToken: string, + pageToken?: string, + maxResults = 50, +) { + const senders: SenderMap = new Map(); + + const { threads, nextPageToken } = await getThreadsWithNextPageToken({ + q: "-in:sent", + gmail, + maxResults, + pageToken, + }); + + const messageIds = threads.map((t) => t.id).filter(isDefined); + const messages = await getMessagesBatch(messageIds, accessToken); + + for (const message of messages) { + const sender = message.headers.from; + if (sender) { + const existingMessages = senders.get(sender) ?? []; + senders.set(sender, [...existingMessages, message]); + } + } + + return { senders, nextPageToken }; +} + +function isNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "errors" in error && + Array.isArray((error as any).errors) && + (error as any).errors.some( + (e: any) => + e.message === "Requested entity was not found." && + e.reason === "notFound", + ) + ); +} diff --git a/apps/web/app/api/user/categorize/senders/types.ts b/apps/web/app/api/user/categorize/senders/types.ts new file mode 100644 index 0000000000..2b69463f5f --- /dev/null +++ b/apps/web/app/api/user/categorize/senders/types.ts @@ -0,0 +1,3 @@ +import type { ParsedMessage } from "@/utils/types"; + +export type SenderMap = Map; diff --git a/apps/web/app/api/user/categorize/senders/uncategorized/route.ts b/apps/web/app/api/user/categorize/senders/uncategorized/route.ts new file mode 100644 index 0000000000..64615a3584 --- /dev/null +++ b/apps/web/app/api/user/categorize/senders/uncategorized/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { withError } from "@/utils/middleware"; +import { getSessionAndGmailClient } from "@/utils/actions/helpers"; +import { getSenders } from "@inboxzero/tinybird"; +import prisma from "@/utils/prisma"; + +export type UncategorizedSendersResponse = { + uncategorizedSenders: string[]; + nextOffset?: number; +}; + +async function getUncategorizedSenders({ + email, + userId, + offset = 0, + limit = 100, +}: { + email: string; + userId: string; + offset?: number; + limit?: number; +}) { + let uncategorizedSenders: string[] = []; + let currentOffset = offset; + + while (uncategorizedSenders.length === 0) { + const result = await getSenders({ + ownerEmail: email, + limit, + offset: currentOffset, + }); + const allSenders = result.data.map((sender) => sender.from); + + const existingSenders = await prisma.newsletter.findMany({ + where: { + email: { in: allSenders }, + userId, + }, + select: { email: true }, + }); + + const existingSenderEmails = new Set(existingSenders.map((s) => s.email)); + + uncategorizedSenders = allSenders.filter( + (email) => !existingSenderEmails.has(email), + ); + + // Break the loop if no more senders are available + if (allSenders.length < limit) { + return { uncategorizedSenders }; + } + + currentOffset += limit; + } + + return { + uncategorizedSenders, + nextOffset: currentOffset, // Only return nextOffset if there might be more + }; +} + +export const GET = withError(async (request: Request) => { + const { gmail, user, error, session } = await getSessionAndGmailClient(); + if (!user?.email) return NextResponse.json({ error: "Not authenticated" }); + if (error) return NextResponse.json({ error }); + if (!gmail) return NextResponse.json({ error: "Could not load Gmail" }); + if (!session?.accessToken) + return NextResponse.json({ error: "No access token" }); + + const url = new URL(request.url); + const offset = Number.parseInt(url.searchParams.get("offset") || "0"); + + const result = await getUncategorizedSenders({ + email: user.email, + userId: user.id, + offset, + }); + + return NextResponse.json(result); +}); diff --git a/apps/web/app/api/user/complete-registration/route.ts b/apps/web/app/api/user/complete-registration/route.ts index 27a6554382..27be57bd2a 100644 --- a/apps/web/app/api/user/complete-registration/route.ts +++ b/apps/web/app/api/user/complete-registration/route.ts @@ -7,7 +7,7 @@ import { posthogCaptureEvent } from "@/utils/posthog"; import prisma from "@/utils/prisma"; import { ONE_HOUR_MS } from "@/utils/date"; -export type CompleteRegistrationBody = {}; +export type CompleteRegistrationBody = Record; export const POST = withError(async (_request: NextRequest) => { const session = await auth(); diff --git a/apps/web/components/CommandK.tsx b/apps/web/components/CommandK.tsx index cdf67c5a24..80581ec078 100644 --- a/apps/web/components/CommandK.tsx +++ b/apps/web/components/CommandK.tsx @@ -13,10 +13,10 @@ import { CommandList, CommandShortcut, } from "@/components/ui/command"; -import { navigation } from "@/components/SideNav"; +import { useNavigation } from "@/components/SideNav"; import { useComposeModal } from "@/providers/ComposeModalProvider"; import { refetchEmailListAtom, selectedEmailAtom } from "@/store/email"; -import { archiveEmails } from "@/utils/queue/email-actions"; +import { archiveEmails } from "@/store/archive-queue"; export function CommandK() { const [open, setOpen] = React.useState(false); @@ -31,7 +31,7 @@ export function CommandK() { const onArchive = React.useCallback(() => { if (selectedEmail) { const threadIds = [selectedEmail]; - archiveEmails(threadIds, () => { + archiveEmails(threadIds, undefined, () => { return refreshEmailList?.refetch(threadIds); }); setSelectedEmail(undefined); @@ -68,6 +68,8 @@ export function CommandK() { return () => document.removeEventListener("keydown", down); }, [onArchive, onOpenComposeModal]); + const navigation = useNavigation(); + return ( <> { + const match = name.match(/<(.+)>/); + return match ? match[1] : name; + }; + const name = emailAddress.split("<")[0].trim(); + const email = parseEmail(emailAddress); + + return ( +
+
{name}
+
{email}
+
+ ); +}); diff --git a/apps/web/components/GroupedTable.tsx b/apps/web/components/GroupedTable.tsx new file mode 100644 index 0000000000..be8babc08e --- /dev/null +++ b/apps/web/components/GroupedTable.tsx @@ -0,0 +1,485 @@ +"use client"; + +import Link from "next/link"; +import { Fragment, useMemo } from "react"; +import { useQueryState } from "nuqs"; +import groupBy from "lodash/groupBy"; +import { + useReactTable, + getCoreRowModel, + getExpandedRowModel, + type ColumnDef, + flexRender, +} from "@tanstack/react-table"; +import { ArchiveIcon, ChevronRight } from "lucide-react"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import type { Category } from "@prisma/client"; +import { EmailCell } from "@/components/EmailCell"; +import { useThreads } from "@/hooks/useThreads"; +import { Skeleton } from "@/components/ui/skeleton"; +import { decodeSnippet } from "@/utils/gmail/decode"; +import { formatShortDate } from "@/utils/date"; +import { cn } from "@/utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { changeSenderCategoryAction } from "@/utils/actions/categorize"; +import { toastError, toastSuccess } from "@/components/Toast"; +import { isActionError } from "@/utils/error"; +import { useAiCategorizationQueueItem } from "@/store/ai-categorize-sender-queue"; +import { LoadingMiniSpinner } from "@/components/Loading"; +import { Button } from "@/components/ui/button"; +import { + addToArchiveSenderQueue, + useArchiveSenderStatus, +} from "@/store/archive-sender-queue"; +import { getGmailSearchUrl, getGmailUrl } from "@/utils/url"; + +type EmailGroup = { + address: string; + category: Pick | null; + meta?: { + width?: string; + }; +}; + +export function GroupedTable({ + emailGroups, + categories, +}: { + emailGroups: EmailGroup[]; + categories: Pick[]; +}) { + const groupedEmails = useMemo(() => { + const grouped = groupBy( + emailGroups, + (group) => group.category?.name || "Uncategorized", + ); + + // Add empty arrays for categories without any emails + for (const category of categories) { + if (!grouped[category.name]) { + grouped[category.name] = []; + } + } + + return grouped; + }, [emailGroups, categories]); + + const [expanded, setExpanded] = useQueryState("expanded", { + parse: (value) => value.split(","), + serialize: (value) => value.join(","), + }); + + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "expander", + cell: ({ row }) => { + return row.getCanExpand() ? ( + + ) : null; + }, + meta: { size: "20px" }, + }, + { + accessorKey: "address", + cell: ({ row }) => ( + +
+ +
+ + ), + }, + { + accessorKey: "preview", + cell: ({ row }) => { + return ; + }, + }, + { + accessorKey: "date", + cell: ({ row }) => ( + + ), + }, + ], + [categories], + ); + + const table = useReactTable({ + data: emailGroups, + columns, + getRowCanExpand: () => true, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + }); + + return ( + + + {Object.entries(groupedEmails).map(([category, senders]) => { + const isCategoryExpanded = expanded?.includes(category); + + const onArchiveAll = async () => { + for (const sender of senders) { + await addToArchiveSenderQueue(sender.address); + } + }; + + return ( + + { + setExpanded((prev) => + isCategoryExpanded + ? (prev || []).filter((c) => c !== category) + : [...(prev || []), category], + ); + }} + onArchiveAll={onArchiveAll} + /> + {isCategoryExpanded && ( + + )} + + ); + })} + +
+ ); +} + +export function SendersTable({ + senders, + categories, +}: { + senders: EmailGroup[]; + categories: Pick[]; +}) { + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "expander", + cell: ({ row }) => { + return row.getCanExpand() ? ( + + ) : null; + }, + meta: { size: "20px" }, + }, + { + accessorKey: "address", + cell: ({ row }) => ( +
+ +
+ ), + }, + { + accessorKey: "preview", + }, + { + accessorKey: "category", + cell: ({ row }) => { + return ( + + ); + }, + }, + ], + [categories], + ); + + const table = useReactTable({ + data: senders, + columns, + getRowCanExpand: () => true, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + }); + + return ( + + + + +
+ ); +} + +function GroupRow({ + category, + count, + isExpanded, + onToggle, + onArchiveAll, +}: { + category: string; + count: number; + isExpanded: boolean; + onToggle: () => void; + onArchiveAll: () => void; +}) { + return ( + + +
+ + {category} + ({count}) +
+
+ + + +
+ ); +} + +function SenderRows({ + table, + senders, +}: { + table: ReturnType>; + senders: EmailGroup[]; +}) { + return senders.map((sender) => { + const row = table + .getRowModel() + .rows.find((r) => r.original.address === sender.address); + if (!row) return null; + return ( + + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + {row.getIsExpanded() && } + + ); + }); +} + +function ExpandedRows({ sender }: { sender: string }) { + const { data, isLoading, error } = useThreads({ + fromEmail: sender, + limit: 5, + type: "all", + }); + + if (isLoading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + Error loading emails + + ); + } + + if (!data?.threads.length) { + return ( + + No emails found + + ); + } + + return ( + <> + {data.threads.map((thread) => ( + + + + + {thread.messages[0].headers.subject} + + + {decodeSnippet(thread.messages[0].snippet)} + + {formatShortDate(new Date(thread.messages[0].headers.date))} + + + ))} + + ); +} + +function SelectCategoryCell({ + sender, + senderCategory, + categories, +}: { + sender: string; + senderCategory: Pick | null; + categories: Pick[]; +}) { + const item = useAiCategorizationQueueItem(sender); + + if (item?.status && item?.status !== "completed") { + return ( + + + Categorizing... + + ); + } + + return ( + + ); +} + +function ArchiveStatusCell({ sender }: { sender: string }) { + const status = useArchiveSenderStatus(sender); + + switch (status?.status) { + case "completed": + if (status.threadsTotal) { + return ( + + Archived {status.threadsTotal} emails! + + ); + } + return Archived; + case "processing": + return ( + + Archiving... {status.threadsTotal - status.threadIds.length} /{" "} + {status.threadsTotal} + + ); + case "pending": + return Pending...; + default: + return null; + } +} diff --git a/apps/web/components/Modal.tsx b/apps/web/components/Modal.tsx index 80764465bc..e3ddd31365 100644 --- a/apps/web/components/Modal.tsx +++ b/apps/web/components/Modal.tsx @@ -19,7 +19,6 @@ export interface ModalProps { title?: string; size?: "xl" | "2xl" | "4xl" | "6xl"; padding?: "sm" | "none"; - backdropClass?: string; } export function useModal() { @@ -30,10 +29,18 @@ export function useModal() { return { isModalOpen, openModal, closeModal, setIsModalOpen }; } -export function Modal(props: ModalProps) { +export function Modal({ + isOpen, + children, + padding, + fullWidth, + size, + title, + hideModal, +}: ModalProps) { return ( - - + + -
+
@@ -64,27 +66,24 @@ export function Modal(props: ModalProps) { className={clsx( "w-full transform rounded-2xl bg-white text-left align-middle shadow-xl transition-all", { - "p-6": props.padding === "sm", - "p-10": !props.padding, + "p-6": padding === "sm", + "p-10": !padding, "sm:w-full sm:max-w-xl": - !props.fullWidth && (!props.size || props.size === "xl"), - "sm:w-full sm:max-w-2xl": - !props.fullWidth && props.size === "2xl", - "sm:w-full sm:max-w-4xl": - !props.fullWidth && props.size === "4xl", - "sm:w-full sm:max-w-6xl": - !props.fullWidth && props.size === "6xl", - "sm:w-full sm:max-w-full": props.fullWidth, + !fullWidth && (!size || size === "xl"), + "sm:w-full sm:max-w-2xl": !fullWidth && size === "2xl", + "sm:w-full sm:max-w-4xl": !fullWidth && size === "4xl", + "sm:w-full sm:max-w-6xl": !fullWidth && size === "6xl", + "sm:w-full sm:max-w-full": fullWidth, }, )} > - {props.title && ( + {title && ( - {props.title} + {title} )} - {props.children} + {children}
diff --git a/apps/web/components/MultiSelectFilter.tsx b/apps/web/components/MultiSelectFilter.tsx new file mode 100644 index 0000000000..5b01022b30 --- /dev/null +++ b/apps/web/components/MultiSelectFilter.tsx @@ -0,0 +1,161 @@ +"use client"; + +import * as React from "react"; +import { CheckIcon } from "lucide-react"; +import { cn } from "@/utils"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandSeparator, +} from "@/components/ui/command"; + +interface MultiSelectFilterProps { + title?: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + selectedValues: Set; + setSelectedValues: (values: Set) => void; + maxDisplayedValues?: number; +} + +export function MultiSelectFilter({ + title, + options, + selectedValues, + setSelectedValues, + maxDisplayedValues, +}: MultiSelectFilterProps) { + return ( + + + + + + + + + No results found. + + + setSelectedValues( + new Set(options.map((option) => option.value)), + ) + } + className="justify-center text-center" + > + Select all + + + + {selectedValues.size > 0 && ( + <> + + setSelectedValues(new Set())} + className="justify-center text-center" + > + Clear filters + + + + + )} + + {options.map((option) => { + const isSelected = selectedValues.has(option.value); + return ( + { + const newSelectedValues = new Set(selectedValues); + if (isSelected) { + newSelectedValues.delete(option.value); + } else { + newSelectedValues.add(option.value); + } + setSelectedValues(newSelectedValues); + }} + > +
+ +
+ {option.icon && ( + + )} + {option.label} +
+ ); + })} +
+
+
+
+
+ ); +} + +export function useMultiSelectFilter(options: string[]) { + const [selectedValues, setSelectedValues] = React.useState>( + new Set(options), + ); + return { selectedValues, setSelectedValues }; +} diff --git a/apps/web/components/OnboardingModal.tsx b/apps/web/components/OnboardingModal.tsx index 26b2da0e7e..660804ec8d 100644 --- a/apps/web/components/OnboardingModal.tsx +++ b/apps/web/components/OnboardingModal.tsx @@ -1,7 +1,8 @@ "use client"; +import { useCallback, useEffect, useState } from "react"; +import { useLocalStorage, useWindowSize } from "usehooks-ts"; import { PlayIcon } from "lucide-react"; -import { useWindowSize } from "usehooks-ts"; import { useModal } from "@/components/Modal"; import { YouTubeVideo } from "@/components/YouTubeVideo"; import { Button } from "@/components/ui/button"; @@ -86,3 +87,29 @@ export function OnboardingModalDialog({
); } + +export const useOnboarding = (feature: string) => { + const [isOpen, setIsOpen] = useState(false); + const [hasViewedOnboarding, setHasViewedOnboarding] = useLocalStorage( + `viewed${feature}Onboarding`, + false, + ); + + useEffect(() => { + if (!hasViewedOnboarding) { + setIsOpen(true); + setHasViewedOnboarding(true); + } + }, [setHasViewedOnboarding, hasViewedOnboarding]); + + const onClose = useCallback(() => { + setIsOpen(false); + }, []); + + return { + isOpen, + hasViewedOnboarding, + setIsOpen, + onClose, + }; +}; diff --git a/apps/web/components/PremiumAlert.tsx b/apps/web/components/PremiumAlert.tsx index 489524bd72..6c81c3368f 100644 --- a/apps/web/components/PremiumAlert.tsx +++ b/apps/web/components/PremiumAlert.tsx @@ -48,24 +48,23 @@ export function usePremium() { } function PremiumAlert({ - plan = "Inbox Zero Business", + plan = "Inbox Zero AI", showSetApiKey, className, }: { - plan?: "Inbox Zero Business" | "Inbox Zero Pro"; + plan?: "Inbox Zero AI" | "Inbox Zero Pro"; showSetApiKey: boolean; className?: string; }) { const { PremiumModal, openModal } = usePremiumModal(); return ( - <> +
- This is a premium feature. Upgrade to {plan} + This is a premium feature. Upgrade to the {plan} {showSetApiKey ? ( <> {" "} @@ -88,7 +87,7 @@ function PremiumAlert({ variant="blue" /> - +
); } @@ -121,7 +120,7 @@ export function PremiumTooltip(props: { } > -
{props.children}
+ {props.children}
); } diff --git a/apps/web/components/Select.tsx b/apps/web/components/Select.tsx index 6061b77ecb..ce5b369d60 100644 --- a/apps/web/components/Select.tsx +++ b/apps/web/components/Select.tsx @@ -4,6 +4,7 @@ import { ErrorMessage, ExplainText, Label } from "@/components/Input"; interface SelectProps { name: string; label: string; + tooltipText?: string; options: Array<{ label: string; value: T }>; explainText?: string; error?: FieldError; @@ -16,7 +17,13 @@ export function Select( ) { return (
- {props.label ?