diff --git a/apps/web/__tests__/ai-categorize-senders.test.ts b/apps/web/__tests__/ai-categorize-senders.test.ts index 8e66822d93..8edf235c8d 100644 --- a/apps/web/__tests__/ai-categorize-senders.test.ts +++ b/apps/web/__tests__/ai-categorize-senders.test.ts @@ -97,7 +97,7 @@ describe.runIf(isAiTest)("AI Sender Categorization", () => { "should categorize senders for all valid SenderCategory values", async () => { const senders = getEnabledCategories() - .filter((category) => category.name !== "Unknown") + .filter((category) => category.name !== "Other") .map((category) => `${category.name}@example.com`); const result = await aiCategorizeSenders({ diff --git a/apps/web/app/(app)/(redirects)/bulk-archive/page.tsx b/apps/web/app/(app)/(redirects)/bulk-archive/page.tsx new file mode 100644 index 0000000000..bd5d3ec58e --- /dev/null +++ b/apps/web/app/(app)/(redirects)/bulk-archive/page.tsx @@ -0,0 +1,5 @@ +import { redirectToEmailAccountPath } from "@/utils/account"; + +export default async function BulkArchivePage() { + await redirectToEmailAccountPath("/bulk-archive"); +} diff --git a/apps/web/app/(app)/(redirects)/quick-bulk-archive/page.tsx b/apps/web/app/(app)/(redirects)/quick-bulk-archive/page.tsx new file mode 100644 index 0000000000..961bee73ca --- /dev/null +++ b/apps/web/app/(app)/(redirects)/quick-bulk-archive/page.tsx @@ -0,0 +1,5 @@ +import { redirectToEmailAccountPath } from "@/utils/account"; + +export default async function QuickBulkArchivePage() { + await redirectToEmailAccountPath("/quick-bulk-archive"); +} diff --git a/apps/web/app/(app)/[emailAccountId]/briefs/Onboarding.tsx b/apps/web/app/(app)/[emailAccountId]/briefs/Onboarding.tsx index eb5263ab9e..343441935b 100644 --- a/apps/web/app/(app)/[emailAccountId]/briefs/Onboarding.tsx +++ b/apps/web/app/(app)/[emailAccountId]/briefs/Onboarding.tsx @@ -1,22 +1,28 @@ "use client"; -import { CardFooter, Card } from "@/components/ui/card"; -import { - MessageText, - SectionDescription, - TypographyH3, -} from "@/components/Typography"; -import { ConnectCalendar } from "@/app/(app)/[emailAccountId]/calendars/ConnectCalendar"; import { MailIcon, LightbulbIcon, UserSearchIcon } from "lucide-react"; +import { SetupCard } from "@/components/SetupCard"; +import { MessageText } from "@/components/Typography"; import { Button } from "@/components/ui/button"; -import { - Item, - ItemContent, - ItemDescription, - ItemGroup, - ItemTitle, -} from "@/components/ui/item"; -import Image from "next/image"; +import { ConnectCalendar } from "@/app/(app)/[emailAccountId]/calendars/ConnectCalendar"; + +const features = [ + { + icon: , + title: "Attendee research", + description: "Who they are, their company, and role", + }, + { + icon: , + title: "Email history", + description: "Recent conversations with this person", + }, + { + icon: , + title: "Key context", + description: "Important details from past discussions", + }, +]; export function BriefsOnboarding({ emailAccountId, @@ -30,72 +36,28 @@ export function BriefsOnboarding({ isEnabling?: boolean; }) { return ( - - Meeting Briefs - -
- Meeting Briefs - - Receive email briefings before meetings with external guests. - -
- - - - - - Attendee research - - Who they are, their company, and role - - - - - - - Email history - - Recent conversations with this person - - - - - - - Key context - - Important details from past discussions - - - - - - - {hasCalendarConnected ? ( - <> - - You're all set! Enable meeting briefs to get started: - - - - ) : ( - <> - Connect your calendar to get started: - - - )} - -
+ + {hasCalendarConnected ? ( + <> + + You're all set! Enable meeting briefs to get started: + + + + ) : ( + <> + Connect your calendar to get started: + + + )} + ); } diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-archive/AutoCategorizationSetup.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-archive/AutoCategorizationSetup.tsx new file mode 100644 index 0000000000..ca105b2c10 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/bulk-archive/AutoCategorizationSetup.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { toastError, toastSuccess } from "@/components/Toast"; +import { ArchiveIcon, RotateCcwIcon, TagsIcon } from "lucide-react"; +import { SetupDialog } from "@/components/SetupCard"; +import { Button } from "@/components/ui/button"; +import { bulkCategorizeSendersAction } from "@/utils/actions/categorize"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { useCategorizeProgress } from "@/app/(app)/[emailAccountId]/smart-categories/CategorizeProgress"; + +const features = [ + { + icon: , + title: "Sorted automatically", + description: + "We group senders into categories like Newsletters, Receipts, and Marketing", + }, + { + icon: , + title: "Archive by category", + description: + "Clean up an entire category at once instead of one email at a time", + }, + { + icon: , + title: "Always reversible", + description: "Emails are archived, not deleted — you can find them anytime", + }, +]; + +export function AutoCategorizationSetup({ open }: { open: boolean }) { + const { emailAccountId } = useAccount(); + const { setIsBulkCategorizing } = useCategorizeProgress(); + + const [isEnabling, setIsEnabling] = useState(false); + + const enableFeature = useCallback(async () => { + setIsEnabling(true); + setIsBulkCategorizing(true); + + try { + const result = await bulkCategorizeSendersAction(emailAccountId); + + if (result?.serverError) { + throw new Error(result.serverError); + } + + if (result?.data?.totalUncategorizedSenders) { + toastSuccess({ + description: `Categorizing ${result.data.totalUncategorizedSenders} senders... This may take a few minutes.`, + }); + } else { + toastSuccess({ description: "No uncategorized senders found." }); + setIsBulkCategorizing(false); + } + } catch (error) { + toastError({ + description: `Failed to enable feature: ${error instanceof Error ? error.message : "Unknown error"}`, + }); + setIsBulkCategorizing(false); + } finally { + setIsEnabling(false); + } + }, [emailAccountId, setIsBulkCategorizing]); + + return ( + + + + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-archive/BulkArchive.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-archive/BulkArchive.tsx new file mode 100644 index 0000000000..054c135eed --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/bulk-archive/BulkArchive.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useMemo, useCallback } from "react"; +import useSWR from "swr"; +import { parseAsBoolean, useQueryState } from "nuqs"; +import { AutoCategorizationSetup } from "@/app/(app)/[emailAccountId]/bulk-archive/AutoCategorizationSetup"; +import { BulkArchiveProgress } from "@/app/(app)/[emailAccountId]/bulk-archive/BulkArchiveProgress"; +import { BulkArchiveCards } from "@/components/BulkArchiveCards"; +import { useCategorizeProgress } from "@/app/(app)/[emailAccountId]/smart-categories/CategorizeProgress"; +import { CategorizeWithAiButton } from "@/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton"; +import type { CategorizedSendersResponse } from "@/app/api/user/categorize/senders/categorized/route"; +import { PageWrapper } from "@/components/PageWrapper"; +import { LoadingContent } from "@/components/LoadingContent"; +import { TooltipExplanation } from "@/components/TooltipExplanation"; +import { PageHeading } from "@/components/Typography"; + +export function BulkArchive() { + const { isBulkCategorizing } = useCategorizeProgress(); + const [onboarding] = useQueryState("onboarding", parseAsBoolean); + + // Fetch data with SWR and poll while categorization is in progress + const { data, error, isLoading, mutate } = useSWR( + "/api/user/categorize/senders/categorized", + { + refreshInterval: isBulkCategorizing ? 2000 : undefined, + }, + ); + + const senders = data?.senders ?? []; + const categories = data?.categories ?? []; + const autoCategorizeSenders = data?.autoCategorizeSenders ?? false; + + const emailGroups = useMemo( + () => + senders.map((sender) => ({ + address: sender.email, + name: sender.name ?? null, + category: categories.find((c) => c.id === sender.category?.id) || null, + })), + [senders, categories], + ); + + const handleProgressComplete = useCallback(() => { + mutate(); + }, [mutate]); + + // Show setup dialog for first-time setup only + const shouldShowSetup = + onboarding || (!autoCategorizeSenders && !isBulkCategorizing); + + return ( + + +
+
+ Bulk Archive + +
+ +
+ + +
+ +
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-archive/BulkArchiveProgress.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-archive/BulkArchiveProgress.tsx new file mode 100644 index 0000000000..968d2b5413 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/bulk-archive/BulkArchiveProgress.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import { ProgressPanel } from "@/components/ProgressPanel"; +import type { CategorizeProgress } from "@/app/api/user/categorize/senders/progress/route"; +import { useCategorizeProgress } from "@/app/(app)/[emailAccountId]/smart-categories/CategorizeProgress"; +import { useInterval } from "@/hooks/useInterval"; + +export function BulkArchiveProgress({ + onComplete, +}: { + onComplete?: () => void; +}) { + const { isBulkCategorizing, setIsBulkCategorizing } = useCategorizeProgress(); + const [fakeProgress, setFakeProgress] = useState(0); + + // Check if there's active progress (categorization in progress from server) + const { data } = useSWR( + "/api/user/categorize/senders/progress", + { + refreshInterval: 1000, // Always poll to detect ongoing categorization + }, + ); + + // Categorization is active if explicitly set OR if server shows incomplete progress + const hasActiveProgress = + data?.totalItems && data.completedItems < data.totalItems; + const isCategorizationActive = isBulkCategorizing || hasActiveProgress; + + // Sync local state with server state + useEffect(() => { + if (hasActiveProgress && !isBulkCategorizing) { + setIsBulkCategorizing(true); + } + }, [hasActiveProgress, isBulkCategorizing, setIsBulkCategorizing]); + + // Fake progress animation to make it feel responsive + useInterval( + () => { + if (!data?.totalItems) return; + + setFakeProgress((prev) => { + const realCompleted = data.completedItems || 0; + if (realCompleted > prev) return realCompleted; + + const maxProgress = Math.min( + Math.floor(data.totalItems * 0.9), + realCompleted + 30, + ); + return prev < maxProgress ? prev + 1 : prev; + }); + }, + isCategorizationActive ? 1500 : null, + ); + + // Handle completion + useEffect(() => { + let timeoutId: NodeJS.Timeout | undefined; + if ( + data?.completedItems && + data?.totalItems && + data.completedItems === data.totalItems + ) { + timeoutId = setTimeout(() => { + setIsBulkCategorizing(false); + setFakeProgress(0); + onComplete?.(); + }, 3000); + } + return () => { + if (timeoutId) clearTimeout(timeoutId); + }; + }, [ + data?.completedItems, + data?.totalItems, + setIsBulkCategorizing, + onComplete, + ]); + + if (!isCategorizationActive || !data?.totalItems) { + return null; + } + + const totalItems = data.totalItems || 0; + const displayedProgress = Math.max(data.completedItems || 0, fakeProgress); + + return ( + + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-archive/page.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-archive/page.tsx new file mode 100644 index 0000000000..92640d089b --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/bulk-archive/page.tsx @@ -0,0 +1,11 @@ +import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; +import { BulkArchive } from "@/app/(app)/[emailAccountId]/bulk-archive/BulkArchive"; + +export default function BulkArchivePage() { + return ( + <> + + + + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding/StepFeatures.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding/StepFeatures.tsx index f58016662e..fb4ced87d0 100644 --- a/apps/web/app/(app)/[emailAccountId]/onboarding/StepFeatures.tsx +++ b/apps/web/app/(app)/[emailAccountId]/onboarding/StepFeatures.tsx @@ -16,7 +16,6 @@ import { OnboardingWrapper } from "@/app/(app)/[emailAccountId]/onboarding/Onboa import { cn } from "@/utils"; import { Button } from "@/components/ui/button"; import { saveOnboardingFeaturesAction } from "@/utils/actions/onboarding"; -import { toastError } from "@/components/Toast"; // `value` is the value that will be saved to the database const choices = [ @@ -57,7 +56,6 @@ export function StepFeatures({ onNext }: { onNext: () => void }) { const [selectedChoices, setSelectedChoices] = useState>( new Map(), ); - const [isSaving, setIsSaving] = useState(false); return ( @@ -103,26 +101,16 @@ export function StepFeatures({ onNext }: { onNext: () => void }) { type="button" size="sm" className="mt-6" - loading={isSaving} - onClick={async () => { - setIsSaving(true); - + onClick={() => { // Get all selected features (only the ones that are true) const features = Array.from(selectedChoices.entries()) .filter(([_, isSelected]) => isSelected) - .map(([label, _]) => label); + .map(([label]) => label); + + // Fire and forget - don't block navigation + saveOnboardingFeaturesAction({ features }); - try { - await saveOnboardingFeaturesAction({ features }); - onNext(); - } catch (error) { - console.error("Failed to save features:", error); - toastError({ - title: "Failed to save your preferences", - description: "Please try again.", - }); - setIsSaving(false); - } + onNext(); }} > Continue diff --git a/apps/web/app/(app)/[emailAccountId]/quick-bulk-archive/BulkArchiveTab.tsx b/apps/web/app/(app)/[emailAccountId]/quick-bulk-archive/BulkArchiveTab.tsx new file mode 100644 index 0000000000..f7cefb2030 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/quick-bulk-archive/BulkArchiveTab.tsx @@ -0,0 +1,653 @@ +"use client"; + +import { useState, useMemo, useEffect } from "react"; +import useSWR from "swr"; +import sortBy from "lodash/sortBy"; +import { toast } from "sonner"; +import Link from "next/link"; +import { + ArchiveIcon, + CheckIcon, + ChevronDownIcon, + InboxIcon, + MailIcon, + MailOpenIcon, + MailXIcon, + BellOffIcon, + TrendingDownIcon, +} from "lucide-react"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmailCell } from "@/components/EmailCell"; +import { LoadingContent } from "@/components/LoadingContent"; +import { cn } from "@/utils"; +import { + addToArchiveSenderQueue, + useArchiveSenderStatus, +} from "@/store/archive-sender-queue"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { useThreads } from "@/hooks/useThreads"; +import { formatShortDate } from "@/utils/date"; +import { getEmailUrl } from "@/utils/url"; +import { + getArchiveCandidates, + type ConfidenceLevel, + type ArchiveCandidate, +} from "@/utils/bulk-archive/get-archive-candidates"; +import type { CategorizedSendersResponse } from "@/app/api/user/categorize/senders/categorized/route"; + +const confidenceConfig = { + high: { + label: "Safe to Archive", + description: "Marketing emails and newsletters you likely don't need", + icon: MailXIcon, + color: "text-green-600", + bgColor: "bg-green-50 dark:bg-green-950/30", + hoverBgColor: "hover:bg-green-100 dark:hover:bg-green-950/50", + borderColor: "border-green-200 dark:border-green-900", + badgeVariant: "default" as const, + }, + medium: { + label: "Probably Safe", + description: "Automated notifications and updates", + icon: BellOffIcon, + color: "text-amber-600", + bgColor: "bg-amber-50 dark:bg-amber-950/30", + hoverBgColor: "hover:bg-amber-100 dark:hover:bg-amber-950/50", + borderColor: "border-amber-200 dark:border-amber-900", + badgeVariant: "secondary" as const, + }, + low: { + label: "Review Recommended", + description: "Senders that may need a closer look", + icon: MailOpenIcon, + color: "text-blue-600", + bgColor: "bg-blue-50 dark:bg-blue-950/30", + hoverBgColor: "hover:bg-blue-100 dark:hover:bg-blue-950/50", + borderColor: "border-blue-200 dark:border-blue-900", + badgeVariant: "outline" as const, + }, +}; + +export function BulkArchiveTab() { + const { emailAccountId, userEmail } = useAccount(); + + const { data, error, isLoading } = useSWR( + "/api/user/categorize/senders/categorized", + ); + + const emailGroups = useMemo(() => { + if (!data) return []; + const sorted = sortBy(data.senders, (sender) => sender.category?.name); + return sorted.map((sender) => ({ + address: sender.email, + category: + data.categories.find((c) => c.id === sender.category?.id) || null, + })); + }, [data]); + + const [expandedSenders, setExpandedSenders] = useState< + Record + >({}); + const [selectedSenders, setSelectedSenders] = useState< + Record + >({}); + const [expandedSections, setExpandedSections] = useState< + Record + >({ + high: false, + medium: false, + low: false, + }); + const [isArchiving, setIsArchiving] = useState(false); + const [archiveComplete, setArchiveComplete] = useState(false); + const [hasInitializedSelection, setHasInitializedSelection] = useState(false); + + const candidates = useMemo( + () => getArchiveCandidates(emailGroups), + [emailGroups], + ); + + // Initialize selection when data loads + useEffect(() => { + if (candidates.length > 0 && !hasInitializedSelection) { + const initial: Record = {}; + for (const candidate of candidates) { + initial[candidate.address] = + candidate.confidence === "high" || candidate.confidence === "medium"; + } + setSelectedSenders(initial); + setHasInitializedSelection(true); + } + }, [candidates, hasInitializedSelection]); + + const groupedByConfidence = useMemo(() => { + const grouped: Record = { + high: [], + medium: [], + low: [], + }; + for (const candidate of candidates) { + grouped[candidate.confidence].push(candidate); + } + return grouped; + }, [candidates]); + + const selectedCount = useMemo(() => { + return Object.values(selectedSenders).filter(Boolean).length; + }, [selectedSenders]); + + const totalCount = candidates.length; + + const toggleSection = (level: ConfidenceLevel) => { + setExpandedSections((prev) => ({ + ...prev, + [level]: !prev[level], + })); + }; + + const toggleSenderSelection = (address: string) => { + setSelectedSenders((prev) => ({ + ...prev, + [address]: !prev[address], + })); + }; + + const toggleSenderExpanded = (address: string) => { + setExpandedSenders((prev) => ({ + ...prev, + [address]: !prev[address], + })); + }; + + const selectAllInSection = (level: ConfidenceLevel) => { + setSelectedSenders((prev) => { + const newSelected = { ...prev }; + for (const candidate of groupedByConfidence[level]) { + newSelected[candidate.address] = true; + } + return newSelected; + }); + }; + + const deselectAllInSection = (level: ConfidenceLevel) => { + setSelectedSenders((prev) => { + const newSelected = { ...prev }; + for (const candidate of groupedByConfidence[level]) { + newSelected[candidate.address] = false; + } + return newSelected; + }); + }; + + const getSelectedInSection = (level: ConfidenceLevel) => { + return groupedByConfidence[level].filter((c) => selectedSenders[c.address]) + .length; + }; + + const archiveSelected = async () => { + setIsArchiving(true); + const toArchive = candidates.filter((c) => selectedSenders[c.address]); + + try { + for (const candidate of toArchive) { + await addToArchiveSenderQueue({ + sender: candidate.address, + emailAccountId, + }); + } + setArchiveComplete(true); + } catch { + toast.error("Failed to archive some senders. Please try again."); + } finally { + setIsArchiving(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + {null} + + ); + } + + if (archiveComplete) { + return ( +
+ +
+ +
+

+ Archive Started! +

+

+ {selectedCount} senders are being archived in the background. +

+

+ Emails are archived, not deleted. You can find them in Gmail + anytime. +

+ +
+
+ ); + } + + if (totalCount === 0) { + return ( +
+ +
+ +
+

No Senders to Archive

+

+ Once our AI categorizes your senders, you'll see archive + suggestions here. +

+
+
+ ); + } + + return ( +
+ {/* Hero Card */} + +
+
+
+ +
+
+

Ready to Clean Up

+

+ We found{" "} + + {totalCount} + {" "} + senders you may want to archive +

+ +
+ {groupedByConfidence.high.length > 0 && ( +
+
+ + {groupedByConfidence.high.length} safe to archive + +
+ )} + {groupedByConfidence.medium.length > 0 && ( +
+
+ + {groupedByConfidence.medium.length} probably safe + +
+ )} + {groupedByConfidence.low.length > 0 && ( +
+
+ {groupedByConfidence.low.length} to review +
+ )} +
+ +
+ +
+
+
+
+ + {/* Progress bar */} +
+
+ + + {selectedCount} of {totalCount} senders selected + + + {Math.round((selectedCount / totalCount) * 100)}% inbox cleanup + +
+ +
+ + + {/* Confidence Sections */} +
+ {(["high", "medium", "low"] as ConfidenceLevel[]).map((level) => { + const config = confidenceConfig[level]; + const senders = groupedByConfidence[level]; + const Icon = config.icon; + const isExpanded = expandedSections[level]; + const selectedInSection = getSelectedInSection(level); + + if (senders.length === 0) return null; + + return ( + +
toggleSection(level)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleSection(level); + } + }} + role="button" + tabIndex={0} + > +
+
+
+ +
+
+
+

{config.label}

+ + {senders.length} + +
+

+ {config.description} +

+
+
+
+ + + {selectedInSection}/{senders.length} + + +
+
+
+ + {isExpanded && ( +
+ {senders.map((candidate) => ( + + toggleSenderSelection(candidate.address) + } + onToggleExpanded={() => + toggleSenderExpanded(candidate.address) + } + userEmail={userEmail} + /> + ))} +
+ )} +
+ ); + })} +
+
+ ); +} + +function SenderRow({ + candidate, + isSelected, + isExpanded, + onToggleSelection, + onToggleExpanded, + userEmail, +}: { + candidate: ArchiveCandidate; + isSelected: boolean; + isExpanded: boolean; + onToggleSelection: () => void; + onToggleExpanded: () => void; + userEmail: string; +}) { + const status = useArchiveSenderStatus(candidate.address); + + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggleExpanded(); + } + }} + role="button" + tabIndex={0} + > + { + e.stopPropagation(); + onToggleSelection(); + }} + className="size-5" + /> +
+ +
+
+ + {candidate.reason} + + + +
+
+ + {isExpanded && ( + + )} +
+ ); +} + +function ArchiveStatus({ + status, +}: { + status: ReturnType; +}) { + switch (status?.status) { + case "completed": + if (status.threadsTotal) { + return ( + + Archived {status.threadsTotal}! + + ); + } + return Archived; + case "processing": + return ( + + {status.threadsTotal - status.threadIds.length} /{" "} + {status.threadsTotal} + + ); + case "pending": + return Pending...; + default: + return null; + } +} + +function ExpandedEmails({ + sender, + userEmail, +}: { + sender: string; + userEmail: string; +}) { + const { provider } = useAccount(); + + 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.slice(0, 5).map((thread) => { + const firstMessage = thread.messages[0]; + if (!firstMessage) return null; + const subject = firstMessage.subject; + const date = firstMessage.date; + const snippet = thread.snippet || firstMessage.snippet; + + return ( +
+
+
+
+
+ + + + + {subject.length > 50 + ? `${subject.slice(0, 50)}...` + : subject} + + {snippet && ( + + {(() => { + const cleaned = snippet + .replace(/[\u034F\u200B-\u200D\uFEFF\u00A0]/g, "") + .trim() + .replace(/\s+/g, " "); + return cleaned.length > 80 + ? `${cleaned.slice(0, 80).trimEnd()}...` + : cleaned; + })()} + + )} + + + {formatShortDate(new Date(date))} + + +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/quick-bulk-archive/page.tsx b/apps/web/app/(app)/[emailAccountId]/quick-bulk-archive/page.tsx new file mode 100644 index 0000000000..ad98c3d4cb --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/quick-bulk-archive/page.tsx @@ -0,0 +1,26 @@ +import { ClientOnly } from "@/components/ClientOnly"; +import { PageWrapper } from "@/components/PageWrapper"; +import { PageHeader } from "@/components/PageHeader"; +import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; +import { ArchiveProgress } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/ArchiveProgress"; +import { BulkArchiveTab } from "@/app/(app)/[emailAccountId]/quick-bulk-archive/BulkArchiveTab"; + +export default function QuickBulkArchivePage() { + return ( + <> + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx index 8ed11fce1d..8d4405213c 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx @@ -70,7 +70,7 @@ export function CategorizeWithAiButton({ {buttonProps?.children || ( <> - Categorize Senders with AI + Categorize )} diff --git a/apps/web/app/(app)/early-access/page.tsx b/apps/web/app/(app)/early-access/page.tsx index abe11b5132..08c9e77964 100644 --- a/apps/web/app/(app)/early-access/page.tsx +++ b/apps/web/app/(app)/early-access/page.tsx @@ -21,20 +21,51 @@ export default function RequestAccessPage() {
{isGoogleProvider(provider) && ( - - - Sender Categories - - Sender Categories is a feature that allows you to categorize - emails by sender, and take bulk actions or apply rules to them. - - - - - - + <> + + + Sender Categories + + Sender Categories is a feature that allows you to categorize + emails by sender, and take bulk actions or apply rules to + them. + + + + + + + {/* + + Bulk Archive + + Archive emails from multiple senders at once, organized by + category. + + + + + + */} + {/* + + Quick Bulk Archive + + Quickly archive emails from multiple senders at once, grouped + by AI confidence level. + + + + + + */} + )} diff --git a/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts b/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts index 09d55fd010..8fb3028adc 100644 --- a/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts +++ b/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts @@ -1,18 +1,17 @@ import { NextResponse } from "next/server"; import { aiCategorizeSendersSchema } from "@/app/api/user/categorize/senders/batch/handle-batch-validation"; -import { getThreadsFromSenderWithSubject } from "@/utils/gmail/thread"; import { categorizeWithAi, getCategories, updateSenderCategory, } from "@/utils/categorize/senders/categorize"; import { validateUserAndAiAccess } from "@/utils/user/validate"; -import { getGmailClientWithRefresh } from "@/utils/gmail/client"; import { UNKNOWN_CATEGORY } from "@/utils/ai/categorize-sender/ai-categorize-senders"; import prisma from "@/utils/prisma"; import { saveCategorizationProgress } from "@/utils/redis/categorization-progress"; import { SafeError } from "@/utils/error"; import type { RequestWithLogger } from "@/utils/middleware"; +import { createEmailProvider } from "@/utils/email/provider"; export async function handleBatchRequest( request: RequestWithLogger, @@ -47,9 +46,6 @@ async function handleBatchInternal(request: RequestWithLogger) { select: { account: { select: { - access_token: true, - refresh_token: true, - expires_at: true, provider: true, }, }, @@ -59,14 +55,10 @@ async function handleBatchInternal(request: RequestWithLogger) { const account = emailAccountWithAccount?.account; if (!account) throw new SafeError("No account found"); - if (!account.access_token || !account.refresh_token) - throw new SafeError("No access or refresh token"); - const gmail = await getGmailClientWithRefresh({ - accessToken: account.access_token, - refreshToken: account.refresh_token, - expiresAt: account.expires_at?.getTime() || null, + const emailProvider = await createEmailProvider({ emailAccountId, + provider: account.provider, logger: request.logger, }); @@ -75,12 +67,8 @@ async function handleBatchInternal(request: RequestWithLogger) { // 1. fetch 3 messages for each sender for (const sender of senders) { - const threadsFromSender = await getThreadsFromSenderWithSubject( - gmail, - account.access_token, - sender, - 3, - ); + const threadsFromSender = + await emailProvider.getThreadsFromSenderWithSubject(sender, 3); sendersWithEmails.set(sender, threadsFromSender); } diff --git a/apps/web/app/api/user/categorize/senders/categorized/route.ts b/apps/web/app/api/user/categorize/senders/categorized/route.ts new file mode 100644 index 0000000000..beae4a93f1 --- /dev/null +++ b/apps/web/app/api/user/categorize/senders/categorized/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withEmailAccount } from "@/utils/middleware"; +import { getUserCategoriesWithRules } from "@/utils/category.server"; + +export type CategorizedSendersResponse = Awaited< + ReturnType +>; + +async function getCategorizedSenders({ + emailAccountId, +}: { + emailAccountId: string; +}) { + const [senders, categories, emailAccount] = await Promise.all([ + prisma.newsletter.findMany({ + where: { emailAccountId, categoryId: { not: null } }, + select: { + id: true, + email: true, + name: true, + category: { select: { id: true, description: true, name: true } }, + }, + }), + getUserCategoriesWithRules({ emailAccountId }), + prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { autoCategorizeSenders: true }, + }), + ]); + + return { + senders, + categories, + autoCategorizeSenders: emailAccount?.autoCategorizeSenders ?? false, + }; +} + +export const GET = withEmailAccount( + "user/categorize/senders/categorized", + async (request) => { + const emailAccountId = request.auth.emailAccountId; + const result = await getCategorizedSenders({ emailAccountId }); + return NextResponse.json(result); + }, +); diff --git a/apps/web/components/BulkArchiveCards.tsx b/apps/web/components/BulkArchiveCards.tsx new file mode 100644 index 0000000000..dd98705f4f --- /dev/null +++ b/apps/web/components/BulkArchiveCards.tsx @@ -0,0 +1,488 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useQueryState } from "nuqs"; +import groupBy from "lodash/groupBy"; +import { + ArchiveIcon, + CheckIcon, + ChevronDownIcon, + MailIcon, +} from "lucide-react"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmailCell } from "@/components/EmailCell"; +import { useThreads } from "@/hooks/useThreads"; +import { formatShortDate } from "@/utils/date"; +import { cn } from "@/utils"; +import { toastError } from "@/components/Toast"; +import { + addToArchiveSenderQueue, + useArchiveSenderStatus, +} from "@/store/archive-sender-queue"; +import { getEmailUrl } from "@/utils/url"; +import type { CategoryWithRules } from "@/utils/category.server"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { getCategoryStyle } from "@/components/bulk-archive/categoryIcons"; +import { defaultCategory } from "@/utils/categories"; +import type { EmailGroup } from "@/utils/bulk-archive/get-archive-candidates"; + +export function BulkArchiveCards({ + emailGroups, + categories, +}: { + emailGroups: EmailGroup[]; + categories: CategoryWithRules[]; +}) { + const { emailAccountId, userEmail } = useAccount(); + const [expandedCategory, setExpandedCategory] = useQueryState("expanded"); + const [expandedSenders, setExpandedSenders] = useState< + Record + >({}); + const [archivedCategories, setArchivedCategories] = useState< + Record + >({}); + const [selectedSenders, setSelectedSenders] = useState< + Record + >({}); + + const categoryMap = useMemo(() => { + return categories.reduce>( + (acc, category) => { + acc[category.name] = category; + return acc; + }, + {}, + ); + }, [categories]); + + const groupedEmails = useMemo(() => { + const grouped = groupBy( + emailGroups, + (group) => + categoryMap[group.category?.name || ""]?.name || "Uncategorized", + ); + + // Add empty arrays for categories without any emails + for (const category of categories) { + if (!grouped[category.name]) { + grouped[category.name] = []; + } + } + + // Always show default categories with 0 senders if no categories exist + if (categories.length === 0) { + for (const cat of Object.values(defaultCategory)) { + if (!grouped[cat.name]) { + grouped[cat.name] = []; + } + } + } + + return grouped; + }, [emailGroups, categories, categoryMap]); + + // Sort categories alphabetically, but always put Other and Uncategorized last + const sortedCategoryEntries = useMemo(() => { + return Object.entries(groupedEmails).sort(([a], [b]) => { + if (a === "Uncategorized") return 1; + if (b === "Uncategorized") return -1; + if (a === defaultCategory.OTHER.name) return 1; + if (b === defaultCategory.OTHER.name) return -1; + return a.localeCompare(b); + }); + }, [groupedEmails]); + + const toggleCategory = (categoryName: string) => { + if (expandedCategory !== categoryName) { + initializeSenders(categoryName); + } + setExpandedCategory( + expandedCategory === categoryName ? null : categoryName, + ); + }; + + const toggleSender = (senderAddress: string) => { + setExpandedSenders((prev) => ({ + ...prev, + [senderAddress]: !prev[senderAddress], + })); + }; + + const initializeSenders = (categoryName: string) => { + const senders = groupedEmails[categoryName] || []; + const newSelected = { ...selectedSenders }; + for (const sender of senders) { + if (newSelected[sender.address] === undefined) { + newSelected[sender.address] = true; + } + } + setSelectedSenders(newSelected); + }; + + const toggleSenderSelection = ( + senderAddress: string, + e: React.MouseEvent, + ) => { + e.stopPropagation(); + setSelectedSenders((prev) => ({ + ...prev, + [senderAddress]: !prev[senderAddress], + })); + }; + + const getSelectedCount = (categoryName: string) => { + const senders = groupedEmails[categoryName] || []; + return senders.filter((s) => selectedSenders[s.address] !== false).length; + }; + + const archiveCategory = async (categoryName: string, e: React.MouseEvent) => { + e.stopPropagation(); + const senders = groupedEmails[categoryName] || []; + const selectedToArchive = senders.filter( + (s) => selectedSenders[s.address] !== false, + ); + + try { + for (const sender of selectedToArchive) { + await addToArchiveSenderQueue({ + sender: sender.address, + emailAccountId, + }); + } + + setArchivedCategories((prev) => ({ ...prev, [categoryName]: true })); + } catch (_error) { + toastError({ + description: "Failed to archive some senders. Please try again.", + }); + } + }; + + return ( +
+ {sortedCategoryEntries.map(([categoryName, senders]) => { + const category = categoryMap[categoryName]; + const categoryStyle = getCategoryStyle(categoryName); + const CategoryIcon = categoryStyle.icon; + + // Get default category info if no category exists + const defaultCat = Object.values(defaultCategory).find( + (c) => c.name === categoryName, + ); + + // Skip if no category found and not a default category (but allow Uncategorized) + if (!category && !defaultCat && categoryName !== "Uncategorized") + return null; + + const isExpanded = expandedCategory === categoryName; + const isArchived = archivedCategories[categoryName]; + + return ( + + {/* Category header - clickable to expand */} +
toggleCategory(categoryName)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleCategory(categoryName); + } + }} + role="button" + tabIndex={0} + > +
+
+
+
+ +
+
+
+

+ {categoryName} +

+

+ {senders.length} senders + {isArchived && " archived"} + {!isArchived && + (category?.description || defaultCat?.description) && + ` · ${category?.description || defaultCat?.description}`} +

+
+
+
+ {isArchived ? ( +
+ + Archived +
+ ) : ( + + )} + +
+
+
+ + {/* Expanded sender list */} + {isExpanded && ( +
+
+ {senders.length === 0 ? ( +
+ No senders in this category +
+ ) : ( + senders.map((sender) => ( + toggleSender(sender.address)} + onToggleSelection={(e) => + toggleSenderSelection(sender.address, e) + } + userEmail={userEmail} + /> + )) + )} +
+
+ )} +
+ ); + })} +
+ ); +} + +function SenderRow({ + sender, + isExpanded, + isSelected, + onToggle, + onToggleSelection, + userEmail, +}: { + sender: EmailGroup; + isExpanded: boolean; + isSelected: boolean; + onToggle: () => void; + onToggleSelection: (e: React.MouseEvent) => void; + userEmail: string; +}) { + const status = useArchiveSenderStatus(sender.address); + + return ( +
+ {/* Sender row */} +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggle(); + } + }} + role="button" + tabIndex={0} + > + { + e.stopPropagation(); + onToggleSelection(e); + }} + className="size-5" + /> +
+ +
+
+ +
+ +
+ + {/* Expanded email list */} + {isExpanded && ( + + )} +
+ ); +} + +function ArchiveStatus({ + status, +}: { + status: ReturnType; +}) { + switch (status?.status) { + case "completed": + if (status.threadsTotal) { + return ( + + Archived {status.threadsTotal}! + + ); + } + return Archived; + case "processing": + return ( + + {status.threadsTotal - status.threadIds.length} /{" "} + {status.threadsTotal} + + ); + case "pending": + return Pending...; + default: + return null; + } +} + +function ExpandedEmails({ + sender, + userEmail, +}: { + sender: string; + userEmail: string; +}) { + const { provider } = useAccount(); + + 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.slice(0, 5).map((thread) => { + const firstMessage = thread.messages[0]; + if (!firstMessage) return null; + const subject = firstMessage.subject; + const date = firstMessage.date; + const snippet = thread.snippet || firstMessage.snippet; + + return ( +
+
+
+
+
+ + + + + {subject.length > 50 + ? `${subject.slice(0, 50)}...` + : subject} + + {snippet && ( + + {(() => { + // Remove invisible/zero-width chars and normalize whitespace + const cleaned = snippet + .replace(/[\u034F\u200B-\u200D\uFEFF\u00A0]/g, "") + .trim() + .replace(/\s+/g, " "); + return cleaned.length > 80 + ? `${cleaned.slice(0, 80).trimEnd()}...` + : cleaned; + })()} + + )} + + + {formatShortDate(new Date(date))} + + +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/components/EmailCell.tsx b/apps/web/components/EmailCell.tsx index ec430459ce..87b5301726 100644 --- a/apps/web/components/EmailCell.tsx +++ b/apps/web/components/EmailCell.tsx @@ -1,23 +1,23 @@ import { memo } from "react"; +import { extractNameFromEmail, extractEmailAddress } from "@/utils/email"; export const EmailCell = memo(function EmailCell({ emailAddress, + name, className, }: { emailAddress: string; + name?: string | null; className?: string; }) { - const parseEmail = (name: string) => { - const match = name.match(/<(.+)>/); - return match ? match[1] : name; - }; - const name = emailAddress.split("<")[0].trim(); - const email = parseEmail(emailAddress); + const displayName = name || extractNameFromEmail(emailAddress); + const email = extractEmailAddress(emailAddress) || emailAddress; + const showEmail = displayName !== email; return (
-
{name}
-
{email}
+
{displayName}
+ {showEmail &&
{email}
}
); }); diff --git a/apps/web/components/ProgressPanel.tsx b/apps/web/components/ProgressPanel.tsx index a8f907d67d..5f90030adc 100644 --- a/apps/web/components/ProgressPanel.tsx +++ b/apps/web/components/ProgressPanel.tsx @@ -25,7 +25,7 @@ export function ProgressPanel({ if (!totalItems) return null; return ( -
+
-

+

{totalProcessed} of {totalItems} {itemLabel} processed -

+
diff --git a/apps/web/components/SetupCard.tsx b/apps/web/components/SetupCard.tsx new file mode 100644 index 0000000000..cab33f7d96 --- /dev/null +++ b/apps/web/components/SetupCard.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { ReactNode } from "react"; +import Image from "next/image"; +import { Card, CardFooter } from "@/components/ui/card"; +import { SectionDescription, TypographyH3 } from "@/components/Typography"; +import { + Item, + ItemContent, + ItemDescription, + ItemGroup, + ItemTitle, +} from "@/components/ui/item"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +type FeatureItem = { + icon: ReactNode; + title: string; + description: string; +}; + +type SetupContentProps = { + imageSrc: string; + imageAlt: string; + title: string; + description: string; + features: FeatureItem[]; + children: ReactNode; +}; + +export function SetupCard(props: SetupContentProps) { + return ( + + + + ); +} + +export function SetupDialog({ + open, + ...props +}: SetupContentProps & { open: boolean }) { + return ( + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + hideCloseButton + > + + {props.title} + {props.description} + + + + + ); +} + +function SetupContent({ + imageSrc, + imageAlt, + title, + description, + features, + children, +}: SetupContentProps) { + return ( + <> + {imageAlt} + +
+ {title} + + {description} + +
+ + + {features.map((feature) => ( + + {feature.icon} + + {feature.title} + {feature.description} + + + ))} + + + + {children} + + + ); +} diff --git a/apps/web/components/bulk-archive/categoryIcons.ts b/apps/web/components/bulk-archive/categoryIcons.ts new file mode 100644 index 0000000000..6704e2dd2b --- /dev/null +++ b/apps/web/components/bulk-archive/categoryIcons.ts @@ -0,0 +1,71 @@ +import { + BellIcon, + MailIcon, + MegaphoneIcon, + NewspaperIcon, + ReceiptIcon, +} from "lucide-react"; + +export function getCategoryIcon(categoryName: string) { + const name = categoryName.toLowerCase(); + + if (name.includes("newsletter")) return NewspaperIcon; + if (name.includes("marketing")) return MegaphoneIcon; + if (name.includes("receipt")) return ReceiptIcon; + if (name.includes("notification")) return BellIcon; + + // Default icon for "Other" and any other category + return MailIcon; +} + +export function getCategoryStyle(categoryName: string) { + const name = categoryName.toLowerCase(); + + if (name.includes("newsletter")) { + return { + icon: NewspaperIcon, + iconColor: "text-new-purple-600", + borderColor: "from-new-purple-200 to-new-purple-300", + gradient: "from-new-purple-50 to-new-purple-100", + }; + } + if (name.includes("marketing")) { + return { + icon: MegaphoneIcon, + iconColor: "text-new-orange-600", + borderColor: "from-new-orange-150 to-new-orange-200", + gradient: "from-new-orange-50 to-new-orange-100", + }; + } + if (name.includes("receipt")) { + return { + icon: ReceiptIcon, + iconColor: "text-new-green-500", + borderColor: "from-new-green-150 to-new-green-200", + gradient: "from-new-green-50 to-new-green-100", + }; + } + if (name.includes("notification")) { + return { + icon: BellIcon, + iconColor: "text-new-blue-600", + borderColor: "from-new-blue-150 to-new-blue-200", + gradient: "from-new-blue-50 to-new-blue-100", + }; + } + if (name === "uncategorized") { + return { + icon: MailIcon, + iconColor: "text-new-indigo-600", + borderColor: "from-new-indigo-150 to-new-indigo-200", + gradient: "from-new-indigo-50 to-new-indigo-100", + }; + } + // Default for "Other" and any other category + return { + icon: MailIcon, + iconColor: "text-gray-500", + borderColor: "from-gray-200 to-gray-300", + gradient: "from-gray-50 to-gray-100", + }; +} diff --git a/apps/web/components/ui/dialog.tsx b/apps/web/components/ui/dialog.tsx index 9385549d4a..254526c113 100644 --- a/apps/web/components/ui/dialog.tsx +++ b/apps/web/components/ui/dialog.tsx @@ -31,8 +31,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + hideCloseButton?: boolean; + } +>(({ className, children, hideCloseButton, ...props }, ref) => ( {children} - - - Close - + {!hideCloseButton && ( + + + Close + + )} )); diff --git a/apps/web/prisma/migrations/20260109163518_newsletter_sender_name/migration.sql b/apps/web/prisma/migrations/20260109163518_newsletter_sender_name/migration.sql new file mode 100644 index 0000000000..4b84ff7742 --- /dev/null +++ b/apps/web/prisma/migrations/20260109163518_newsletter_sender_name/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Newsletter" ADD COLUMN "name" TEXT; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 846623fb59..f71be77a50 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -653,6 +653,7 @@ model Newsletter { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt email String + name String? status NewsletterStatus? // For learned patterns for rules diff --git a/apps/web/public/images/illustrations/working-vacation.svg b/apps/web/public/images/illustrations/working-vacation.svg new file mode 100644 index 0000000000..e35da82f9f --- /dev/null +++ b/apps/web/public/images/illustrations/working-vacation.svg @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index f7cb25de1b..a086440d74 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -31,6 +31,26 @@ export const bulkCategorizeSendersAction = actionClient .action(async ({ ctx: { emailAccountId, logger } }) => { await validateUserAndAiAccess({ emailAccountId }); + // Ensure default categories exist before categorizing + const categoriesToCreate = Object.values(defaultCategory) + .filter((c) => c.enabled) + .map((c) => ({ + emailAccountId, + name: c.name, + description: c.description, + })); + + await prisma.category.createMany({ + data: categoriesToCreate, + skipDuplicates: true, + }); + + // Enable auto-categorization for this email account + await prisma.emailAccount.update({ + where: { id: emailAccountId }, + data: { autoCategorizeSenders: true }, + }); + // Delete empty queues as Qstash has a limit on how many queues we can have // We could run this in a cron too but simplest to do here for now deleteEmptyCategorizeSendersQueues({ diff --git a/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts b/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts index 61e2c7404a..71055b6cb0 100644 --- a/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts +++ b/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts @@ -8,7 +8,7 @@ import { getModel } from "@/utils/llms/model"; import { createGenerateObject } from "@/utils/llms"; export const REQUEST_MORE_INFORMATION_CATEGORY = "RequestMoreInformation"; -export const UNKNOWN_CATEGORY = "Unknown"; +export const UNKNOWN_CATEGORY = "Other"; const categorizeSendersSchema = z.object({ senders: z.array( @@ -75,14 +75,14 @@ ${formatCategoriesForPrompt(categories)} 1. Analyze each sender's email address and their recent emails for categorization. 2. If the sender's category is clear, assign it. -3. Use "Unknown" if the category is unclear or multiple categories could apply. +3. Use "${UNKNOWN_CATEGORY}" if the category is unclear or multiple categories could apply. 4. Use "${REQUEST_MORE_INFORMATION_CATEGORY}" if more context is needed. - Accuracy is more important than completeness - Only use the categories provided above -- Respond with "Unknown" if unsure +- Respond with "${UNKNOWN_CATEGORY}" if unsure - Return your response in JSON format `; diff --git a/apps/web/utils/bulk-archive/get-archive-candidates.test.ts b/apps/web/utils/bulk-archive/get-archive-candidates.test.ts new file mode 100644 index 0000000000..9d7a8ffb9d --- /dev/null +++ b/apps/web/utils/bulk-archive/get-archive-candidates.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "vitest"; +import { + getArchiveCandidates, + type EmailGroup, +} from "./get-archive-candidates"; + +function createEmailGroup( + address: string, + categoryName: string | null, +): EmailGroup { + return { + address, + category: categoryName + ? ({ id: "cat-1", name: categoryName, description: null } as any) + : null, + }; +} + +describe("getArchiveCandidates", () => { + describe("high confidence classification", () => { + it("should classify marketing category as high confidence", () => { + const groups = [createEmailGroup("test@example.com", "Marketing")]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("high"); + expect(result[0].reason).toBe("Marketing / Promotional"); + }); + + it("should classify promotion category as high confidence", () => { + const groups = [createEmailGroup("test@example.com", "Promotions")]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("high"); + expect(result[0].reason).toBe("Marketing / Promotional"); + }); + + it("should classify newsletter category as high confidence", () => { + const groups = [createEmailGroup("test@example.com", "Newsletter")]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("high"); + expect(result[0].reason).toBe("Marketing / Promotional"); + }); + + it("should classify sale category as high confidence", () => { + const groups = [createEmailGroup("test@example.com", "Sales")]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("high"); + expect(result[0].reason).toBe("Marketing / Promotional"); + }); + + it("should match category names case-insensitively", () => { + const groups = [ + createEmailGroup("test1@example.com", "MARKETING"), + createEmailGroup("test2@example.com", "Newsletter"), + createEmailGroup("test3@example.com", "promotional"), + ]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("high"); + expect(result[1].confidence).toBe("high"); + expect(result[2].confidence).toBe("high"); + }); + + it("should match partial category names containing high confidence keywords", () => { + const groups = [ + createEmailGroup("test1@example.com", "Email Marketing"), + createEmailGroup("test2@example.com", "Weekly Newsletter"), + createEmailGroup("test3@example.com", "Flash Sale Alerts"), + ]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("high"); + expect(result[1].confidence).toBe("high"); + expect(result[2].confidence).toBe("high"); + }); + }); + + describe("medium confidence classification", () => { + it("should classify notification category as medium confidence", () => { + const groups = [createEmailGroup("test@example.com", "Notifications")]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("medium"); + expect(result[0].reason).toBe("Automated notification"); + }); + + it("should classify alert category as medium confidence", () => { + const groups = [createEmailGroup("test@example.com", "Alerts")]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("medium"); + expect(result[0].reason).toBe("Automated notification"); + }); + + it("should classify receipt category as medium confidence", () => { + const groups = [createEmailGroup("test@example.com", "Receipts")]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("medium"); + expect(result[0].reason).toBe("Automated notification"); + }); + + it("should classify update category as medium confidence", () => { + const groups = [createEmailGroup("test@example.com", "Updates")]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("medium"); + expect(result[0].reason).toBe("Automated notification"); + }); + + it("should match partial category names containing medium confidence keywords", () => { + const groups = [ + createEmailGroup("test1@example.com", "Account Notifications"), + createEmailGroup("test2@example.com", "Security Alerts"), + createEmailGroup("test3@example.com", "Purchase Receipts"), + createEmailGroup("test4@example.com", "Product Updates"), + ]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("medium"); + expect(result[1].confidence).toBe("medium"); + expect(result[2].confidence).toBe("medium"); + expect(result[3].confidence).toBe("medium"); + }); + }); + + describe("low confidence classification", () => { + it("should classify uncategorized senders as low confidence", () => { + const groups = [createEmailGroup("test@example.com", null)]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("low"); + expect(result[0].reason).toBe("Other category"); + }); + + it("should classify unrecognized categories as low confidence", () => { + const groups = [ + createEmailGroup("test1@example.com", "Personal"), + createEmailGroup("test2@example.com", "Work"), + createEmailGroup("test3@example.com", "Finance"), + ]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("low"); + expect(result[1].confidence).toBe("low"); + expect(result[2].confidence).toBe("low"); + }); + + it("should classify empty category name as low confidence", () => { + const groups = [ + { + address: "test@example.com", + category: { id: "cat-1", name: "", description: null } as any, + }, + ]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("low"); + }); + }); + + describe("preserves original data", () => { + it("should preserve the email address in the result", () => { + const groups = [createEmailGroup("unique@example.com", "Marketing")]; + const result = getArchiveCandidates(groups); + + expect(result[0].address).toBe("unique@example.com"); + }); + + it("should preserve the category in the result", () => { + const category = { + id: "cat-123", + name: "Marketing", + description: "Marketing emails", + } as any; + const groups = [{ address: "test@example.com", category }]; + const result = getArchiveCandidates(groups); + + expect(result[0].category).toBe(category); + }); + }); + + describe("batch processing", () => { + it("should handle empty array", () => { + const result = getArchiveCandidates([]); + + expect(result).toEqual([]); + }); + + it("should correctly classify multiple senders with different confidence levels", () => { + const groups = [ + createEmailGroup("marketing@example.com", "Marketing"), + createEmailGroup("alerts@example.com", "Alerts"), + createEmailGroup("personal@example.com", "Personal"), + ]; + const result = getArchiveCandidates(groups); + + expect(result[0].confidence).toBe("high"); + expect(result[1].confidence).toBe("medium"); + expect(result[2].confidence).toBe("low"); + }); + + it("should maintain order of input", () => { + const groups = [ + createEmailGroup("first@example.com", "Personal"), + createEmailGroup("second@example.com", "Marketing"), + createEmailGroup("third@example.com", "Alerts"), + ]; + const result = getArchiveCandidates(groups); + + expect(result[0].address).toBe("first@example.com"); + expect(result[1].address).toBe("second@example.com"); + expect(result[2].address).toBe("third@example.com"); + }); + }); +}); diff --git a/apps/web/utils/bulk-archive/get-archive-candidates.ts b/apps/web/utils/bulk-archive/get-archive-candidates.ts new file mode 100644 index 0000000000..094a093961 --- /dev/null +++ b/apps/web/utils/bulk-archive/get-archive-candidates.ts @@ -0,0 +1,65 @@ +import type { CategoryWithRules } from "@/utils/category.server"; + +export type EmailGroup = { + address: string; + name: string | null; + category: CategoryWithRules | null; +}; + +export type ConfidenceLevel = "high" | "medium" | "low"; + +export type ArchiveCandidate = { + address: string; + category: CategoryWithRules | null; + confidence: ConfidenceLevel; + reason: string; +}; + +/** + * Classifies email senders into archive confidence levels based on their category. + * - High confidence: marketing, promotions, newsletters, sales + * - Medium confidence: notifications, alerts, receipts, updates + * - Low confidence: everything else + */ +export function getArchiveCandidates( + emailGroups: EmailGroup[], +): ArchiveCandidate[] { + return emailGroups.map((group) => { + const categoryName = group.category?.name?.toLowerCase() || ""; + + // High confidence: marketing, promotions, newsletters + if ( + categoryName.includes("marketing") || + categoryName.includes("promotion") || + categoryName.includes("newsletter") || + categoryName.includes("sale") + ) { + return { + ...group, + confidence: "high" as ConfidenceLevel, + reason: "Marketing / Promotional", + }; + } + + // Medium confidence: notifications, receipts, automated + if ( + categoryName.includes("notification") || + categoryName.includes("alert") || + categoryName.includes("receipt") || + categoryName.includes("update") + ) { + return { + ...group, + confidence: "medium" as ConfidenceLevel, + reason: "Automated notification", + }; + } + + // Low confidence: everything else + return { + ...group, + confidence: "low" as ConfidenceLevel, + reason: "Other category", + }; + }); +} diff --git a/apps/web/utils/categories.ts b/apps/web/utils/categories.ts index a8310afade..188269816a 100644 --- a/apps/web/utils/categories.ts +++ b/apps/web/utils/categories.ts @@ -1,9 +1,9 @@ export const defaultCategory = { - UNKNOWN: { - name: "Unknown", + // Primary categories - used in rules and bulk archive UI + OTHER: { + name: "Other", enabled: true, - description: - "Senders that don't fit any other category or can't be classified", + description: "Senders that don't fit any other category", }, NEWSLETTER: { name: "Newsletter", @@ -22,86 +22,28 @@ export const defaultCategory = { description: "Purchase confirmations, order receipts, and payment confirmations", }, - BANKING: { - name: "Banking", - enabled: true, - description: - "Financial institutions, banks, and payment services that send statements and alerts", - }, - LEGAL: { - name: "Legal", - enabled: true, - description: - "Terms of service updates, legal notices, contracts, and legal communications", - }, - SUPPORT: { - name: "Support", - enabled: true, - description: "Customer service and support", - }, - PERSONAL: { - name: "Personal", - enabled: true, - description: "Personal communications from friends and family", - }, - SOCIAL: { - name: "Social", - enabled: true, - description: "Social media platforms and their notification systems", - }, - TRAVEL: { - name: "Travel", - enabled: true, - description: "Airlines, hotels, booking services, and travel agencies", - }, - EVENTS: { - name: "Events", - enabled: true, - description: - "Event invitations, reminders, schedules, and registration information", - }, - ACCOUNT: { - name: "Account", - enabled: true, - description: - "Account security notifications, password resets, and settings updates", - }, - SHOPPING: { - name: "Shopping", - enabled: false, - description: - "Shopping updates, wishlist notifications, shipping updates, and retail communications", - }, - WORK: { - name: "Work", - enabled: false, - description: - "Professional contacts, colleagues, and work-related communications", - }, - EDUCATIONAL: { - name: "Educational", - enabled: false, - description: - "Educational institutions, online learning platforms, and course providers", - }, - HEALTH: { - name: "Health", - enabled: false, - description: - "Healthcare providers, medical offices, and health service platforms", - }, - GOVERNMENT: { - name: "Government", - enabled: false, - description: - "Government agencies, departments, and official communication channels", - }, - ENTERTAINMENT: { - name: "Entertainment", - enabled: false, - description: - "Streaming services, gaming platforms, and entertainment providers", - }, + NOTIFICATION: { + name: "Notification", + enabled: true, + description: "Automated alerts, system notifications, and status updates", + }, + // TODO: Secondary categories for future two-round categorization + // These would refine "Other" senders for analytics purposes. + // Implementation: After primary categorization, if result is "Other", + // make a second AI call with only secondary categories. + // See: aiCategorizeSendersTwoRound in ai-categorize-senders.ts (commented out) + // + // BANKING: { name: "Banking", enabled: false, description: "Financial institutions, banks, and payment services" }, + // LEGAL: { name: "Legal", enabled: false, description: "Legal notices, contracts, and legal communications" }, + // INVESTOR: { name: "Investor", enabled: false, description: "VCs, stock alerts, portfolio updates, cap table tools" }, + // PERSONAL: { name: "Personal", enabled: false, description: "Personal communications from friends and family" }, + // WORK: { name: "Work", enabled: false, description: "Professional contacts and work-related communications" }, + // TRAVEL: { name: "Travel", enabled: false, description: "Airlines, hotels, booking services" }, + // SUPPORT: { name: "Support", enabled: false, description: "Customer service and support" }, + // EVENTS: { name: "Events", enabled: false, description: "Event invitations and reminders" }, + // EDUCATIONAL: { name: "Educational", enabled: false, description: "Educational institutions and courses" }, + // HEALTH: { name: "Health", enabled: false, description: "Healthcare providers and medical services" }, + // GOVERNMENT: { name: "Government", enabled: false, description: "Government agencies and official communications" }, } as const; export type SenderCategoryKey = keyof typeof defaultCategory; diff --git a/apps/web/utils/categorize/senders/categorize.ts b/apps/web/utils/categorize/senders/categorize.ts index 6e1572aefd..169e679bfe 100644 --- a/apps/web/utils/categorize/senders/categorize.ts +++ b/apps/web/utils/categorize/senders/categorize.ts @@ -59,11 +59,13 @@ export async function categorizeSender( export async function updateSenderCategory({ emailAccountId, sender, + senderName, categories, categoryName, }: { emailAccountId: string; sender: string; + senderName?: string | null; categories: Pick[]; categoryName: string; }) { @@ -87,9 +89,13 @@ export async function updateSenderCategory({ where: { email_emailAccountId: { email: sender, emailAccountId }, }, - update: { categoryId: category.id }, + update: { + categoryId: category.id, + ...(senderName && { name: senderName }), + }, create: { email: sender, + name: senderName, emailAccountId, categoryId: category.id, }, @@ -104,18 +110,22 @@ export async function updateSenderCategory({ export async function updateCategoryForSender({ emailAccountId, sender, + senderName, categoryId, }: { emailAccountId: string; sender: string; + senderName?: string | null; categoryId: string; }) { const email = extractEmailAddress(sender); + await prisma.newsletter.upsert({ where: { email_emailAccountId: { email, emailAccountId } }, - update: { categoryId }, + update: { categoryId, ...(senderName && { name: senderName }) }, create: { email, + name: senderName, emailAccountId, categoryId, }, diff --git a/apps/web/utils/internal-api.ts b/apps/web/utils/internal-api.ts index 2180b9813e..9a96a24525 100644 --- a/apps/web/utils/internal-api.ts +++ b/apps/web/utils/internal-api.ts @@ -4,7 +4,13 @@ import type { Logger } from "@/utils/logger"; export const INTERNAL_API_KEY_HEADER = "x-api-key"; export function getInternalApiUrl(): string { - return env.INTERNAL_API_URL || env.NEXT_PUBLIC_BASE_URL; + const url = env.INTERNAL_API_URL || env.NEXT_PUBLIC_BASE_URL; + + if (!url.startsWith("http://") && !url.startsWith("https://")) { + return `https://${url}`; + } + + return url; } export const isValidInternalApiKey = ( diff --git a/apps/web/utils/redis/categorization-progress.ts b/apps/web/utils/redis/categorization-progress.ts index 784b1d4d7a..04caaffdc2 100644 --- a/apps/web/utils/redis/categorization-progress.ts +++ b/apps/web/utils/redis/categorization-progress.ts @@ -61,3 +61,12 @@ export async function saveCategorizationProgress({ await redis.set(key, updatedProgress, { ex: 2 * 60 }); return updatedProgress; } + +export async function deleteCategorizationProgress({ + emailAccountId, +}: { + emailAccountId: string; +}) { + const key = getKey({ emailAccountId }); + await redis.del(key); +} diff --git a/apps/web/utils/upstash/index.ts b/apps/web/utils/upstash/index.ts index 18e9164571..1eb2fc2f2e 100644 --- a/apps/web/utils/upstash/index.ts +++ b/apps/web/utils/upstash/index.ts @@ -72,9 +72,18 @@ export async function publishToQstashQueue({ const client = getQstashClient(); if (client) { - const queue = client.queue({ queueName }); - queue.upsert({ parallelism }); - return await queue.enqueueJSON({ url, body, headers }); + try { + const queue = client.queue({ queueName }); + await queue.upsert({ parallelism }); + return await queue.enqueueJSON({ url, body, headers }); + } catch (error) { + logger.error("Failed to publish to Qstash queue", { + url, + queueName, + error, + }); + throw error; + } } return fallbackPublishToQstash(url, body);