+
-
+
{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 (
+
+ );
+}
+
+function SetupContent({
+ imageSrc,
+ imageAlt,
+ title,
+ description,
+ features,
+ children,
+}: SetupContentProps) {
+ return (
+ <>
+
+
+
+ {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);