Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/web/app/(app)/(redirects)/bulk-archive/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { redirectToEmailAccountPath } from "@/utils/account";

export default async function BulkArchivePage() {
await redirectToEmailAccountPath("/bulk-archive");
}
5 changes: 5 additions & 0 deletions apps/web/app/(app)/(redirects)/quick-bulk-archive/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { redirectToEmailAccountPath } from "@/utils/account";

export default async function QuickBulkArchivePage() {
await redirectToEmailAccountPath("/quick-bulk-archive");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use client";

import { useState, useCallback } from "react";
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
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";

export function AutoCategorizationSetup({
hasCategorizedSenders,
}: {
hasCategorizedSenders: 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);
}

toast.success(
result?.data?.totalUncategorizedSenders
? `Categorizing ${result.data.totalUncategorizedSenders} senders... This may take a few minutes.`
: "No uncategorized senders found.",
);
} catch (error) {
toast.error(
`Failed to enable feature: ${error instanceof Error ? error.message : "Unknown error"}`,
);
setIsBulkCategorizing(false);
} finally {
setIsEnabling(false);
}
}, [emailAccountId, setIsBulkCategorizing]);
Comment on lines +27 to +51
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clear the categorizing flag when no senders are found.

When totalUncategorizedSenders is 0, the success toast displays "No uncategorized senders found", but setIsBulkCategorizing(true) from Line 29 is never cleared. This leaves the UI in a perpetual "categorizing" state even though no work is being performed.

🔎 Proposed fix
     try {
       const result = await bulkCategorizeSendersAction(emailAccountId);

       if (result?.serverError) {
         throw new Error(result.serverError);
       }

-      toast.success(
-        result?.data?.totalUncategorizedSenders
-          ? `Categorizing ${result.data.totalUncategorizedSenders} senders... This may take a few minutes.`
-          : "No uncategorized senders found.",
-      );
+      if (result?.data?.totalUncategorizedSenders) {
+        toast.success(
+          `Categorizing ${result.data.totalUncategorizedSenders} senders... This may take a few minutes.`
+        );
+      } else {
+        toast.success("No uncategorized senders found.");
+        setIsBulkCategorizing(false);
+      }
     } catch (error) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const enableFeature = useCallback(async () => {
setIsEnabling(true);
setIsBulkCategorizing(true);
try {
const result = await bulkCategorizeSendersAction(emailAccountId);
if (result?.serverError) {
throw new Error(result.serverError);
}
toast.success(
result?.data?.totalUncategorizedSenders
? `Categorizing ${result.data.totalUncategorizedSenders} senders... This may take a few minutes.`
: "No uncategorized senders found.",
);
} catch (error) {
toast.error(
`Failed to enable feature: ${error instanceof Error ? error.message : "Unknown error"}`,
);
setIsBulkCategorizing(false);
} finally {
setIsEnabling(false);
}
}, [emailAccountId, setIsBulkCategorizing]);
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) {
toast.success(
`Categorizing ${result.data.totalUncategorizedSenders} senders... This may take a few minutes.`
);
} else {
toast.success("No uncategorized senders found.");
setIsBulkCategorizing(false);
}
} catch (error) {
toast.error(
`Failed to enable feature: ${error instanceof Error ? error.message : "Unknown error"}`,
);
setIsBulkCategorizing(false);
} finally {
setIsEnabling(false);
}
}, [emailAccountId, setIsBulkCategorizing]);
🤖 Prompt for AI Agents
In @apps/web/app/(app)/[emailAccountId]/bulk-archive/AutoCategorizationSetup.tsx
around lines 27 - 51, enableFeature sets setIsBulkCategorizing(true) but never
clears it when there are zero uncategorized senders; update enableFeature (the
async callback) to call setIsBulkCategorizing(false) when
result?.data?.totalUncategorizedSenders is 0 or falsy (before or immediately
after showing the "No uncategorized senders found." toast), keeping the existing
error and finally handling intact so the UI doesn't remain stuck in the
categorizing state.


// Don't show setup if user already has categorized senders
if (hasCategorizedSenders) {
return null;
}

