From f06a79f2f1c96ee8fa6440b2494287dd47094256 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 5 Oct 2025 12:50:11 +0300 Subject: [PATCH 01/17] allow renaming system fields --- .../assistant/settings/SettingsTab.tsx | 2 + .../settings/SystemLabelsSetting.tsx | 178 ++++++++++++++++ .../20251005093547_label_id/migration.sql | 13 ++ apps/web/prisma/schema.prisma | 8 + apps/web/utils/actions/settings.ts | 32 +++ apps/web/utils/ai/actions.ts | 30 ++- apps/web/utils/ai/assistant/chat.ts | 1 + .../utils/cold-email/is-cold-email.test.ts | 66 +++--- apps/web/utils/cold-email/is-cold-email.ts | 17 +- apps/web/utils/email/google.ts | 15 +- apps/web/utils/email/microsoft.ts | 17 +- apps/web/utils/email/types.ts | 4 +- apps/web/utils/label-config.ts | 195 ++++++++++++++++++ apps/web/utils/reply-tracker/label-helpers.ts | 118 +++++++++++ apps/web/utils/rule/rule.ts | 74 +++++-- 15 files changed, 682 insertions(+), 88 deletions(-) create mode 100644 apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx create mode 100644 apps/web/prisma/migrations/20251005093547_label_id/migration.sql create mode 100644 apps/web/utils/label-config.ts create mode 100644 apps/web/utils/reply-tracker/label-helpers.ts diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx index ca67fa81b2..642a54cccf 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx @@ -6,12 +6,14 @@ import { AwaitingReplySetting } from "@/app/(app)/[emailAccountId]/assistant/set import { ReferralSignatureSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/ReferralSignatureSetting"; import { LearnedPatternsSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/LearnedPatternsSetting"; import { PersonalSignatureSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/PersonalSignatureSetting"; +import { SystemLabelsSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting"; export function SettingsTab() { return (
+ diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx new file mode 100644 index 0000000000..7603e43942 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useCallback, useState, useEffect } from "react"; +import { SettingCard } from "@/components/SettingCard"; +import { Button } from "@/components/ui/button"; +import { Select } from "@/components/Select"; +import { Label } from "@/components/Input"; +import { toastError, toastSuccess } from "@/components/Toast"; +import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; +import { LoadingContent } from "@/components/LoadingContent"; +import { updateSystemLabelsAction } from "@/utils/actions/settings"; +import { useLabels } from "@/hooks/useLabels"; +import { + AWAITING_REPLY_LABEL_NAME, + NEEDS_REPLY_LABEL_NAME, +} from "@/utils/reply-tracker/consts"; +import { inboxZeroLabels } from "@/utils/label"; + +export function SystemLabelsSetting() { + const { + data: emailAccountData, + isLoading: isLoadingAccount, + error: accountError, + mutate, + } = useEmailAccountFull(); + const { userLabels, isLoading: isLoadingLabels } = useLabels(); + const [isSaving, setIsSaving] = useState(false); + + const [needsReplyLabelId, setNeedsReplyLabelId] = useState( + null, + ); + const [awaitingReplyLabelId, setAwaitingReplyLabelId] = useState< + string | null + >(null); + const [coldEmailLabelId, setColdEmailLabelId] = useState(null); + // const [doneLabelId, setDoneLabelId] = useState(null); + + useEffect(() => { + if (emailAccountData) { + setNeedsReplyLabelId(emailAccountData.needsReplyLabelId ?? null); + setAwaitingReplyLabelId(emailAccountData.awaitingReplyLabelId ?? null); + setColdEmailLabelId(emailAccountData.coldEmailLabelId ?? null); + // setDoneLabelId(emailAccountData.doneLabelId ?? null); + } + }, [emailAccountData]); + + const labelOptions = + userLabels?.map((label: { id: string; name: string }) => ({ + label: label.name, + value: label.id, + })) ?? []; + + const handleSave = useCallback(async () => { + if (!emailAccountData?.id) return; + + setIsSaving(true); + try { + const result = await updateSystemLabelsAction(emailAccountData.id, { + needsReplyLabelId: needsReplyLabelId ?? undefined, + awaitingReplyLabelId: awaitingReplyLabelId ?? undefined, + coldEmailLabelId: coldEmailLabelId ?? undefined, + // doneLabelId: doneLabelId ?? undefined, + }); + + if (result?.serverError) { + toastError({ description: result.serverError }); + return; + } + + toastSuccess({ description: "System labels updated" }); + await mutate(); + } finally { + setIsSaving(false); + } + }, [ + emailAccountData?.id, + needsReplyLabelId, + awaitingReplyLabelId, + coldEmailLabelId, + // doneLabelId, + mutate, + ]); + + const hasChanges = + needsReplyLabelId !== (emailAccountData?.needsReplyLabelId ?? null) || + awaitingReplyLabelId !== (emailAccountData?.awaitingReplyLabelId ?? null) || + coldEmailLabelId !== (emailAccountData?.coldEmailLabelId ?? null); + // || + // doneLabelId !== (emailAccountData?.doneLabelId ?? null); + + return ( + +
+
+
+ +
+
*/} + + +
+ + } + /> + ); +} diff --git a/apps/web/prisma/migrations/20251005093547_label_id/migration.sql b/apps/web/prisma/migrations/20251005093547_label_id/migration.sql new file mode 100644 index 0000000000..2b1538f734 --- /dev/null +++ b/apps/web/prisma/migrations/20251005093547_label_id/migration.sql @@ -0,0 +1,13 @@ +-- AlterTable +ALTER TABLE "Action" ADD COLUMN "labelId" TEXT; + +-- AlterTable +ALTER TABLE "EmailAccount" ADD COLUMN "awaitingReplyLabelId" TEXT, +ADD COLUMN "coldEmailLabelId" TEXT, +ADD COLUMN "needsReplyLabelId" TEXT; + +-- AlterTable +ALTER TABLE "ExecutedAction" ADD COLUMN "labelId" TEXT; + +-- AlterTable +ALTER TABLE "ScheduledAction" ADD COLUMN "labelId" TEXT; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index fb485bb347..a536f5760e 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -129,6 +129,11 @@ model EmailAccount { outboundReplyTracking Boolean @default(false) // When true: manages "To Reply" and "Awaiting Reply" labels automatically autoCategorizeSenders Boolean @default(false) + // Configurable system labels (store stable IDs) + needsReplyLabelId String? // Gmail: Label ID, Outlook: Category ID + awaitingReplyLabelId String? // Gmail: Label ID, Outlook: Category ID + coldEmailLabelId String? // Gmail: Label ID, Outlook: Category ID + digestSchedule Schedule? userId String @@ -424,6 +429,7 @@ model Action { rule Rule @relation(fields: [ruleId], references: [id], onDelete: Cascade) label String? + labelId String? // Stable ID: Label ID (Gmail) or Category ID (Outlook) subject String? content String? to String? @@ -501,6 +507,7 @@ model ExecutedAction { // optional extra fields to be used with the action label String? + labelId String? // Stable ID: Label ID (Gmail) or Category ID (Outlook) subject String? content String? to String? @@ -532,6 +539,7 @@ model ScheduledAction { schedulingStatus SchedulingStatus @default(PENDING) label String? + labelId String? // Stable ID: Label ID (Gmail) or Category ID (Outlook) subject String? content String? to String? diff --git a/apps/web/utils/actions/settings.ts b/apps/web/utils/actions/settings.ts index 537ffe6916..d4229a6dae 100644 --- a/apps/web/utils/actions/settings.ts +++ b/apps/web/utils/actions/settings.ts @@ -209,3 +209,35 @@ export const updateDigestItemsAction = actionClient return { success: true }; }, ); + +export const updateSystemLabelsAction = actionClient + .metadata({ name: "updateSystemLabels" }) + .schema( + z.object({ + needsReplyLabelId: z.string().optional(), + awaitingReplyLabelId: z.string().optional(), + coldEmailLabelId: z.string().optional(), + doneLabelId: z.string().optional(), + }), + ) + .action( + async ({ + ctx: { emailAccountId }, + parsedInput: { + needsReplyLabelId, + awaitingReplyLabelId, + coldEmailLabelId, + }, + }) => { + await prisma.emailAccount.update({ + where: { id: emailAccountId }, + data: { + needsReplyLabelId: needsReplyLabelId ?? null, + awaitingReplyLabelId: awaitingReplyLabelId ?? null, + coldEmailLabelId: coldEmailLabelId ?? null, + }, + }); + + return { success: true }; + }, + ); diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index a219d0e10d..2ea502d84a 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -80,13 +80,29 @@ const archive: ActionFunction> = async ({ await client.archiveThread(email.threadId, userEmail); }; -const label: ActionFunction<{ label?: string | null }> = async ({ - client, - email, - args, -}) => { - if (!args.label) return; - await client.labelMessage(email.id, args.label); +const label: ActionFunction<{ + label?: string | null; + labelId?: string | null; +}> = async ({ client, email, args }) => { + let labelIdToUse = args.labelId; + + // Lazy migration: If no labelId but label name exists, look it up + if (!labelIdToUse && args.label) { + const matchingLabel = await client.getLabelByName(args.label); + + if (matchingLabel) { + labelIdToUse = matchingLabel.id; + // Note: We don't update the Action here to avoid race conditions + // The Action will be migrated when the rule is next updated + } else { + logger.warn("Label not found", { labelName: args.label }); + return; + } + } + + if (!labelIdToUse) return; + + await client.labelMessage(email.id, labelIdToUse); }; const draft: ActionFunction<{ diff --git a/apps/web/utils/ai/assistant/chat.ts b/apps/web/utils/ai/assistant/chat.ts index 2939aa2c20..628852a566 100644 --- a/apps/web/utils/ai/assistant/chat.ts +++ b/apps/web/utils/ai/assistant/chat.ts @@ -463,6 +463,7 @@ const updateRuleActionsTool = ({ delayInMinutes: action.delayInMinutes ?? null, })), provider, + emailAccountId, }); return { diff --git a/apps/web/utils/cold-email/is-cold-email.test.ts b/apps/web/utils/cold-email/is-cold-email.test.ts index 1105095cf0..34d7163edc 100644 --- a/apps/web/utils/cold-email/is-cold-email.test.ts +++ b/apps/web/utils/cold-email/is-cold-email.test.ts @@ -4,6 +4,7 @@ import { ColdEmailSetting, ColdEmailStatus } from "@prisma/client"; import { blockColdEmail } from "./is-cold-email"; import { getEmailAccount } from "@/__tests__/helpers"; import type { EmailProvider } from "@/utils/email/types"; +import { getOrCreateSystemLabelId } from "@/utils/label-config"; // Mock dependencies vi.mock("server-only", () => ({})); @@ -13,15 +14,25 @@ vi.mock("@/utils/prisma", () => ({ coldEmail: { upsert: vi.fn(), }, + emailAccount: { + findUnique: vi.fn(), + update: vi.fn(), + }, }, })); +vi.mock("@/utils/label-config", () => ({ + getOrCreateSystemLabelId: vi.fn(), +})); + describe("blockColdEmail", () => { const mockProvider = { - getOrCreateInboxZeroLabel: vi.fn(), labelMessage: vi.fn(), archiveThread: vi.fn(), markReadThread: vi.fn(), + moveThreadToFolder: vi.fn(), + getOrCreateOutlookFolderIdByName: vi.fn(), + name: "google", } as unknown as EmailProvider; const mockEmail = { @@ -37,15 +48,12 @@ describe("blockColdEmail", () => { beforeEach(() => { vi.clearAllMocks(); + + // Mock getOrCreateSystemLabelId to return a label ID + vi.mocked(getOrCreateSystemLabelId).mockResolvedValue("label123"); }); it("should upsert cold email record in database", async () => { - vi.mocked(mockProvider.getOrCreateInboxZeroLabel).mockResolvedValue({ - id: "label123", - name: "Cold Email", - type: "user", - }); - await blockColdEmail({ provider: mockProvider, email: mockEmail, @@ -73,12 +81,6 @@ describe("blockColdEmail", () => { }); it("should add cold email label when coldEmailBlocker is LABEL", async () => { - vi.mocked(mockProvider.getOrCreateInboxZeroLabel).mockResolvedValue({ - id: "label123", - name: "Cold Email", - type: "user", - }); - await blockColdEmail({ provider: mockProvider, email: mockEmail, @@ -86,12 +88,14 @@ describe("blockColdEmail", () => { aiReason: mockAiReason, }); - expect(mockProvider.getOrCreateInboxZeroLabel).toHaveBeenCalledWith( - "cold_email", - ); + expect(getOrCreateSystemLabelId).toHaveBeenCalledWith({ + emailAccountId: mockEmailAccount.id, + type: "coldEmail", + provider: mockProvider, + }); expect(mockProvider.labelMessage).toHaveBeenCalledWith( mockEmail.id, - "Cold Email", + "label123", ); }); @@ -100,11 +104,6 @@ describe("blockColdEmail", () => { ...mockEmailAccount, coldEmailBlocker: ColdEmailSetting.ARCHIVE_AND_LABEL, }; - vi.mocked(mockProvider.getOrCreateInboxZeroLabel).mockResolvedValue({ - id: "label123", - name: "Cold Email", - type: "user", - }); await blockColdEmail({ provider: mockProvider, @@ -115,7 +114,7 @@ describe("blockColdEmail", () => { expect(mockProvider.labelMessage).toHaveBeenCalledWith( mockEmail.id, - "Cold Email", + "label123", ); expect(mockProvider.archiveThread).toHaveBeenCalledWith( mockEmail.threadId, @@ -128,11 +127,6 @@ describe("blockColdEmail", () => { ...mockEmailAccount, coldEmailBlocker: ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL, }; - vi.mocked(mockProvider.getOrCreateInboxZeroLabel).mockResolvedValue({ - id: "label123", - name: "Cold Email", - type: "user", - }); await blockColdEmail({ provider: mockProvider, @@ -143,7 +137,7 @@ describe("blockColdEmail", () => { expect(mockProvider.labelMessage).toHaveBeenCalledWith( mockEmail.id, - "Cold Email", + "label123", ); expect(mockProvider.archiveThread).toHaveBeenCalledWith( mockEmail.threadId, @@ -169,11 +163,7 @@ describe("blockColdEmail", () => { }); it("should handle missing label id", async () => { - vi.mocked(mockProvider.getOrCreateInboxZeroLabel).mockResolvedValue({ - id: "", - name: "Cold Email", - type: "user", - }); + vi.mocked(getOrCreateSystemLabelId).mockResolvedValue(null); await blockColdEmail({ provider: mockProvider, @@ -182,10 +172,8 @@ describe("blockColdEmail", () => { aiReason: mockAiReason, }); - expect(mockProvider.labelMessage).toHaveBeenCalledWith( - mockEmail.id, - "Cold Email", - ); + expect(getOrCreateSystemLabelId).toHaveBeenCalled(); + expect(mockProvider.labelMessage).not.toHaveBeenCalled(); }); it("should not modify labels when coldEmailBlocker is DISABLED", async () => { @@ -201,7 +189,7 @@ describe("blockColdEmail", () => { aiReason: mockAiReason, }); - expect(mockProvider.getOrCreateInboxZeroLabel).not.toHaveBeenCalled(); + expect(getOrCreateSystemLabelId).not.toHaveBeenCalled(); expect(mockProvider.labelMessage).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/utils/cold-email/is-cold-email.ts b/apps/web/utils/cold-email/is-cold-email.ts index a324ba689f..9fd9a27426 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -14,6 +14,7 @@ import type { EmailForLLM } from "@/utils/types"; import type { EmailProvider } from "@/utils/email/types"; import { getModel, type ModelType } from "@/utils/llms/model"; import { createGenerateObject } from "@/utils/llms"; +import { getOrCreateSystemLabelId } from "@/utils/label-config"; export const COLD_EMAIL_FOLDER_NAME = "Cold Emails"; @@ -221,9 +222,12 @@ export async function blockColdEmail(options: { ) { if (!emailAccount.email) throw new Error("User email is required"); - // For Outlook, we'll use categories instead of labels - const coldEmailLabel = - await provider.getOrCreateInboxZeroLabel("cold_email"); + // Get or create the configured cold email label + const coldEmailLabelId = await getOrCreateSystemLabelId({ + emailAccountId: emailAccount.id, + type: "coldEmail", + provider, + }); const shouldArchive = emailAccount.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_LABEL || @@ -234,10 +238,9 @@ export async function blockColdEmail(options: { emailAccount.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL; - // For Outlook, we'll use the provider's labelMessage method - // The provider will handle the differences between Gmail labels and Outlook categories - if (coldEmailLabel?.name) { - await provider.labelMessage(email.id, coldEmailLabel.name); + // Apply the cold email label using stable ID + if (coldEmailLabelId) { + await provider.labelMessage(email.id, coldEmailLabelId); } // For archiving and marking as read, we'll need to implement these in the provider diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 68cbd33f31..6340d88ca0 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -147,6 +147,11 @@ export class GmailProvider implements EmailProvider { } } + async getLabelByName(name: string): Promise { + const labels = await this.getLabels(); + return labels.find((label) => label.name === name) || null; + } + async getMessage(messageId: string): Promise { const message = await getMessage(messageId, this.client, "full"); return parseMessage(message); @@ -250,17 +255,11 @@ export class GmailProvider implements EmailProvider { }); } - async labelMessage(messageId: string, labelName: string): Promise { - const label = await getOrCreateLabel({ - gmail: this.client, - name: labelName, - }); - if (!label.id) - throw new Error("Label not found and unable to create label"); + async labelMessage(messageId: string, labelId: string): Promise { await labelMessage({ gmail: this.client, messageId, - addLabelIds: [label.id], + addLabelIds: [labelId], }); } diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 1cec23c6d3..1cc9a36777 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -127,6 +127,11 @@ export class OutlookProvider implements EmailProvider { return labels.find((label) => label.id === labelId) || null; } + async getLabelByName(name: string): Promise { + const labels = await this.getLabels(); + return labels.find((label) => label.name === name) || null; + } + async getMessage(messageId: string): Promise { try { const message = await getMessage(messageId, this.client); @@ -297,15 +302,15 @@ export class OutlookProvider implements EmailProvider { }); } - async labelMessage(messageId: string, labelName: string): Promise { - const label = await getOrCreateLabel({ - client: this.client, - name: labelName, - }); + async labelMessage(messageId: string, labelId: string): Promise { + const category = await this.getLabelById(labelId); + if (!category) { + throw new Error(`Category with ID ${labelId} not found`); + } await labelMessage({ client: this.client, messageId, - categories: [label.displayName || ""], + categories: [category.name], }); } diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index aa803dac3e..d1ea061f6b 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -46,6 +46,7 @@ export interface EmailProvider { getThread(threadId: string): Promise; getLabels(): Promise; getLabelById(labelId: string): Promise; + getLabelByName(name: string): Promise; getMessage(messageId: string): Promise; getMessagesByFields(options: { froms?: string[]; @@ -86,8 +87,7 @@ export interface EmailProvider { ownerEmail: string, actionSource: "user" | "automation", ): Promise; - labelMessage(messageId: string, labelName: string): Promise; - + labelMessage(messageId: string, labelId: string): Promise; removeThreadLabel(threadId: string, labelId: string): Promise; getNeedsReplyLabel(): Promise; getAwaitingReplyLabel(): Promise; diff --git a/apps/web/utils/label-config.ts b/apps/web/utils/label-config.ts new file mode 100644 index 0000000000..25248bc576 --- /dev/null +++ b/apps/web/utils/label-config.ts @@ -0,0 +1,195 @@ +import prisma from "@/utils/prisma"; +import type { EmailProvider } from "@/utils/email/types"; +import { createScopedLogger } from "@/utils/logger"; +import { + NEEDS_REPLY_LABEL_NAME, + AWAITING_REPLY_LABEL_NAME, + NEEDS_REPLY_LABEL_NAME_LEGACY, + AWAITING_REPLY_LABEL_NAME_LEGACY, +} from "@/utils/reply-tracker/consts"; +import { inboxZeroLabels } from "@/utils/label"; + +const logger = createScopedLogger("label-config"); + +type SystemLabelType = "needsReply" | "awaitingReply" | "coldEmail" | "done"; + +export async function getOrCreateSystemLabelId(options: { + emailAccountId: string; + type: SystemLabelType; + provider: EmailProvider; +}): Promise { + const { emailAccountId, type, provider } = options; + + const existingId = await getSystemLabelId({ emailAccountId, type }); + if (existingId) { + return existingId; + } + + // Define new numbered names and legacy names for backward compatibility + const labelNames = { + needsReply: { + name: NEEDS_REPLY_LABEL_NAME, + legacy: NEEDS_REPLY_LABEL_NAME_LEGACY, + }, + awaitingReply: { + name: AWAITING_REPLY_LABEL_NAME, + legacy: AWAITING_REPLY_LABEL_NAME_LEGACY, + }, + coldEmail: { + name: inboxZeroLabels.cold_email.name, + legacy: inboxZeroLabels.cold_email.nameLegacy, + }, + done: { + name: "9. Done", + legacy: "Done", + }, + }; + + const { name: newName, legacy: legacyName } = labelNames[type]; + + try { + // 1. Check if new numbered version exists + let label = await provider.getLabelByName(newName); + if (label) { + logger.info("Found new numbered label", { type, name: newName }); + await updateSystemLabelId({ + emailAccountId, + type, + labelId: label.id, + }); + return label.id; + } + + // 2. Check if legacy version exists (for existing users) + label = await provider.getLabelByName(legacyName); + if (label) { + logger.info("Found legacy label, using it for existing user", { + type, + name: legacyName, + }); + await updateSystemLabelId({ + emailAccountId, + type, + labelId: label.id, + }); + return label.id; + } + + // 3. Create new numbered version (new users only) + logger.info("Creating new numbered label for new user", { + type, + name: newName, + }); + label = await provider.createLabel(newName); + + await updateSystemLabelId({ + emailAccountId, + type, + labelId: label.id, + }); + + return label.id; + } catch (error) { + logger.error("Failed to get or create system label", { type, error }); + return null; + } +} + +export async function getSystemLabelId(options: { + emailAccountId: string; + type: SystemLabelType; +}): Promise { + const { emailAccountId, type } = options; + + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + needsReplyLabelId: true, + awaitingReplyLabelId: true, + coldEmailLabelId: true, + doneLabelId: true, + }, + }); + + if (!emailAccount) return null; + + const fieldMap = { + needsReply: emailAccount.needsReplyLabelId, + awaitingReply: emailAccount.awaitingReplyLabelId, + coldEmail: emailAccount.coldEmailLabelId, + done: emailAccount.doneLabelId, + } as const; + + return fieldMap[type] ?? null; +} + +export async function updateSystemLabelId(options: { + emailAccountId: string; + type: SystemLabelType; + labelId: string; +}): Promise { + const { emailAccountId, type, labelId } = options; + + const fieldMap = { + needsReply: "needsReplyLabelId", + awaitingReply: "awaitingReplyLabelId", + coldEmail: "coldEmailLabelId", + done: "doneLabelId", + } as const; + + const field = fieldMap[type]; + + await prisma.emailAccount.update({ + where: { id: emailAccountId }, + data: { [field]: labelId }, + }); + + await updateAffectedRules({ emailAccountId, type, labelId }); +} + +async function updateAffectedRules(options: { + emailAccountId: string; + type: SystemLabelType; + labelId: string; +}): Promise { + const { emailAccountId, type, labelId } = options; + + // Only update rules for needsReply type (TO_REPLY system type) + if (type !== "needsReply") return; + + const rules = await prisma.rule.findMany({ + where: { + emailAccountId, + systemType: "TO_REPLY", + }, + include: { + actions: { + where: { type: "LABEL" }, + }, + }, + }); + + for (const rule of rules) { + for (const action of rule.actions) { + await prisma.action.update({ + where: { id: action.id }, + data: { labelId }, + }); + } + } +} + +export async function getLabelDisplayName(options: { + labelId: string; + provider: EmailProvider; +}): Promise { + const { labelId, provider } = options; + + try { + const label = await provider.getLabelById(labelId); + return label?.name ?? null; + } catch (error) { + logger.error("Failed to get label display name", { labelId, error }); + return null; + } +} diff --git a/apps/web/utils/reply-tracker/label-helpers.ts b/apps/web/utils/reply-tracker/label-helpers.ts new file mode 100644 index 0000000000..7c23e6cda1 --- /dev/null +++ b/apps/web/utils/reply-tracker/label-helpers.ts @@ -0,0 +1,118 @@ +import type { EmailProvider } from "@/utils/email/types"; +import { getOrCreateSystemLabelId } from "@/utils/label-config"; +import { createScopedLogger } from "@/utils/logger"; + +const logger = createScopedLogger("reply-tracker-labels"); + +export async function labelMessageAsAwaitingReply(options: { + emailAccountId: string; + messageId: string; + provider: EmailProvider; +}): Promise { + const { emailAccountId, messageId, provider } = options; + + const labelId = await getOrCreateSystemLabelId({ + emailAccountId, + type: "awaitingReply", + provider, + }); + + if (!labelId) { + logger.error("Failed to get or create awaiting reply label"); + return; + } + + await provider.labelMessage(messageId, labelId); +} + +export async function removeAwaitingReplyLabelFromThread(options: { + emailAccountId: string; + threadId: string; + provider: EmailProvider; +}): Promise { + const { emailAccountId, threadId, provider } = options; + + const labelId = await getOrCreateSystemLabelId({ + emailAccountId, + type: "awaitingReply", + provider, + }); + + if (!labelId) { + return; + } + + await provider.removeThreadLabel(threadId, labelId); +} + +export async function removeNeedsReplyLabelFromThread(options: { + emailAccountId: string; + threadId: string; + provider: EmailProvider; +}): Promise { + const { emailAccountId, threadId, provider } = options; + + const labelId = await getOrCreateSystemLabelId({ + emailAccountId, + type: "needsReply", + provider, + }); + + if (!labelId) { + return; + } + + await provider.removeThreadLabel(threadId, labelId); +} + +/** + * Applies the "Done" label to a thread and removes state labels. + * Marks thread as completed - no longer needs attention. + */ +export async function markThreadAsDone(options: { + emailAccountId: string; + threadId: string; + provider: EmailProvider; +}): Promise { + const { emailAccountId, threadId, provider } = options; + + // Get all state label IDs + const [doneLabelId, needsReplyLabelId, awaitingReplyLabelId] = + await Promise.all([ + getOrCreateSystemLabelId({ + emailAccountId, + type: "done", + provider, + }), + getOrCreateSystemLabelId({ + emailAccountId, + type: "needsReply", + provider, + }), + getOrCreateSystemLabelId({ + emailAccountId, + type: "awaitingReply", + provider, + }), + ]); + + // Remove active state labels + const removePromises = []; + if (needsReplyLabelId) { + removePromises.push( + provider.removeThreadLabel(threadId, needsReplyLabelId), + ); + } + if (awaitingReplyLabelId) { + removePromises.push( + provider.removeThreadLabel(threadId, awaitingReplyLabelId), + ); + } + + // Apply Done label + if (doneLabelId) { + removePromises.push(provider.labelMessage(threadId, doneLabelId)); + } + + await Promise.allSettled(removePromises); +} diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index 593089d6dc..510a2bcba7 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -14,6 +14,8 @@ import { hasExampleParams } from "@/app/(app)/[emailAccountId]/assistant/example import { SafeError } from "@/utils/error"; import { createRuleHistory } from "@/utils/rule/rule-history"; import { isMicrosoftProvider } from "@/utils/email/provider-types"; +import { createEmailProvider } from "@/utils/email/provider"; +import type { EmailProvider } from "@/utils/email/types"; const logger = createScopedLogger("rule"); @@ -164,7 +166,12 @@ export async function createRule({ triggerType?: "ai_creation" | "manual_creation" | "system_creation"; provider: string; }) { - const mappedActions = mapActionFields(result.actions, provider); + const emailProvider = await createEmailProvider({ emailAccountId, provider }); + const mappedActions = await mapActionFields( + result.actions, + provider, + emailProvider, + ); const rule = await prisma.rule.create({ data: { @@ -222,6 +229,7 @@ async function updateRule({ triggerType?: "ai_update" | "manual_update" | "system_update"; provider: string; }) { + const emailProvider = await createEmailProvider({ emailAccountId, provider }); const rule = await prisma.rule.update({ where: { id: ruleId }, data: { @@ -231,7 +239,9 @@ async function updateRule({ // but if we add relations to `Action`, we would need to `update` here instead of `deleteMany` and `createMany` to avoid cascading deletes actions: { deleteMany: {}, - createMany: { data: mapActionFields(result.actions, provider) }, + createMany: { + data: await mapActionFields(result.actions, provider, emailProvider), + }, }, conditionalOperator: result.condition.conditionalOperator ?? undefined, instructions: result.condition.aiInstructions, @@ -260,17 +270,22 @@ export async function updateRuleActions({ ruleId, actions, provider, + emailAccountId, }: { ruleId: string; actions: CreateOrUpdateRuleSchemaWithCategories["actions"]; provider: string; + emailAccountId: string; }) { + const emailProvider = await createEmailProvider({ emailAccountId, provider }); return prisma.rule.update({ where: { id: ruleId }, data: { actions: { deleteMany: {}, - createMany: { data: mapActionFields(actions, provider) }, + createMany: { + data: await mapActionFields(actions, provider, emailProvider), + }, }, }, }); @@ -361,24 +376,45 @@ export async function removeRuleCategories( }); } -function mapActionFields( +async function mapActionFields( actions: CreateOrUpdateRuleSchemaWithCategories["actions"], provider: string, + emailProvider: EmailProvider, ) { - return actions.map( - (a): Prisma.ActionCreateManyRuleInput => ({ - type: a.type, - label: a.fields?.label, - to: a.fields?.to, - cc: a.fields?.cc, - bcc: a.fields?.bcc, - subject: a.fields?.subject, - content: a.fields?.content, - url: a.fields?.webhookUrl, - ...(isMicrosoftProvider(provider) && { - folderName: a.fields?.folderName as string | null, - }), - delayInMinutes: a.delayInMinutes, - }), + const actionPromises = actions.map( + async (a): Promise => { + let labelId: string | null = null; + + if (a.type === ActionType.LABEL && a.fields?.label) { + try { + const matchingLabel = await emailProvider.getLabelByName( + a.fields.label, + ); + if (matchingLabel) { + labelId = matchingLabel.id; + } + } catch (error) { + logger.warn("Failed to lookup labelId", { error }); + } + } + + return { + type: a.type, + label: a.fields?.label, + labelId, + to: a.fields?.to, + cc: a.fields?.cc, + bcc: a.fields?.bcc, + subject: a.fields?.subject, + content: a.fields?.content, + url: a.fields?.webhookUrl, + ...(isMicrosoftProvider(provider) && { + folderName: a.fields?.folderName as string | null, + }), + delayInMinutes: a.delayInMinutes, + }; + }, ); + + return Promise.all(actionPromises); } From a6267701736a1a1f07de8d38783f7c83461e3190 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 5 Oct 2025 13:00:06 +0300 Subject: [PATCH 02/17] remove done id --- apps/web/utils/actions/settings.ts | 1 - apps/web/utils/label-config.ts | 69 ++++--------------- apps/web/utils/reply-tracker/label-helpers.ts | 52 -------------- 3 files changed, 12 insertions(+), 110 deletions(-) diff --git a/apps/web/utils/actions/settings.ts b/apps/web/utils/actions/settings.ts index d4229a6dae..c8acb801c3 100644 --- a/apps/web/utils/actions/settings.ts +++ b/apps/web/utils/actions/settings.ts @@ -217,7 +217,6 @@ export const updateSystemLabelsAction = actionClient needsReplyLabelId: z.string().optional(), awaitingReplyLabelId: z.string().optional(), coldEmailLabelId: z.string().optional(), - doneLabelId: z.string().optional(), }), ) .action( diff --git a/apps/web/utils/label-config.ts b/apps/web/utils/label-config.ts index 25248bc576..bcce7303e5 100644 --- a/apps/web/utils/label-config.ts +++ b/apps/web/utils/label-config.ts @@ -4,14 +4,13 @@ import { createScopedLogger } from "@/utils/logger"; import { NEEDS_REPLY_LABEL_NAME, AWAITING_REPLY_LABEL_NAME, - NEEDS_REPLY_LABEL_NAME_LEGACY, - AWAITING_REPLY_LABEL_NAME_LEGACY, } from "@/utils/reply-tracker/consts"; import { inboxZeroLabels } from "@/utils/label"; +import { ActionType, SystemType } from "@prisma/client"; const logger = createScopedLogger("label-config"); -type SystemLabelType = "needsReply" | "awaitingReply" | "coldEmail" | "done"; +type SystemLabelType = "needsReply" | "awaitingReply" | "coldEmail"; export async function getOrCreateSystemLabelId(options: { emailAccountId: string; @@ -25,63 +24,22 @@ export async function getOrCreateSystemLabelId(options: { return existingId; } - // Define new numbered names and legacy names for backward compatibility const labelNames = { - needsReply: { - name: NEEDS_REPLY_LABEL_NAME, - legacy: NEEDS_REPLY_LABEL_NAME_LEGACY, - }, - awaitingReply: { - name: AWAITING_REPLY_LABEL_NAME, - legacy: AWAITING_REPLY_LABEL_NAME_LEGACY, - }, - coldEmail: { - name: inboxZeroLabels.cold_email.name, - legacy: inboxZeroLabels.cold_email.nameLegacy, - }, - done: { - name: "9. Done", - legacy: "Done", - }, + needsReply: NEEDS_REPLY_LABEL_NAME, + awaitingReply: AWAITING_REPLY_LABEL_NAME, + coldEmail: inboxZeroLabels.cold_email.name, }; - const { name: newName, legacy: legacyName } = labelNames[type]; + const labelName = labelNames[type]; try { - // 1. Check if new numbered version exists - let label = await provider.getLabelByName(newName); - if (label) { - logger.info("Found new numbered label", { type, name: newName }); - await updateSystemLabelId({ - emailAccountId, - type, - labelId: label.id, - }); - return label.id; - } + let label = await provider.getLabelByName(labelName); - // 2. Check if legacy version exists (for existing users) - label = await provider.getLabelByName(legacyName); - if (label) { - logger.info("Found legacy label, using it for existing user", { - type, - name: legacyName, - }); - await updateSystemLabelId({ - emailAccountId, - type, - labelId: label.id, - }); - return label.id; + if (!label) { + logger.info("Creating system label", { type, name: labelName }); + label = await provider.createLabel(labelName); } - // 3. Create new numbered version (new users only) - logger.info("Creating new numbered label for new user", { - type, - name: newName, - }); - label = await provider.createLabel(newName); - await updateSystemLabelId({ emailAccountId, type, @@ -107,7 +65,6 @@ export async function getSystemLabelId(options: { needsReplyLabelId: true, awaitingReplyLabelId: true, coldEmailLabelId: true, - doneLabelId: true, }, }); @@ -117,7 +74,6 @@ export async function getSystemLabelId(options: { needsReply: emailAccount.needsReplyLabelId, awaitingReply: emailAccount.awaitingReplyLabelId, coldEmail: emailAccount.coldEmailLabelId, - done: emailAccount.doneLabelId, } as const; return fieldMap[type] ?? null; @@ -134,7 +90,6 @@ export async function updateSystemLabelId(options: { needsReply: "needsReplyLabelId", awaitingReply: "awaitingReplyLabelId", coldEmail: "coldEmailLabelId", - done: "doneLabelId", } as const; const field = fieldMap[type]; @@ -160,11 +115,11 @@ async function updateAffectedRules(options: { const rules = await prisma.rule.findMany({ where: { emailAccountId, - systemType: "TO_REPLY", + systemType: SystemType.TO_REPLY, }, include: { actions: { - where: { type: "LABEL" }, + where: { type: ActionType.LABEL }, }, }, }); diff --git a/apps/web/utils/reply-tracker/label-helpers.ts b/apps/web/utils/reply-tracker/label-helpers.ts index 7c23e6cda1..eb7d1e469e 100644 --- a/apps/web/utils/reply-tracker/label-helpers.ts +++ b/apps/web/utils/reply-tracker/label-helpers.ts @@ -64,55 +64,3 @@ export async function removeNeedsReplyLabelFromThread(options: { await provider.removeThreadLabel(threadId, labelId); } - -/** - * Applies the "Done" label to a thread and removes state labels. - * Marks thread as completed - no longer needs attention. - */ -export async function markThreadAsDone(options: { - emailAccountId: string; - threadId: string; - provider: EmailProvider; -}): Promise { - const { emailAccountId, threadId, provider } = options; - - // Get all state label IDs - const [doneLabelId, needsReplyLabelId, awaitingReplyLabelId] = - await Promise.all([ - getOrCreateSystemLabelId({ - emailAccountId, - type: "done", - provider, - }), - getOrCreateSystemLabelId({ - emailAccountId, - type: "needsReply", - provider, - }), - getOrCreateSystemLabelId({ - emailAccountId, - type: "awaitingReply", - provider, - }), - ]); - - // Remove active state labels - const removePromises = []; - if (needsReplyLabelId) { - removePromises.push( - provider.removeThreadLabel(threadId, needsReplyLabelId), - ); - } - if (awaitingReplyLabelId) { - removePromises.push( - provider.removeThreadLabel(threadId, awaitingReplyLabelId), - ); - } - - // Apply Done label - if (doneLabelId) { - removePromises.push(provider.labelMessage(threadId, doneLabelId)); - } - - await Promise.allSettled(removePromises); -} From cc8acc0a8b981911237e2d10eac35e66382408fb Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 5 Oct 2025 13:24:48 +0300 Subject: [PATCH 03/17] fix up system labels dialog --- .../[emailAccountId]/assistant/RuleForm.tsx | 68 +---- .../[emailAccountId]/assistant/Rules.tsx | 3 + .../assistant/settings/SettingsTab.tsx | 2 +- .../settings/SystemLabelsSetting.tsx | 285 ++++++++++-------- apps/web/components/LabelCombobox.tsx | 73 +++++ apps/web/utils/actions/settings.ts | 9 +- apps/web/utils/actions/settings.validation.ts | 7 + 7 files changed, 242 insertions(+), 205 deletions(-) create mode 100644 apps/web/components/LabelCombobox.tsx diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx index 39bcea9d7f..0bbe983d0b 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx @@ -11,7 +11,6 @@ import { useForm, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { toast } from "sonner"; import TextareaAutosize from "react-textarea-autosize"; import { capitalCase } from "capital-case"; import { usePostHog } from "posthog-js/react"; @@ -48,7 +47,6 @@ import { actionInputs } from "@/utils/action-item"; import { Toggle } from "@/components/Toggle"; import { LoadingContent } from "@/components/LoadingContent"; import { TooltipExplanation } from "@/components/TooltipExplanation"; -import { Combobox } from "@/components/Combobox"; import { useLabels } from "@/hooks/useLabels"; import { createLabelAction } from "@/utils/actions/mail"; import { MultiSelectFilter } from "@/components/MultiSelectFilter"; @@ -97,6 +95,7 @@ import { useFolders } from "@/hooks/useFolders"; import type { OutlookFolder } from "@/utils/outlook/folders"; import { cn } from "@/utils"; import { WebhookDocumentationLink } from "@/components/WebhookDocumentation"; +import { LabelCombobox } from "@/components/LabelCombobox"; export function Rule({ ruleId, @@ -1148,7 +1147,7 @@ function ActionCard({ {field.name === "label" && !isAiGenerated ? (
void; - userLabels: EmailLabel[]; - isLoading: boolean; - mutate: () => void; - emailAccountId: string; -}) { - const [search, setSearch] = useState(""); - - return ( - ({ - value: label.name || "", - label: label.name || "", - }))} - value={value} - onChangeValue={onChangeValue} - search={search} - onSearch={setSearch} - placeholder="Select a label" - emptyText={ -
-
No labels
- {search && ( - - )} -
- } - loading={isLoading} - /> - ); -} - function ReplyTrackerAction() { return (
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx index 3b4d4d3c11..07015a1d03 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx @@ -132,6 +132,7 @@ export function Rules({ id: "cold-email-blocker-label", type: ActionType.LABEL, label: inboxZeroLabels.cold_email.name, + labelId: null, createdAt: new Date(), updatedAt: new Date(), ruleId: COLD_EMAIL_BLOCKER_RULE_ID, @@ -153,6 +154,7 @@ export function Rules({ ? ActionType.MOVE_FOLDER : ActionType.ARCHIVE, label: null, + labelId: null, createdAt: new Date(), updatedAt: new Date(), ruleId: COLD_EMAIL_BLOCKER_RULE_ID, @@ -172,6 +174,7 @@ export function Rules({ id: "cold-email-blocker-digest", type: ActionType.DIGEST, label: null, + labelId: null, createdAt: new Date(), updatedAt: new Date(), ruleId: COLD_EMAIL_BLOCKER_RULE_ID, diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx index 642a54cccf..b4dd1c7a08 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx @@ -13,12 +13,12 @@ export function SettingsTab() {
- +
); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx index 7603e43942..83a0d2919f 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx @@ -1,178 +1,201 @@ "use client"; -import { useCallback, useState, useEffect } from "react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { SettingCard } from "@/components/SettingCard"; import { Button } from "@/components/ui/button"; -import { Select } from "@/components/Select"; -import { Label } from "@/components/Input"; import { toastError, toastSuccess } from "@/components/Toast"; import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; import { LoadingContent } from "@/components/LoadingContent"; import { updateSystemLabelsAction } from "@/utils/actions/settings"; +import { + updateSystemLabelsBody, + type UpdateSystemLabelsBody, +} from "@/utils/actions/settings.validation"; import { useLabels } from "@/hooks/useLabels"; import { - AWAITING_REPLY_LABEL_NAME, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/Input"; +import { LabelCombobox } from "@/components/LabelCombobox"; +import { SettingsIcon } from "lucide-react"; +import { NEEDS_REPLY_LABEL_NAME, + AWAITING_REPLY_LABEL_NAME, } from "@/utils/reply-tracker/consts"; import { inboxZeroLabels } from "@/utils/label"; export function SystemLabelsSetting() { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + + {isOpen && ( + setIsOpen(false)} /> + )} + + } + /> + ); +} + +function SystemLabelsDialogContent({ onClose }: { onClose: () => void }) { const { data: emailAccountData, isLoading: isLoadingAccount, error: accountError, mutate, } = useEmailAccountFull(); - const { userLabels, isLoading: isLoadingLabels } = useLabels(); - const [isSaving, setIsSaving] = useState(false); + const { + userLabels, + isLoading: isLoadingLabels, + mutate: mutateLabels, + } = useLabels(); - const [needsReplyLabelId, setNeedsReplyLabelId] = useState( - null, - ); - const [awaitingReplyLabelId, setAwaitingReplyLabelId] = useState< - string | null - >(null); - const [coldEmailLabelId, setColdEmailLabelId] = useState(null); - // const [doneLabelId, setDoneLabelId] = useState(null); + const { + watch, + setValue, + handleSubmit, + reset, + formState: { isSubmitting, isDirty }, + } = useForm({ + resolver: zodResolver(updateSystemLabelsBody), + defaultValues: { + needsReplyLabelId: undefined, + awaitingReplyLabelId: undefined, + coldEmailLabelId: undefined, + }, + }); useEffect(() => { - if (emailAccountData) { - setNeedsReplyLabelId(emailAccountData.needsReplyLabelId ?? null); - setAwaitingReplyLabelId(emailAccountData.awaitingReplyLabelId ?? null); - setColdEmailLabelId(emailAccountData.coldEmailLabelId ?? null); - // setDoneLabelId(emailAccountData.doneLabelId ?? null); - } - }, [emailAccountData]); + if (emailAccountData && userLabels) { + // Find default labels by name if not already set + const defaultNeedsReplyLabel = userLabels.find( + (l) => l.name === NEEDS_REPLY_LABEL_NAME, + ); + const defaultAwaitingReplyLabel = userLabels.find( + (l) => l.name === AWAITING_REPLY_LABEL_NAME, + ); + const defaultColdEmailLabel = userLabels.find( + (l) => l.name === inboxZeroLabels.cold_email.name, + ); - const labelOptions = - userLabels?.map((label: { id: string; name: string }) => ({ - label: label.name, - value: label.id, - })) ?? []; + reset({ + needsReplyLabelId: + emailAccountData.needsReplyLabelId ?? defaultNeedsReplyLabel?.id, + awaitingReplyLabelId: + emailAccountData.awaitingReplyLabelId ?? + defaultAwaitingReplyLabel?.id, + coldEmailLabelId: + emailAccountData.coldEmailLabelId ?? defaultColdEmailLabel?.id, + }); + } + }, [emailAccountData, userLabels, reset]); - const handleSave = useCallback(async () => { + const onSubmit = async (data: UpdateSystemLabelsBody) => { if (!emailAccountData?.id) return; - setIsSaving(true); - try { - const result = await updateSystemLabelsAction(emailAccountData.id, { - needsReplyLabelId: needsReplyLabelId ?? undefined, - awaitingReplyLabelId: awaitingReplyLabelId ?? undefined, - coldEmailLabelId: coldEmailLabelId ?? undefined, - // doneLabelId: doneLabelId ?? undefined, - }); - - if (result?.serverError) { - toastError({ description: result.serverError }); - return; - } + const result = await updateSystemLabelsAction(emailAccountData.id, data); - toastSuccess({ description: "System labels updated" }); - await mutate(); - } finally { - setIsSaving(false); + if (result?.serverError) { + toastError({ description: result.serverError }); + return; } - }, [ - emailAccountData?.id, - needsReplyLabelId, - awaitingReplyLabelId, - coldEmailLabelId, - // doneLabelId, - mutate, - ]); - const hasChanges = - needsReplyLabelId !== (emailAccountData?.needsReplyLabelId ?? null) || - awaitingReplyLabelId !== (emailAccountData?.awaitingReplyLabelId ?? null) || - coldEmailLabelId !== (emailAccountData?.coldEmailLabelId ?? null); - // || - // doneLabelId !== (emailAccountData?.doneLabelId ?? null); + toastSuccess({ description: "System labels updated" }); + await mutate(); + onClose(); + }; return ( - -
-
-
- - } - /> + + + + + ); } diff --git a/apps/web/components/LabelCombobox.tsx b/apps/web/components/LabelCombobox.tsx new file mode 100644 index 0000000000..5a157663af --- /dev/null +++ b/apps/web/components/LabelCombobox.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/Combobox"; +import { createLabelAction } from "@/utils/actions/mail"; +import type { EmailLabel } from "@/providers/EmailProvider"; + +export function LabelCombobox({ + value, + onChangeValue, + userLabels, + isLoading, + mutate, + emailAccountId, +}: { + value: string; + onChangeValue: (value: string) => void; + userLabels: EmailLabel[]; + isLoading: boolean; + mutate: () => void; + emailAccountId: string; +}) { + const [search, setSearch] = useState(""); + + const selectedLabel = userLabels.find((label) => label.id === value); + + return ( + ({ + value: label.id || "", + label: label.name || "", + }))} + value={value} + onChangeValue={onChangeValue} + search={search} + onSearch={setSearch} + placeholder={selectedLabel?.name || "Select a label"} + emptyText={ +
+
No labels
+ {search && ( + + )} +
+ } + loading={isLoading} + /> + ); +} diff --git a/apps/web/utils/actions/settings.ts b/apps/web/utils/actions/settings.ts index c8acb801c3..cb5d77033a 100644 --- a/apps/web/utils/actions/settings.ts +++ b/apps/web/utils/actions/settings.ts @@ -8,6 +8,7 @@ import { saveEmailUpdateSettingsBody, saveDigestScheduleBody, updateDigestItemsBody, + updateSystemLabelsBody, } from "@/utils/actions/settings.validation"; import { DEFAULT_PROVIDER } from "@/utils/llms/config"; import prisma from "@/utils/prisma"; @@ -212,13 +213,7 @@ export const updateDigestItemsAction = actionClient export const updateSystemLabelsAction = actionClient .metadata({ name: "updateSystemLabels" }) - .schema( - z.object({ - needsReplyLabelId: z.string().optional(), - awaitingReplyLabelId: z.string().optional(), - coldEmailLabelId: z.string().optional(), - }), - ) + .schema(updateSystemLabelsBody) .action( async ({ ctx: { emailAccountId }, diff --git a/apps/web/utils/actions/settings.validation.ts b/apps/web/utils/actions/settings.validation.ts index daa081a40a..8298481716 100644 --- a/apps/web/utils/actions/settings.validation.ts +++ b/apps/web/utils/actions/settings.validation.ts @@ -53,3 +53,10 @@ export const updateDigestItemsBody = z.object({ coldEmailDigest: z.boolean().optional(), }); export type UpdateDigestItemsBody = z.infer; + +export const updateSystemLabelsBody = z.object({ + needsReplyLabelId: z.string().optional(), + awaitingReplyLabelId: z.string().optional(), + coldEmailLabelId: z.string().optional(), +}); +export type UpdateSystemLabelsBody = z.infer; From 412fef1982d1faf0cd91b95fdd13337ad608ed13 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 5 Oct 2025 13:38:09 +0300 Subject: [PATCH 04/17] clean up form --- .../[emailAccountId]/assistant/RuleForm.tsx | 2 +- .../settings/SystemLabelsSetting.tsx | 231 ++++++++++-------- apps/web/components/LabelCombobox.tsx | 24 +- 3 files changed, 145 insertions(+), 112 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx index 0bbe983d0b..a35c572f97 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx @@ -1017,7 +1017,7 @@ function ActionCard({ errors: FieldErrors; userLabels: EmailLabel[]; isLoading: boolean; - mutate: () => void; + mutate: () => Promise; emailAccountId: string; remove: (index: number) => void; typeOptions: { label: string; value: ActionType }[]; diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx index 83a0d2919f..602afe998c 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SystemLabelsSetting.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState, useMemo } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { SettingCard } from "@/components/SettingCard"; @@ -67,46 +67,77 @@ function SystemLabelsDialogContent({ onClose }: { onClose: () => void }) { mutate: mutateLabels, } = useLabels(); + return ( + + + Configure System Labels + + + {emailAccountData && userLabels && ( + + )} + + + ); +} + +function SystemLabelsForm({ + emailAccountData, + userLabels, + isLoadingLabels, + mutate, + mutateLabels, + onClose, +}: { + emailAccountData: NonNullable["data"]>; + userLabels: NonNullable["userLabels"]>; + isLoadingLabels: boolean; + mutate: () => Promise; + mutateLabels: () => Promise; + onClose: () => void; +}) { + // Find default labels by name if not already set + const defaultValues = useMemo(() => { + const defaultNeedsReplyLabel = userLabels.find( + (l) => l.name === NEEDS_REPLY_LABEL_NAME, + ); + const defaultAwaitingReplyLabel = userLabels.find( + (l) => l.name === AWAITING_REPLY_LABEL_NAME, + ); + const defaultColdEmailLabel = userLabels.find( + (l) => l.name === inboxZeroLabels.cold_email.name, + ); + + return { + needsReplyLabelId: + emailAccountData.needsReplyLabelId ?? defaultNeedsReplyLabel?.id, + awaitingReplyLabelId: + emailAccountData.awaitingReplyLabelId ?? defaultAwaitingReplyLabel?.id, + coldEmailLabelId: + emailAccountData.coldEmailLabelId ?? defaultColdEmailLabel?.id, + }; + }, [emailAccountData, userLabels]); + const { watch, setValue, handleSubmit, - reset, formState: { isSubmitting, isDirty }, } = useForm({ resolver: zodResolver(updateSystemLabelsBody), - defaultValues: { - needsReplyLabelId: undefined, - awaitingReplyLabelId: undefined, - coldEmailLabelId: undefined, - }, + defaultValues, }); - useEffect(() => { - if (emailAccountData && userLabels) { - // Find default labels by name if not already set - const defaultNeedsReplyLabel = userLabels.find( - (l) => l.name === NEEDS_REPLY_LABEL_NAME, - ); - const defaultAwaitingReplyLabel = userLabels.find( - (l) => l.name === AWAITING_REPLY_LABEL_NAME, - ); - const defaultColdEmailLabel = userLabels.find( - (l) => l.name === inboxZeroLabels.cold_email.name, - ); - - reset({ - needsReplyLabelId: - emailAccountData.needsReplyLabelId ?? defaultNeedsReplyLabel?.id, - awaitingReplyLabelId: - emailAccountData.awaitingReplyLabelId ?? - defaultAwaitingReplyLabel?.id, - coldEmailLabelId: - emailAccountData.coldEmailLabelId ?? defaultColdEmailLabel?.id, - }); - } - }, [emailAccountData, userLabels, reset]); - const onSubmit = async (data: UpdateSystemLabelsBody) => { if (!emailAccountData?.id) return; @@ -123,79 +154,69 @@ function SystemLabelsDialogContent({ onClose }: { onClose: () => void }) { }; return ( - - - Configure System Labels - - +
+
+ +
+
+ +
+
+ + - -
-
+ Save Changes + + ); } diff --git a/apps/web/components/LabelCombobox.tsx b/apps/web/components/LabelCombobox.tsx index 5a157663af..0c4c1eac95 100644 --- a/apps/web/components/LabelCombobox.tsx +++ b/apps/web/components/LabelCombobox.tsx @@ -19,7 +19,7 @@ export function LabelCombobox({ onChangeValue: (value: string) => void; userLabels: EmailLabel[]; isLoading: boolean; - mutate: () => void; + mutate: () => Promise; emailAccountId: string; }) { const [search, setSearch] = useState(""); @@ -45,19 +45,31 @@ export function LabelCombobox({ className="mt-2" variant="outline" onClick={() => { + const searchValue = search; + toast.promise( async () => { const res = await createLabelAction(emailAccountId, { - name: search, + name: searchValue, }); - mutate(); if (res?.serverError) throw new Error(res.serverError); + + await mutate(); + + setSearch(""); + + // Auto-select the newly created label + if (res?.data?.id) { + onChangeValue(res.data.id); + } + + return res; }, { - loading: `Creating label "${search}"...`, - success: `Created label "${search}"`, + loading: `Creating label "${searchValue}"...`, + success: `Created label "${searchValue}"`, error: (errorMessage) => - `Error creating label "${search}": ${errorMessage}`, + `Error creating label "${searchValue}": ${errorMessage}`, }, ); }} From c7f7ba4ae9c0315b64f93e8bcf1ab8dcbd04c072 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:08:02 +0300 Subject: [PATCH 05/17] fix build --- .../(app)/[emailAccountId]/assistant/settings/DraftReplies.tsx | 1 + version.txt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/DraftReplies.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/DraftReplies.tsx index bfa2e369f9..86ed96b927 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/DraftReplies.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/DraftReplies.tsx @@ -81,6 +81,7 @@ export function useDraftReplies() { type: ActionType.DRAFT_EMAIL, ruleId: rule.id, label: null, + labelId: null, subject: null, content: null, to: null, diff --git a/version.txt b/version.txt index 84c01c732a..d4174a4e19 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.13.9 +v2.14.0 From be7d376a6db68b454f4ffe9035136fe50d28d014 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:22:35 +0300 Subject: [PATCH 06/17] fix labelling based on custom id --- apps/web/utils/__mocks__/email-provider.ts | 7 +-- .../assistant/process-assistant-email.ts | 2 +- apps/web/utils/cold-email/is-cold-email.ts | 7 ++- apps/web/utils/email/google.ts | 58 +++---------------- apps/web/utils/email/microsoft.ts | 52 +++-------------- apps/web/utils/email/types.ts | 7 +-- apps/web/utils/reply-tracker/inbound.ts | 7 ++- apps/web/utils/reply-tracker/label-helpers.ts | 2 +- apps/web/utils/reply-tracker/outbound.ts | 16 ++++- 9 files changed, 45 insertions(+), 113 deletions(-) diff --git a/apps/web/utils/__mocks__/email-provider.ts b/apps/web/utils/__mocks__/email-provider.ts index ca9ba4446c..98d0620b1a 100644 --- a/apps/web/utils/__mocks__/email-provider.ts +++ b/apps/web/utils/__mocks__/email-provider.ts @@ -44,6 +44,8 @@ export const createMockEmailProvider = ( }), getLabels: vi.fn().mockResolvedValue([]), getLabelById: vi.fn().mockResolvedValue(null), + getLabelByName: vi.fn().mockResolvedValue(null), + getSignatures: vi.fn().mockResolvedValue([]), getMessage: vi.fn().mockResolvedValue({ id: "msg1", threadId: "thread1", @@ -74,11 +76,6 @@ export const createMockEmailProvider = ( trashThread: vi.fn().mockResolvedValue(undefined), labelMessage: vi.fn().mockResolvedValue(undefined), removeThreadLabel: vi.fn().mockResolvedValue(undefined), - getNeedsReplyLabel: vi.fn().mockResolvedValue(null), - getAwaitingReplyLabel: vi.fn().mockResolvedValue(null), - labelAwaitingReply: vi.fn().mockResolvedValue(undefined), - removeAwaitingReplyLabel: vi.fn().mockResolvedValue(undefined), - removeNeedsReplyLabel: vi.fn().mockResolvedValue(undefined), draftEmail: vi.fn().mockResolvedValue({ draftId: "draft1" }), replyToEmail: vi.fn().mockResolvedValue(undefined), sendEmail: vi.fn().mockResolvedValue(undefined), diff --git a/apps/web/utils/assistant/process-assistant-email.ts b/apps/web/utils/assistant/process-assistant-email.ts index 8a5d17c2bc..59e392c5e2 100644 --- a/apps/web/utils/assistant/process-assistant-email.ts +++ b/apps/web/utils/assistant/process-assistant-email.ts @@ -274,7 +274,7 @@ async function withProcessingLabels( if (labels.length) { // Fire and forget the initial labeling - provider.labelMessage(messageId, labels[0]).catch((error) => { + provider.labelMessage({ messageId, labelId: labels[0] }).catch((error) => { logger.error("Error labeling message", { error }); }); } diff --git a/apps/web/utils/cold-email/is-cold-email.ts b/apps/web/utils/cold-email/is-cold-email.ts index 9fd9a27426..0c30aeb66a 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -222,7 +222,6 @@ export async function blockColdEmail(options: { ) { if (!emailAccount.email) throw new Error("User email is required"); - // Get or create the configured cold email label const coldEmailLabelId = await getOrCreateSystemLabelId({ emailAccountId: emailAccount.id, type: "coldEmail", @@ -238,9 +237,11 @@ export async function blockColdEmail(options: { emailAccount.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL; - // Apply the cold email label using stable ID if (coldEmailLabelId) { - await provider.labelMessage(email.id, coldEmailLabelId); + await provider.labelMessage({ + messageId: email.id, + labelId: coldEmailLabelId, + }); } // For archiving and marking as read, we'll need to implement these in the provider diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 6340d88ca0..886a005c10 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -1,9 +1,5 @@ import type { gmail_v1 } from "@googleapis/gmail"; -import { - isDefined, - type MessageWithPayload, - type ParsedMessage, -} from "@/utils/types"; +import type { MessageWithPayload, ParsedMessage } from "@/utils/types"; import { parseMessage } from "@/utils/gmail/message"; import { getMessage, @@ -17,8 +13,6 @@ import { createLabel, getOrCreateInboxZeroLabel, GmailLabel, - getNeedsReplyLabel, - getAwaitingReplyLabel, } from "@/utils/gmail/label"; import { labelVisibility, messageVisibility } from "@/utils/gmail/constants"; import type { InboxZeroLabel } from "@/utils/label"; @@ -33,7 +27,6 @@ import { } from "@/utils/gmail/mail"; import { archiveThread, - getOrCreateLabel, labelMessage, markReadThread, removeThreadLabel, @@ -246,7 +239,7 @@ export class GmailProvider implements EmailProvider { threadId: string, ownerEmail: string, actionSource: "user" | "automation", - ): Promise { + ) { await trashThread({ gmail: this.client, threadId, @@ -255,7 +248,13 @@ export class GmailProvider implements EmailProvider { }); } - async labelMessage(messageId: string, labelId: string): Promise { + async labelMessage({ + messageId, + labelId, + }: { + messageId: string; + labelId: string; + }) { await labelMessage({ gmail: this.client, messageId, @@ -376,32 +375,6 @@ export class GmailProvider implements EmailProvider { await removeThreadLabel(this.client, threadId, labelId); } - async getAwaitingReplyLabel(): Promise { - return getAwaitingReplyLabel(this.client); - } - - async getNeedsReplyLabel(): Promise { - return getNeedsReplyLabel(this.client); - } - - async removeAwaitingReplyLabel(threadId: string): Promise { - const awaitingReplyLabelId = await this.getAwaitingReplyLabel(); - if (!awaitingReplyLabelId) { - logger.warn("No awaiting reply label found"); - return; - } - await removeThreadLabel(this.client, threadId, awaitingReplyLabelId); - } - - async removeNeedsReplyLabel(threadId: string): Promise { - const needsReplyLabelId = await this.getNeedsReplyLabel(); - if (!needsReplyLabelId) { - logger.warn("No needs reply label found"); - return; - } - await removeThreadLabel(this.client, threadId, needsReplyLabelId); - } - async createLabel(name: string): Promise { const label = await createLabel({ gmail: this.client, @@ -841,19 +814,6 @@ export class GmailProvider implements EmailProvider { ); } - async labelAwaitingReply(messageId: string): Promise { - const awaitingReplyLabelId = await this.getAwaitingReplyLabel(); - if (!awaitingReplyLabelId) { - logger.warn("No awaiting reply label found"); - return; - } - await labelMessage({ - gmail: this.client, - messageId, - addLabelIds: [awaitingReplyLabelId], - }); - } - async processHistory(options: { emailAddress: string; historyId?: number; diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 1cc9a36777..9855d63474 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -24,7 +24,6 @@ import { } from "@/utils/outlook/mail"; import { archiveThread, - getOrCreateLabel, labelMessage, markReadThread, removeThreadLabel, @@ -38,11 +37,6 @@ import { getThreadsFromSenderWithSubject, } from "@/utils/outlook/thread"; import { getOutlookAttachment } from "@/utils/outlook/attachment"; -import { getOrCreateLabels } from "@/utils/outlook/label"; -import { - AWAITING_REPLY_LABEL_NAME, - NEEDS_REPLY_LABEL_NAME, -} from "@/utils/reply-tracker/consts"; import { getDraft, deleteDraft } from "@/utils/outlook/draft"; import { getFiltersList, @@ -302,7 +296,13 @@ export class OutlookProvider implements EmailProvider { }); } - async labelMessage(messageId: string, labelId: string): Promise { + async labelMessage({ + messageId, + labelId, + }: { + messageId: string; + labelId: string; + }) { const category = await this.getLabelById(labelId); if (!category) { throw new Error(`Category with ID ${labelId} not found`); @@ -1071,44 +1071,6 @@ export class OutlookProvider implements EmailProvider { return getThreadsFromSenderWithSubject(this.client, sender, limit); } - async getNeedsReplyLabel(): Promise { - const [needsReplyLabel] = await getOrCreateLabels({ - client: this.client, - names: [NEEDS_REPLY_LABEL_NAME], - }); - - return needsReplyLabel.id || null; - } - - async getAwaitingReplyLabel(): Promise { - const [awaitingReplyLabel] = await getOrCreateLabels({ - client: this.client, - names: [AWAITING_REPLY_LABEL_NAME], - }); - - return awaitingReplyLabel.id || null; - } - - async labelAwaitingReply(messageId: string): Promise { - await this.labelMessage(messageId, AWAITING_REPLY_LABEL_NAME); - } - - async removeAwaitingReplyLabel(threadId: string): Promise { - await removeThreadLabel({ - client: this.client, - threadId, - categoryName: AWAITING_REPLY_LABEL_NAME, - }); - } - - async removeNeedsReplyLabel(threadId: string): Promise { - await removeThreadLabel({ - client: this.client, - threadId, - categoryName: NEEDS_REPLY_LABEL_NAME, - }); - } - async processHistory(options: { emailAddress: string; historyId?: number; diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index d1ea061f6b..2168dc7031 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -87,13 +87,8 @@ export interface EmailProvider { ownerEmail: string, actionSource: "user" | "automation", ): Promise; - labelMessage(messageId: string, labelId: string): Promise; + labelMessage(options: { messageId: string; labelId: string }): Promise; removeThreadLabel(threadId: string, labelId: string): Promise; - getNeedsReplyLabel(): Promise; - getAwaitingReplyLabel(): Promise; - labelAwaitingReply(messageId: string): Promise; - removeAwaitingReplyLabel(threadId: string): Promise; - removeNeedsReplyLabel(threadId: string): Promise; draftEmail( email: ParsedMessage, args: { to?: string; subject?: string; content: string }, diff --git a/apps/web/utils/reply-tracker/inbound.ts b/apps/web/utils/reply-tracker/inbound.ts index bdfe704a93..88f1ec0fb1 100644 --- a/apps/web/utils/reply-tracker/inbound.ts +++ b/apps/web/utils/reply-tracker/inbound.ts @@ -8,6 +8,7 @@ import { getEmailForLLM } from "@/utils/get-email-from-message"; import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; import { filterToReplyPreset } from "@/utils/ai/choose-rule/match-rules"; import type { EmailProvider } from "@/utils/email/types"; +import { removeAwaitingReplyLabelFromThread } from "./label-helpers"; /** * Marks an email thread as needing a reply. @@ -44,7 +45,11 @@ export async function coordinateReplyProcess({ sentAt, }); - const labelsPromise = client.removeAwaitingReplyLabel(threadId); + const labelsPromise = removeAwaitingReplyLabelFromThread({ + emailAccountId, + threadId, + provider: client, + }); const [dbResult, labelsResult] = await Promise.allSettled([ dbPromise, diff --git a/apps/web/utils/reply-tracker/label-helpers.ts b/apps/web/utils/reply-tracker/label-helpers.ts index eb7d1e469e..361cf61669 100644 --- a/apps/web/utils/reply-tracker/label-helpers.ts +++ b/apps/web/utils/reply-tracker/label-helpers.ts @@ -22,7 +22,7 @@ export async function labelMessageAsAwaitingReply(options: { return; } - await provider.labelMessage(messageId, labelId); + await provider.labelMessage({ messageId, labelId }); } export async function removeAwaitingReplyLabelFromThread(options: { diff --git a/apps/web/utils/reply-tracker/outbound.ts b/apps/web/utils/reply-tracker/outbound.ts index 7c1b982b9b..beacfbdee9 100644 --- a/apps/web/utils/reply-tracker/outbound.ts +++ b/apps/web/utils/reply-tracker/outbound.ts @@ -7,6 +7,10 @@ import { createScopedLogger, type Logger } from "@/utils/logger"; import { getEmailForLLM } from "@/utils/get-email-from-message"; import { internalDateToDate } from "@/utils/date"; import type { EmailProvider } from "@/utils/email/types"; +import { + labelMessageAsAwaitingReply, + removeNeedsReplyLabelFromThread, +} from "./label-helpers"; export async function handleOutboundReply({ emailAccount, @@ -114,7 +118,11 @@ async function createReplyTrackerOutbound({ }, }); - const labelPromise = provider.labelAwaitingReply(messageId); + const labelPromise = labelMessageAsAwaitingReply({ + emailAccountId, + messageId, + provider, + }); const [upsertResult, labelResult] = await Promise.allSettled([ upsertPromise, @@ -151,7 +159,11 @@ async function resolveReplyTrackers( }, }); - const labelPromise = provider.removeNeedsReplyLabel(threadId); + const labelPromise = removeNeedsReplyLabelFromThread({ + emailAccountId, + threadId, + provider, + }); await Promise.allSettled([updateDbPromise, labelPromise]); } From 77c2b79539ac7a22a89f84f10e21ef841b430ed0 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:38:59 +0300 Subject: [PATCH 07/17] fix get label by name --- apps/web/utils/email/google.ts | 13 +++++++++++-- apps/web/utils/email/microsoft.ts | 10 ++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 886a005c10..6fc660ba38 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -9,6 +9,7 @@ import { } from "@/utils/gmail/message"; import { getLabels, + getLabel, getLabelById, createLabel, getOrCreateInboxZeroLabel, @@ -141,8 +142,16 @@ export class GmailProvider implements EmailProvider { } async getLabelByName(name: string): Promise { - const labels = await this.getLabels(); - return labels.find((label) => label.name === name) || null; + const label = await getLabel({ gmail: this.client, name }); + if (!label) return null; + return { + id: label.id!, + name: label.name!, + type: label.type!, + threadsTotal: label.threadsTotal || undefined, + labelListVisibility: label.labelListVisibility || undefined, + messageListVisibility: label.messageListVisibility || undefined, + }; } async getMessage(messageId: string): Promise { diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 9855d63474..a5378c0818 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -9,6 +9,7 @@ import { } from "@/utils/outlook/message"; import { getLabels, + getLabel, createLabel, getOrCreateInboxZeroLabel, getLabelById, @@ -122,8 +123,13 @@ export class OutlookProvider implements EmailProvider { } async getLabelByName(name: string): Promise { - const labels = await this.getLabels(); - return labels.find((label) => label.name === name) || null; + const category = await getLabel({ client: this.client, name }); + if (!category) return null; + return { + id: category.id || "", + name: category.displayName || "", + type: "user", + }; } async getMessage(messageId: string): Promise { From 922f143f2d3fc512afcff28cd908eb8a0bddb557 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 5 Oct 2025 15:11:43 +0300 Subject: [PATCH 08/17] clean up --- apps/web/utils/label-config.ts | 38 +++++++++++++--------------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/apps/web/utils/label-config.ts b/apps/web/utils/label-config.ts index bcce7303e5..aac139895a 100644 --- a/apps/web/utils/label-config.ts +++ b/apps/web/utils/label-config.ts @@ -53,7 +53,7 @@ export async function getOrCreateSystemLabelId(options: { } } -export async function getSystemLabelId(options: { +async function getSystemLabelId(options: { emailAccountId: string; type: SystemLabelType; }): Promise { @@ -70,16 +70,21 @@ export async function getSystemLabelId(options: { if (!emailAccount) return null; - const fieldMap = { - needsReply: emailAccount.needsReplyLabelId, - awaitingReply: emailAccount.awaitingReplyLabelId, - coldEmail: emailAccount.coldEmailLabelId, - } as const; - - return fieldMap[type] ?? null; + switch (type) { + case "needsReply": + return emailAccount.needsReplyLabelId; + case "awaitingReply": + return emailAccount.awaitingReplyLabelId; + case "coldEmail": + return emailAccount.coldEmailLabelId; + default: { + const exhaustiveCheck: never = type; + return exhaustiveCheck; + } + } } -export async function updateSystemLabelId(options: { +async function updateSystemLabelId(options: { emailAccountId: string; type: SystemLabelType; labelId: string; @@ -133,18 +138,3 @@ async function updateAffectedRules(options: { } } } - -export async function getLabelDisplayName(options: { - labelId: string; - provider: EmailProvider; -}): Promise { - const { labelId, provider } = options; - - try { - const label = await provider.getLabelById(labelId); - return label?.name ?? null; - } catch (error) { - logger.error("Failed to get label display name", { labelId, error }); - return null; - } -} From 5af2ecffec44d47ec3e038a8964095aebfaca6d0 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 5 Oct 2025 15:13:15 +0300 Subject: [PATCH 09/17] fix build --- apps/web/utils/ai/actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index 2ea502d84a..496703daf0 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -102,7 +102,7 @@ const label: ActionFunction<{ if (!labelIdToUse) return; - await client.labelMessage(email.id, labelIdToUse); + await client.labelMessage({ messageId: email.id, labelId: labelIdToUse }); }; const draft: ActionFunction<{ From e341b97e4c686ad7444f8d0da63a7a61adaac2d3 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 5 Oct 2025 15:49:24 +0300 Subject: [PATCH 10/17] update to reply label rules --- apps/web/utils/actions/settings.ts | 28 ++++++++++++ apps/web/utils/gmail/label.ts | 73 ------------------------------ 2 files changed, 28 insertions(+), 73 deletions(-) diff --git a/apps/web/utils/actions/settings.ts b/apps/web/utils/actions/settings.ts index cb5d77033a..d69d22a8e7 100644 --- a/apps/web/utils/actions/settings.ts +++ b/apps/web/utils/actions/settings.ts @@ -223,6 +223,16 @@ export const updateSystemLabelsAction = actionClient coldEmailLabelId, }, }) => { + // Get the old label IDs before updating + const oldConfig = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + needsReplyLabelId: true, + awaitingReplyLabelId: true, + coldEmailLabelId: true, + }, + }); + await prisma.emailAccount.update({ where: { id: emailAccountId }, data: { @@ -232,6 +242,24 @@ export const updateSystemLabelsAction = actionClient }, }); + // Update all LABEL actions that match the old needs reply label + if (needsReplyLabelId) { + await prisma.action.updateMany({ + where: { + rule: { emailAccountId }, + type: ActionType.LABEL, + OR: [ + { labelId: oldConfig?.needsReplyLabelId ?? undefined }, + { label: NEEDS_REPLY_LABEL_NAME }, + ], + }, + data: { + labelId: needsReplyLabelId, + label: null, // Clear the label name since we're using labelId now + }, + }); + } + return { success: true }; }, ); diff --git a/apps/web/utils/gmail/label.ts b/apps/web/utils/gmail/label.ts index 72487e666a..2e65566ca8 100644 --- a/apps/web/utils/gmail/label.ts +++ b/apps/web/utils/gmail/label.ts @@ -252,59 +252,6 @@ export async function getLabelById(options: { return (await gmail.users.labels.get({ userId: "me", id })).data; } -export async function getOrCreateLabel({ - gmail, - name, -}: { - gmail: gmail_v1.Gmail; - name: string; -}) { - if (!name?.trim()) throw new Error("Label name cannot be empty"); - const label = await getLabel({ gmail, name }); - if (label) return label; - const createdLabel = await createLabel({ gmail, name }); - return createdLabel; -} - -// More efficient way to get or create multiple labels, so we fetch labels only once -export async function getOrCreateLabels({ - gmail, - names, -}: { - gmail: gmail_v1.Gmail; - names: string[]; -}): Promise { - if (!names.length) return []; - - // Validate names - const emptyNames = names.filter((name) => !name?.trim()); - if (emptyNames.length) throw new Error("Label names cannot be empty"); - - // Fetch labels once - const existingLabels = (await getLabels(gmail)) || []; - const normalizedNames = names.map(normalizeLabel); - - // Find existing labels - const labelMap = new Map(); - existingLabels.forEach((label) => { - if (label.name) { - labelMap.set(normalizeLabel(label.name), label); - } - }); - - // Create missing labels - const results = await Promise.all( - normalizedNames.map(async (normalizedName, index) => { - const existingLabel = labelMap.get(normalizedName); - if (existingLabel) return existingLabel; - - return createLabel({ gmail, name: names[index] }); - }), - ); - - return results; -} - export async function getOrCreateInboxZeroLabel({ gmail, key, @@ -339,23 +286,3 @@ export async function getOrCreateInboxZeroLabel({ }); return createdLabel; } - -export async function getAwaitingReplyLabel( - gmail: gmail_v1.Gmail, -): Promise { - const [awaitingReplyLabel] = await getOrCreateLabels({ - gmail, - names: [AWAITING_REPLY_LABEL_NAME], - }); - return awaitingReplyLabel.id || ""; -} - -export async function getNeedsReplyLabel( - gmail: gmail_v1.Gmail, -): Promise { - const [toReplyLabel] = await getOrCreateLabels({ - gmail, - names: [NEEDS_REPLY_LABEL_NAME], - }); - return toReplyLabel.id || ""; -} From d8cd5b2538bb98d8936dd6b47722995aadf7a5a7 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 5 Oct 2025 18:04:59 +0300 Subject: [PATCH 11/17] fixes to labelid/name --- .../assistant/CreatedRulesModal.tsx | 9 +- .../[emailAccountId]/assistant/RuleForm.tsx | 42 +- .../[emailAccountId]/assistant/Rules.tsx | 8 +- .../settings/LabelsSection.tsx | 387 ------------------ apps/web/app/(landing)/components/page.tsx | 1 + apps/web/app/api/user/rules/[id]/route.ts | 5 +- apps/web/prisma/schema.prisma | 2 +- apps/web/utils/action-display.tsx | 24 +- apps/web/utils/action-item.ts | 7 +- apps/web/utils/actions/rule.ts | 90 +++- apps/web/utils/actions/rule.validation.ts | 8 +- apps/web/utils/actions/settings.ts | 22 +- apps/web/utils/label/resolve-label.ts | 52 +++ apps/web/utils/reply-tracker/enable.ts | 45 +- apps/web/utils/rule/rule.ts | 39 +- 15 files changed, 272 insertions(+), 469 deletions(-) delete mode 100644 apps/web/app/(app)/[emailAccountId]/settings/LabelsSection.tsx create mode 100644 apps/web/utils/label/resolve-label.ts diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/CreatedRulesModal.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/CreatedRulesModal.tsx index d48f8ffa69..1cc7d2857e 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/CreatedRulesModal.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/CreatedRulesModal.tsx @@ -19,6 +19,7 @@ import { CheckCircle2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { prefixPath } from "@/utils/path"; import type { CreateRuleResult } from "@/utils/rule/types"; +import { useLabels } from "@/hooks/useLabels"; export function CreatedRulesModal({ open, @@ -54,6 +55,8 @@ export function CreatedRulesContent({ router.push(prefixPath(emailAccountId, "/automation?tab=test")); }; + const { userLabels } = useLabels(); + return ( <> @@ -90,7 +93,11 @@ export function CreatedRulesContent({
Actions: - +
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx index a35c572f97..2c0ec953a8 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx @@ -48,7 +48,6 @@ import { Toggle } from "@/components/Toggle"; import { LoadingContent } from "@/components/LoadingContent"; import { TooltipExplanation } from "@/components/TooltipExplanation"; import { useLabels } from "@/hooks/useLabels"; -import { createLabelAction } from "@/utils/actions/mail"; import { MultiSelectFilter } from "@/components/MultiSelectFilter"; import { useCategories } from "@/hooks/useCategories"; import { hasVariables, TEMPLATE_VARIABLE_PATTERN } from "@/utils/template"; @@ -200,20 +199,6 @@ export function RuleForm({ const onSubmit: SubmitHandler = useCallback( async (data) => { - // create labels that don't exist - for (const action of data.actions) { - if (action.type === ActionType.LABEL) { - const hasLabel = userLabels?.some( - (label) => label.name === action.label, - ); - if (!hasLabel && action.label?.value && !action.label?.ai) { - await createLabelAction(emailAccountId, { - name: action.label.value, - }); - } - } - } - // set content to empty string if it's not set manually for (const action of data.actions) { if (action.type === ActionType.DRAFT_EMAIL) { @@ -311,16 +296,7 @@ export function RuleForm({ } } }, - [ - userLabels, - router, - posthog, - emailAccountId, - isDialog, - onSuccess, - mutate, - rule, - ], + [router, posthog, emailAccountId, isDialog, onSuccess, mutate, rule], ); const conditions = watch("conditions"); @@ -341,7 +317,7 @@ export function RuleForm({ watch("actions")?.forEach((_, index) => { const actionError = errors?.actions?.[index]?.url?.root?.message || - errors?.actions?.[index]?.label?.root?.message || + errors?.actions?.[index]?.labelId?.root?.message || errors?.actions?.[index]?.to?.root?.message; if (actionError) actionErrors.push(actionError); }); @@ -1051,8 +1027,8 @@ function ActionCard({ ) => { // Check if the field is visible - this is handled before calling the function - // For label field, only allow variables if AI generated is toggled on - if (field.name === "label") { + // For labelId field, only allow variables if AI generated is toggled on + if (field.name === "labelId") { return isFieldAiGenerated; } @@ -1081,8 +1057,8 @@ function ActionCard({ if (!isFieldVisible) return false; - // For label field, only show variables if AI generated is toggled on - if (field.name === "label") { + // For labelId field, only show variables if AI generated is toggled on + if (field.name === "labelId") { return !!action[field.name]?.ai; } @@ -1144,7 +1120,7 @@ function ActionCard({