return (
<Card className="m-4">
<CardHeader>
<CardTitle>Bulk Archive</CardTitle>
<CardDescription>
Archive emails in bulk by sender category. We'll first categorize your
senders into groups like Newsletters, Receipts, Marketing, etc. Then
you can quickly archive entire categories at once.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={enableFeature} loading={isEnabling}>
Enable feature
</Button>
</CardContent>
</Card>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";

import { useMemo, useCallback } from "react";
import sortBy from "lodash/sortBy";
import useSWR from "swr";
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 type { CategorizedSendersResponse } from "@/app/api/user/categorize/senders/categorized/route";
import type { CategoryWithRules } from "@/utils/category.server";

type Sender = {
id: string;
email: string;
category: { id: string; description: string | null; name: string } | null;
};

export function BulkArchiveContent({
initialSenders,
initialCategories,
}: {
initialSenders: Sender[];
initialCategories: CategoryWithRules[];
}) {
const { isBulkCategorizing } = useCategorizeProgress();

// Poll for updates while categorization is in progress
const { data, mutate } = useSWR<CategorizedSendersResponse>(
"/api/user/categorize/senders/categorized",
{
refreshInterval: isBulkCategorizing ? 2000 : undefined,
fallbackData: { senders: initialSenders, categories: initialCategories },
},
);

// Use SWR data if available, otherwise fall back to initial server data
const senders = data?.senders ?? initialSenders;
const categories = data?.categories ?? initialCategories;

const emailGroups = useMemo(
() =>
sortBy(senders, (sender) => sender.category?.name).map((sender) => ({
address: sender.email,
category: categories.find((c) => c.id === sender.category?.id) || null,
})),
[senders, categories],
);

const handleProgressComplete = useCallback(() => {
// Refresh data when categorization completes
mutate();
}, [mutate]);

return (
<>
<BulkArchiveProgress onComplete={handleProgressComplete} />
<AutoCategorizationSetup hasCategorizedSenders={senders.length > 0} />
<BulkArchiveCards emailGroups={emailGroups} categories={categories} />
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"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);

const { data } = useSWR<CategorizeProgress>(
"/api/user/categorize/senders/progress",
{
refreshInterval: isBulkCategorizing ? 1000 : undefined,
},
);

// 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;
});
},
isBulkCategorizing ? 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 (!isBulkCategorizing || !data?.totalItems) {
return null;
}

const totalItems = data.totalItems || 0;
const displayedProgress = Math.max(data.completedItems || 0, fakeProgress);

return (
<ProgressPanel
totalItems={totalItems}
remainingItems={totalItems - displayedProgress}
inProgressText="Categorizing senders..."
completedText={`Categorization complete! ${displayedProgress} senders categorized!`}
itemLabel="senders"
/>
);
}
56 changes: 56 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/bulk-archive/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import prisma from "@/utils/prisma";
import { ClientOnly } from "@/components/ClientOnly";
import { TopBar } from "@/components/TopBar";
import { getUserCategoriesWithRules } from "@/utils/category.server";
import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck";
import { ArchiveProgress } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/ArchiveProgress";
import { PremiumAlertWithData } from "@/components/PremiumAlert";
import { checkUserOwnsEmailAccount } from "@/utils/email-account";
import { BulkArchiveContent } from "@/app/(app)/[emailAccountId]/bulk-archive/BulkArchiveContent";

export const dynamic = "force-dynamic";
export const maxDuration = 300;

export default async function BulkArchivePage({
params,
}: {
params: Promise<{ emailAccountId: string }>;
}) {
const { emailAccountId } = await params;
await checkUserOwnsEmailAccount({ emailAccountId });

const [senders, categories] = await Promise.all([
prisma.newsletter.findMany({
where: { emailAccountId, categoryId: { not: null } },
select: {
id: true,
email: true,
category: { select: { id: true, description: true, name: true } },
},
}),
getUserCategoriesWithRules({ emailAccountId }),
]);

return (
<>
<PermissionsCheck />

<ClientOnly>
<ArchiveProgress />
</ClientOnly>

<PremiumAlertWithData className="mx-2 mt-2 sm:mx-4" />

<TopBar className="items-center">
<h1 className="text-lg font-semibold">Bulk Archive</h1>
</TopBar>

<ClientOnly>
<BulkArchiveContent
initialSenders={senders}
initialCategories={categories}
/>
</ClientOnly>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function ConnectCalendar({

const setOnboardingReturnCookie = () => {
if (onboardingReturnPath) {
document.cookie = `${CALENDAR_ONBOARDING_RETURN_COOKIE}=${encodeURIComponent(onboardingReturnPath)}; path=/; max-age=180`;
document.cookie = `${CALENDAR_ONBOARDING_RETURN_COOKIE}=${encodeURIComponent(onboardingReturnPath)}; path=/; max-age=180; SameSite=Lax; Secure`;
}
};
Comment on lines 23 to 27
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Apply centralized cookie utility here as well.

The security attributes addition is good, but this is another instance of direct document.cookie assignment that should use the centralized cookie utility suggested in the review of apps/web/utils/auth-cookies.ts.

🔎 Refactor with cookie utility
+import { setCookie } from '@/utils/cookie-manager';
 import { CALENDAR_ONBOARDING_RETURN_COOKIE } from "@/utils/calendar/constants";

 const setOnboardingReturnCookie = () => {
   if (onboardingReturnPath) {
-    document.cookie = `${CALENDAR_ONBOARDING_RETURN_COOKIE}=${encodeURIComponent(onboardingReturnPath)}; path=/; max-age=180; SameSite=Lax; Secure`;
+    setCookie(CALENDAR_ONBOARDING_RETURN_COOKIE, onboardingReturnPath, {
+      maxAge: 180,
+    });
   }
 };

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx around
lines 23 - 27, Replace the direct document.cookie assignment in
setOnboardingReturnCookie with the centralized cookie utility used elsewhere
(the auth cookie helper), i.e., call the utility to set
CALENDAR_ONBOARDING_RETURN_COOKIE to encodeURIComponent(onboardingReturnPath)
and pass options for path:'/', maxAge:180, sameSite:'Lax', secure:true; update
references in ConnectCalendar.tsx to import and use that helper instead of
manipulating document.cookie directly.


Expand Down
Loading
Loading