diff --git a/.claude/commands/write-tests.md b/.claude/commands/write-tests.md index c027098211..9495d6f402 100644 --- a/.claude/commands/write-tests.md +++ b/.claude/commands/write-tests.md @@ -49,6 +49,14 @@ import { getEmail, getEmailAccount, getRule } from "@/__tests__/helpers"; ## Workflow +### Step 0: Determine Scope + +Auto-detect: staged → branch diff → specified files + +```bash +git diff --cached --name-only # or main...HEAD +``` + ### Step 1: Identify Test Targets Look for functions with: diff --git a/.cursor/rules/posthog-feature-flags.mdc b/.cursor/rules/posthog-feature-flags.mdc index c3c004cd28..33648101b5 100644 --- a/.cursor/rules/posthog-feature-flags.mdc +++ b/.cursor/rules/posthog-feature-flags.mdc @@ -25,9 +25,9 @@ We use PostHog for two main purposes: All feature flag hooks should be defined in `apps/web/hooks/useFeatureFlags.ts`: ```typescript -// For early access features (boolean flags) -export function useFeatureName() { - return useFeatureFlagEnabled("feature-flag-key"); +// For early access features (boolean flags with env override) +export function useFeatureNameEnabled() { + return useFeatureFlagEnabled("feature-flag-key") || env.NEXT_PUBLIC_FEATURE_NAME_ENABLED; } // For A/B test variants @@ -39,6 +39,11 @@ export function useFeatureVariant() { } ``` +Early access features should support both PostHog flags AND environment variables using an OR (`||`). This allows: +- Production users to opt-in via PostHog Early Access +- Developers to enable features locally via `.env` +- Self-hosted users to enable features without PostHog + ### 2. Early Access Features Early access features are automatically displayed on the Early Access page (`/early-access`) through the `EarlyAccessFeatures` component. No manual configuration needed. @@ -47,7 +52,7 @@ Early access features are automatically displayed on the Early Access page (`/ea ```typescript // In useFeatureFlags.ts export function useCleanerEnabled() { - return useFeatureFlagEnabled("inbox-cleaner"); + return useFeatureFlagEnabled("inbox-cleaner") || env.NEXT_PUBLIC_CLEANER_ENABLED; } // Usage in components @@ -62,6 +67,11 @@ function MyComponent() { } ``` +When adding a new early access feature: +1. Add the hook with PostHog flag + env override +2. Add the env variable to `apps/web/env.ts` (schema + runtimeEnv) +3. Gate the UI component with the hook + ### 3. A/B Test Variants For A/B tests, define the variant types and provide a default fallback: diff --git a/apps/web/__tests__/e2e/flows/full-reply-cycle.test.ts b/apps/web/__tests__/e2e/flows/full-reply-cycle.test.ts index 50187bd87a..6e149d23d5 100644 --- a/apps/web/__tests__/e2e/flows/full-reply-cycle.test.ts +++ b/apps/web/__tests__/e2e/flows/full-reply-cycle.test.ts @@ -31,7 +31,6 @@ import { } from "./helpers/polling"; import { logStep, clearLogs, setTestStartTime } from "./helpers/logging"; import type { TestAccount } from "./helpers/accounts"; -import prisma from "@/utils/prisma"; describe.skipIf(!shouldRunFlowTests())("Full Reply Cycle", () => { let gmail: TestAccount; @@ -157,26 +156,12 @@ describe.skipIf(!shouldRunFlowTests())("Full Reply Cycle", () => { ); if (labelAction?.labelId) { - // Look up the label name from the database - const label = await prisma.label.findUnique({ - where: { id: labelAction.labelId }, - select: { name: true }, - }); - const message = await outlook.emailProvider.getMessage( outlookMessage.messageId, ); expect(message.labelIds).toBeDefined(); - - // Check if the label name is in the message's labels - // Outlook returns label names (categories), not IDs - if (label?.name) { - expect(message.labelIds).toContain(label.name); - logStep("Label verified on message", { - labelName: label.name, - messageLabels: message.labelIds, - }); - } + expect(message.labelIds).toContain(labelAction.labelId); + logStep("Labels on message", { labels: message.labelIds }); } // ======================================== @@ -302,10 +287,10 @@ describe.skipIf(!shouldRunFlowTests())("Full Reply Cycle", () => { body: "This is the reply from Outlook.", }); - // Wait for Gmail to receive - use fullSubject for unique match + // Wait for Gmail to receive const gmailReply = await waitForMessageInInbox({ provider: gmail.emailProvider, - subjectContains: initialEmail.fullSubject, + subjectContains: "Thread continuity test", timeout: TIMEOUTS.EMAIL_DELIVERY, }); @@ -325,10 +310,10 @@ describe.skipIf(!shouldRunFlowTests())("Full Reply Cycle", () => { body: "This is the second reply from Gmail.", }); - // Wait for Outlook to receive - use fullSubject for unique match + // Wait for Outlook to receive const outlookMsg2 = await waitForMessageInInbox({ provider: outlook.emailProvider, - subjectContains: initialEmail.fullSubject, + subjectContains: "Thread continuity test", timeout: TIMEOUTS.EMAIL_DELIVERY, }); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/FollowUpRemindersSetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/FollowUpRemindersSetting.tsx new file mode 100644 index 0000000000..484669e5e4 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/FollowUpRemindersSetting.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useForm } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { SettingCard } from "@/components/SettingCard"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/Input"; +import { Toggle } from "@/components/Toggle"; +import { Badge } from "@/components/Badge"; +import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; +import { useFollowUpRemindersEnabled } from "@/hooks/useFeatureFlags"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { useAction } from "next-safe-action/hooks"; +import { + toggleFollowUpRemindersAction, + updateFollowUpSettingsAction, + scanFollowUpRemindersAction, +} from "@/utils/actions/follow-up-reminders"; +import { + type SaveFollowUpSettingsFormInput, + DEFAULT_FOLLOW_UP_DAYS, +} from "@/utils/actions/follow-up-reminders.validation"; +import { toastError, toastSuccess } from "@/components/Toast"; +import { getEmailTerminology } from "@/utils/terminology"; + +export function FollowUpRemindersSetting() { + const isFeatureEnabled = useFollowUpRemindersEnabled(); + + if (!isFeatureEnabled) return null; + + return ; +} + +function FollowUpRemindersSettingContent() { + const [open, setOpen] = useState(false); + const { data, mutate } = useEmailAccountFull(); + + const enabled = + data?.followUpAwaitingReplyDays !== null || + data?.followUpNeedsReplyDays !== null; + + const { execute: executeToggle } = useAction( + toggleFollowUpRemindersAction.bind(null, data?.id ?? ""), + { + onError: (error) => { + mutate(); + toastError({ + description: error.error?.serverError ?? "Failed to update settings", + }); + }, + }, + ); + + const handleToggle = useCallback( + (enable: boolean) => { + if (!data) return; + + const optimisticData = { + ...data, + followUpAwaitingReplyDays: enable ? DEFAULT_FOLLOW_UP_DAYS : null, + followUpNeedsReplyDays: enable ? DEFAULT_FOLLOW_UP_DAYS : null, + }; + mutate(optimisticData, false); + executeToggle({ enabled: enable }); + }, + [data, mutate, executeToggle], + ); + + return ( + + {enabled && ( + + + + + { + mutate(); + setOpen(false); + }} + /> + + )} + + + } + /> + ); +} + +function FollowUpSettingsDialog({ + emailAccountId, + followUpAwaitingReplyDays, + followUpNeedsReplyDays, + followUpAutoDraftEnabled, + onSuccess, +}: { + emailAccountId: string; + followUpAwaitingReplyDays: number | null | undefined; + followUpNeedsReplyDays: number | null | undefined; + followUpAutoDraftEnabled: boolean; + onSuccess: () => void; +}) { + const { provider } = useAccount(); + const terminology = getEmailTerminology(provider); + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + defaultValues: { + followUpAwaitingReplyDays: followUpAwaitingReplyDays?.toString() ?? "", + followUpNeedsReplyDays: followUpNeedsReplyDays?.toString() ?? "", + followUpAutoDraftEnabled, + }, + }); + + const autoDraftValue = watch("followUpAutoDraftEnabled"); + + const { execute, isExecuting } = useAction( + updateFollowUpSettingsAction.bind(null, emailAccountId), + { + onSuccess: () => { + toastSuccess({ description: "Settings saved!" }); + onSuccess(); + }, + onError: (error) => { + toastError({ + description: error.error?.serverError ?? "Failed to save settings", + }); + }, + }, + ); + + const { execute: executeScan, isExecuting: isScanning } = useAction( + scanFollowUpRemindersAction.bind(null, emailAccountId), + { + onSuccess: () => { + toastSuccess({ description: "Scan complete!" }); + }, + onError: (error) => { + toastError({ + description: error.error?.serverError ?? "Failed to scan", + }); + }, + }, + ); + + const onSubmit = (formData: SaveFollowUpSettingsFormInput) => { + execute({ + followUpAwaitingReplyDays: formData.followUpAwaitingReplyDays + ? Number(formData.followUpAwaitingReplyDays) + : null, + followUpNeedsReplyDays: formData.followUpNeedsReplyDays + ? Number(formData.followUpNeedsReplyDays) + : null, + followUpAutoDraftEnabled: formData.followUpAutoDraftEnabled, + }); + }; + + return ( + + + Follow-up Reminders + + Get reminded about conversations that need attention. +
+ We'll add a Follow-up{" "} + {terminology.label.singular} so you can easily find them. +
+
+ +
+ + + + +
+
+ +

+ Draft a nudge when you haven't heard back. +

+
+ setValue("followUpAutoDraftEnabled", value)} + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx index b84d12ac9a..5c995550fb 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx @@ -2,6 +2,7 @@ import { AboutSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/Ab import { DigestSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/DigestSetting"; import { DraftReplies } from "@/app/(app)/[emailAccountId]/assistant/settings/DraftReplies"; import { DraftKnowledgeSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/DraftKnowledgeSetting"; +import { FollowUpRemindersSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/FollowUpRemindersSetting"; 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"; @@ -18,6 +19,7 @@ export function SettingsTab() { + {env.NEXT_PUBLIC_DIGEST_ENABLED && } diff --git a/apps/web/app/api/follow-up-reminders/process.ts b/apps/web/app/api/follow-up-reminders/process.ts new file mode 100644 index 0000000000..9b65a2274b --- /dev/null +++ b/apps/web/app/api/follow-up-reminders/process.ts @@ -0,0 +1,266 @@ +import { subDays } from "date-fns/subDays"; +import prisma from "@/utils/prisma"; +import { getPremiumUserFilter } from "@/utils/premium"; +import { createEmailProvider } from "@/utils/email/provider"; +import { + applyFollowUpLabel, + getOrCreateFollowUpLabel, +} from "@/utils/follow-up/labels"; +import { generateFollowUpDraft } from "@/utils/follow-up/generate-draft"; +import { cleanupStaleDrafts } from "@/utils/follow-up/cleanup"; +import { ThreadTrackerType } from "@/generated/prisma/enums"; +import type { EmailProvider } from "@/utils/email/types"; +import type { Logger } from "@/utils/logger"; +import { captureException } from "@/utils/error"; +import type { ThreadTracker } from "@/generated/prisma/client"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; + +export async function processAllFollowUpReminders(logger: Logger) { + logger.info("Processing follow-up reminders for all users"); + + const emailAccounts = await prisma.emailAccount.findMany({ + where: { + OR: [ + { followUpAwaitingReplyDays: { not: null } }, + { followUpNeedsReplyDays: { not: null } }, + ], + ...getPremiumUserFilter(), + }, + select: { + id: true, + email: true, + about: true, + userId: true, + multiRuleSelectionEnabled: true, + timezone: true, + calendarBookingLink: true, + followUpAwaitingReplyDays: true, + followUpNeedsReplyDays: true, + followUpAutoDraftEnabled: true, + user: { + select: { + aiProvider: true, + aiModel: true, + aiApiKey: true, + }, + }, + account: { + select: { + provider: true, + }, + }, + }, + }); + + logger.info("Found eligible accounts", { count: emailAccounts.length }); + + let successCount = 0; + let errorCount = 0; + + for (const emailAccount of emailAccounts) { + const accountLogger = logger.with({ + emailAccountId: emailAccount.id, + }); + + try { + await processAccountFollowUps({ + emailAccount, + logger: accountLogger, + }); + successCount++; + } catch (error) { + accountLogger.error("Failed to process follow-up reminders for user", { + error, + }); + captureException(error); + errorCount++; + } + } + + logger.info("Completed processing follow-up reminders", { + total: emailAccounts.length, + success: successCount, + errors: errorCount, + }); + + return { + total: emailAccounts.length, + success: successCount, + errors: errorCount, + }; +} + +export async function processAccountFollowUps({ + emailAccount, + logger, +}: { + emailAccount: EmailAccountWithAI & { + followUpAwaitingReplyDays: number | null; + followUpNeedsReplyDays: number | null; + followUpAutoDraftEnabled: boolean; + }; + logger: Logger; +}) { + const now = new Date(); + const emailAccountId = emailAccount.id; + + logger.info("Processing follow-ups for account"); + + if (!emailAccount.account?.provider) { + logger.warn("Skipping account with no provider"); + return; + } + + const provider = await createEmailProvider({ + emailAccountId, + provider: emailAccount.account.provider, + logger, + }); + + const followUpLabel = await getOrCreateFollowUpLabel(provider); + + if (emailAccount.followUpAwaitingReplyDays !== null) { + const awaitingThreshold = subDays( + now, + emailAccount.followUpAwaitingReplyDays, + ); + const awaitingTrackers = await prisma.threadTracker.findMany({ + where: { + emailAccountId, + type: ThreadTrackerType.AWAITING, + resolved: false, + followUpAppliedAt: null, + sentAt: { lt: awaitingThreshold }, + }, + }); + + logger.info("Found awaiting trackers past threshold", { + count: awaitingTrackers.length, + thresholdDays: emailAccount.followUpAwaitingReplyDays, + }); + + await processTrackers({ + trackers: awaitingTrackers, + emailAccount, + provider, + labelId: followUpLabel.id, + trackerType: ThreadTrackerType.AWAITING, + generateDraft: emailAccount.followUpAutoDraftEnabled, + now, + logger, + }); + } + + if (emailAccount.followUpNeedsReplyDays !== null) { + const needsReplyThreshold = subDays( + now, + emailAccount.followUpNeedsReplyDays, + ); + const needsReplyTrackers = await prisma.threadTracker.findMany({ + where: { + emailAccountId, + type: ThreadTrackerType.NEEDS_REPLY, + resolved: false, + followUpAppliedAt: null, + sentAt: { lt: needsReplyThreshold }, + }, + }); + + logger.info("Found needs-reply trackers past threshold", { + count: needsReplyTrackers.length, + thresholdDays: emailAccount.followUpNeedsReplyDays, + }); + + await processTrackers({ + trackers: needsReplyTrackers, + emailAccount, + provider, + labelId: followUpLabel.id, + trackerType: ThreadTrackerType.NEEDS_REPLY, + generateDraft: false, + now, + logger, + }); + } + + // Wrapped in try/catch since it's non-critical + try { + await cleanupStaleDrafts({ + emailAccountId, + provider, + logger, + }); + } catch (error) { + logger.error("Failed to cleanup stale drafts", { error }); + captureException(error); + } + + logger.info("Finished processing follow-ups for account"); +} + +async function processTrackers({ + trackers, + emailAccount, + provider, + labelId, + trackerType, + generateDraft, + now, + logger, +}: { + trackers: ThreadTracker[]; + emailAccount: EmailAccountWithAI; + provider: EmailProvider; + labelId: string; + trackerType: ThreadTrackerType; + generateDraft: boolean; + now: Date; + logger: Logger; +}) { + let processedCount = 0; + + for (const tracker of trackers) { + const trackerLogger = logger.with({ + threadId: tracker.threadId, + type: trackerType, + }); + + try { + await applyFollowUpLabel({ + provider, + threadId: tracker.threadId, + messageId: tracker.messageId, + labelId, + logger: trackerLogger, + }); + + if (generateDraft) { + await generateFollowUpDraft({ + emailAccount, + threadId: tracker.threadId, + provider, + logger: trackerLogger, + }); + } + + await prisma.threadTracker.update({ + where: { id: tracker.id }, + data: { followUpAppliedAt: now }, + }); + + trackerLogger.info("Processed tracker", { + draftGenerated: generateDraft, + }); + processedCount++; + } catch (error) { + trackerLogger.error("Failed to process tracker", { error }); + captureException(error); + } + } + + logger.info("Finished processing trackers", { + type: trackerType, + processed: processedCount, + total: trackers.length, + }); +} diff --git a/apps/web/app/api/follow-up-reminders/route.ts b/apps/web/app/api/follow-up-reminders/route.ts new file mode 100644 index 0000000000..468ebc99b8 --- /dev/null +++ b/apps/web/app/api/follow-up-reminders/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { withError } from "@/utils/middleware"; +import { hasCronSecret, hasPostCronSecret } from "@/utils/cron"; +import { captureException } from "@/utils/error"; +import { processAllFollowUpReminders } from "./process"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 800; + +export const GET = withError("follow-up-reminders", async (request) => { + if (!hasCronSecret(request)) { + captureException( + new Error("Unauthorized request: api/follow-up-reminders"), + ); + return new Response("Unauthorized", { status: 401 }); + } + + const result = await processAllFollowUpReminders(request.logger); + + return NextResponse.json(result); +}); + +export const POST = withError("follow-up-reminders", async (request) => { + if (!(await hasPostCronSecret(request))) { + captureException( + new Error("Unauthorized cron request: api/follow-up-reminders"), + ); + return new Response("Unauthorized", { status: 401 }); + } + + const result = await processAllFollowUpReminders(request.logger); + + return NextResponse.json(result); +}); diff --git a/apps/web/app/api/user/email-account/route.ts b/apps/web/app/api/user/email-account/route.ts index 053b27cf85..d65aebb39c 100644 --- a/apps/web/app/api/user/email-account/route.ts +++ b/apps/web/app/api/user/email-account/route.ts @@ -24,6 +24,9 @@ async function getEmailAccount({ emailAccountId }: { emailAccountId: string }) { signature: true, includeReferralSignature: true, writingStyle: true, + followUpAwaitingReplyDays: true, + followUpNeedsReplyDays: true, + followUpAutoDraftEnabled: true, }, }); diff --git a/apps/web/env.ts b/apps/web/env.ts index 94e08ec27a..488ec33b68 100644 --- a/apps/web/env.ts +++ b/apps/web/env.ts @@ -189,6 +189,7 @@ export const env = createEnv({ NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS: z.coerce.boolean().optional(), NEXT_PUBLIC_DIGEST_ENABLED: z.coerce.boolean().optional(), NEXT_PUBLIC_MEETING_BRIEFS_ENABLED: z.coerce.boolean().optional(), + NEXT_PUBLIC_FOLLOW_UP_REMINDERS_ENABLED: z.coerce.boolean().optional(), NEXT_PUBLIC_INTEGRATIONS_ENABLED: z.coerce.boolean().optional(), NEXT_PUBLIC_CLEANER_ENABLED: z.coerce.boolean().optional(), NEXT_PUBLIC_IS_RESEND_CONFIGURED: z.coerce.boolean().optional(), @@ -251,6 +252,8 @@ export const env = createEnv({ NEXT_PUBLIC_DIGEST_ENABLED: process.env.NEXT_PUBLIC_DIGEST_ENABLED, NEXT_PUBLIC_MEETING_BRIEFS_ENABLED: process.env.NEXT_PUBLIC_MEETING_BRIEFS_ENABLED, + NEXT_PUBLIC_FOLLOW_UP_REMINDERS_ENABLED: + process.env.NEXT_PUBLIC_FOLLOW_UP_REMINDERS_ENABLED, NEXT_PUBLIC_INTEGRATIONS_ENABLED: process.env.NEXT_PUBLIC_INTEGRATIONS_ENABLED, NEXT_PUBLIC_CLEANER_ENABLED: process.env.NEXT_PUBLIC_CLEANER_ENABLED, diff --git a/apps/web/hooks/useFeatureFlags.ts b/apps/web/hooks/useFeatureFlags.ts index d74170cd2e..a7dc5f4bac 100644 --- a/apps/web/hooks/useFeatureFlags.ts +++ b/apps/web/hooks/useFeatureFlags.ts @@ -9,6 +9,13 @@ export function useCleanerEnabled() { return env.NEXT_PUBLIC_CLEANER_ENABLED || posthogEnabled; } +export function useFollowUpRemindersEnabled() { + return ( + useFeatureFlagEnabled("follow-up-reminders") || + env.NEXT_PUBLIC_FOLLOW_UP_REMINDERS_ENABLED + ); +} + export function useMeetingBriefsEnabled() { return env.NEXT_PUBLIC_MEETING_BRIEFS_ENABLED; } diff --git a/apps/web/prisma/migrations/20260111000000_add_follow_up_reminders/migration.sql b/apps/web/prisma/migrations/20260111000000_add_follow_up_reminders/migration.sql new file mode 100644 index 0000000000..727529a5a8 --- /dev/null +++ b/apps/web/prisma/migrations/20260111000000_add_follow_up_reminders/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "EmailAccount" ADD COLUMN "followUpAwaitingReplyDays" INTEGER, +ADD COLUMN "followUpNeedsReplyDays" INTEGER, +ADD COLUMN "followUpAutoDraftEnabled" BOOLEAN NOT NULL DEFAULT true; + +-- AlterTable +ALTER TABLE "ThreadTracker" ADD COLUMN "followUpAppliedAt" TIMESTAMP(3); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index f71be77a50..9339f96850 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -145,6 +145,10 @@ model EmailAccount { meetingBriefingsEnabled Boolean @default(false) meetingBriefingsMinutesBefore Int @default(240) // 4 hours in minutes + followUpAwaitingReplyDays Int? + followUpNeedsReplyDays Int? + followUpAutoDraftEnabled Boolean @default(true) + digestSchedule Schedule? userId String @@ -748,6 +752,8 @@ model ThreadTracker { resolved Boolean @default(false) type ThreadTrackerType + followUpAppliedAt DateTime? // When the follow-up label was added (prevents re-processing) + emailAccountId String emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) @@ -755,6 +761,7 @@ model ThreadTracker { @@index([emailAccountId, resolved]) @@index([emailAccountId, resolved, sentAt, type]) @@index([emailAccountId, type, resolved, sentAt]) + @@index([emailAccountId, type, resolved, followUpAppliedAt, sentAt]) } model CleanupJob { diff --git a/apps/web/utils/actions/follow-up-reminders.ts b/apps/web/utils/actions/follow-up-reminders.ts new file mode 100644 index 0000000000..75f23d4052 --- /dev/null +++ b/apps/web/utils/actions/follow-up-reminders.ts @@ -0,0 +1,81 @@ +"use server"; + +import { z } from "zod"; +import { actionClient } from "@/utils/actions/safe-action"; +import { + toggleFollowUpRemindersBody, + saveFollowUpSettingsBody, + DEFAULT_FOLLOW_UP_DAYS, +} from "@/utils/actions/follow-up-reminders.validation"; +import prisma from "@/utils/prisma"; +import { processAccountFollowUps } from "@/app/api/follow-up-reminders/process"; +import { SafeError } from "@/utils/error"; + +export const toggleFollowUpRemindersAction = actionClient + .metadata({ name: "toggleFollowUpReminders" }) + .inputSchema(toggleFollowUpRemindersBody) + .action(async ({ ctx: { emailAccountId }, parsedInput: { enabled } }) => { + await prisma.emailAccount.update({ + where: { id: emailAccountId }, + data: { + followUpAwaitingReplyDays: enabled ? DEFAULT_FOLLOW_UP_DAYS : null, + followUpNeedsReplyDays: enabled ? DEFAULT_FOLLOW_UP_DAYS : null, + }, + }); + }); + +export const updateFollowUpSettingsAction = actionClient + .metadata({ name: "updateFollowUpSettings" }) + .inputSchema(saveFollowUpSettingsBody) + .action( + async ({ + ctx: { emailAccountId }, + parsedInput: { + followUpAwaitingReplyDays, + followUpNeedsReplyDays, + followUpAutoDraftEnabled, + }, + }) => { + await prisma.emailAccount.update({ + where: { id: emailAccountId }, + data: { + followUpAwaitingReplyDays, + followUpNeedsReplyDays, + followUpAutoDraftEnabled, + }, + }); + }, + ); + +export const scanFollowUpRemindersAction = actionClient + .metadata({ name: "scanFollowUpReminders" }) + .inputSchema(z.object({})) + .action(async ({ ctx: { emailAccountId, logger } }) => { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + id: true, + userId: true, + email: true, + about: true, + multiRuleSelectionEnabled: true, + timezone: true, + calendarBookingLink: true, + followUpAwaitingReplyDays: true, + followUpNeedsReplyDays: true, + followUpAutoDraftEnabled: true, + user: { + select: { + aiProvider: true, + aiModel: true, + aiApiKey: true, + }, + }, + account: { select: { provider: true } }, + }, + }); + + if (!emailAccount) throw new SafeError("Email account not found"); + + await processAccountFollowUps({ emailAccount, logger }); + }); diff --git a/apps/web/utils/actions/follow-up-reminders.validation.ts b/apps/web/utils/actions/follow-up-reminders.validation.ts new file mode 100644 index 0000000000..6c09951ed8 --- /dev/null +++ b/apps/web/utils/actions/follow-up-reminders.validation.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +export const DEFAULT_FOLLOW_UP_DAYS = 3; + +export const toggleFollowUpRemindersBody = z.object({ + enabled: z.boolean(), +}); +export type ToggleFollowUpRemindersBody = z.infer< + typeof toggleFollowUpRemindersBody +>; + +const daysSchema = z.number().min(0.001).max(90).nullable(); + +export const saveFollowUpSettingsBody = z.object({ + followUpAwaitingReplyDays: daysSchema, + followUpNeedsReplyDays: daysSchema, + followUpAutoDraftEnabled: z.boolean(), +}); +export type SaveFollowUpSettingsBody = z.infer; + +export const saveFollowUpSettingsFormBody = z.object({ + followUpAwaitingReplyDays: z.string(), + followUpNeedsReplyDays: z.string(), + followUpAutoDraftEnabled: z.boolean(), +}); +export type SaveFollowUpSettingsFormInput = z.infer< + typeof saveFollowUpSettingsFormBody +>; diff --git a/apps/web/utils/follow-up/cleanup.test.ts b/apps/web/utils/follow-up/cleanup.test.ts new file mode 100644 index 0000000000..47283ef087 --- /dev/null +++ b/apps/web/utils/follow-up/cleanup.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import prisma from "@/utils/__mocks__/prisma"; +import { cleanupStaleDrafts } from "./cleanup"; +import { createScopedLogger } from "@/utils/logger"; +import { createMockEmailProvider } from "@/__tests__/mocks/email-provider.mock"; +import { subDays } from "date-fns/subDays"; + +vi.mock("@/utils/prisma"); + +vi.mock("./labels", () => ({ + hasFollowUpLabel: vi.fn(), +})); + +import { hasFollowUpLabel } from "./labels"; + +const mockHasFollowUpLabel = vi.mocked(hasFollowUpLabel); + +const logger = createScopedLogger("test"); + +describe("cleanupStaleDrafts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("cleans up stale drafts when found", async () => { + const staleDate = subDays(new Date(), 10); + const mockProvider = createMockEmailProvider({ + getDrafts: vi.fn().mockResolvedValue([ + { id: "draft-1", threadId: "thread-1" }, + { id: "draft-2", threadId: "thread-2" }, + ]), + deleteDraft: vi.fn().mockResolvedValue(undefined), + }); + + prisma.threadTracker.findMany.mockResolvedValue([ + { id: "tracker-1", threadId: "thread-1", followUpAppliedAt: staleDate }, + ] as any); + + mockHasFollowUpLabel.mockResolvedValue(true); + + await cleanupStaleDrafts({ + emailAccountId: "account-1", + provider: mockProvider, + logger, + }); + + expect(prisma.threadTracker.findMany).toHaveBeenCalled(); + expect(mockProvider.getDrafts).toHaveBeenCalled(); + expect(mockHasFollowUpLabel).toHaveBeenCalledWith({ + provider: mockProvider, + threadId: "thread-1", + logger: expect.anything(), + }); + expect(mockProvider.deleteDraft).toHaveBeenCalledWith("draft-1"); + expect(mockProvider.deleteDraft).not.toHaveBeenCalledWith("draft-2"); + }); + + it("skips if thread no longer has follow-up label", async () => { + const staleDate = subDays(new Date(), 10); + const mockProvider = createMockEmailProvider({ + getDrafts: vi + .fn() + .mockResolvedValue([{ id: "draft-1", threadId: "thread-1" }]), + }); + + prisma.threadTracker.findMany.mockResolvedValue([ + { id: "tracker-1", threadId: "thread-1", followUpAppliedAt: staleDate }, + ] as any); + + mockHasFollowUpLabel.mockResolvedValue(false); + + await cleanupStaleDrafts({ + emailAccountId: "account-1", + provider: mockProvider, + logger, + }); + + expect(mockProvider.deleteDraft).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/utils/follow-up/cleanup.ts b/apps/web/utils/follow-up/cleanup.ts new file mode 100644 index 0000000000..4d560d35f2 --- /dev/null +++ b/apps/web/utils/follow-up/cleanup.ts @@ -0,0 +1,90 @@ +import { subDays } from "date-fns/subDays"; +import prisma from "@/utils/prisma"; +import type { EmailProvider } from "@/utils/email/types"; +import type { Logger } from "@/utils/logger"; +import { hasFollowUpLabel } from "./labels"; + +const STALE_DRAFT_DAYS = 7; + +export async function cleanupStaleDrafts({ + emailAccountId, + provider, + logger, +}: { + emailAccountId: string; + provider: EmailProvider; + logger: Logger; +}): Promise { + const staleThreshold = subDays(new Date(), STALE_DRAFT_DAYS); + + logger.info("Cleaning up stale follow-up drafts", { + thresholdDays: STALE_DRAFT_DAYS, + before: staleThreshold.toISOString(), + }); + + const staleTrackers = await prisma.threadTracker.findMany({ + where: { + emailAccountId, + followUpAppliedAt: { lt: staleThreshold }, + resolved: false, + }, + select: { + id: true, + threadId: true, + followUpAppliedAt: true, + }, + }); + + logger.info("Found stale trackers", { count: staleTrackers.length }); + + const allDrafts = + staleTrackers.length > 0 + ? await provider.getDrafts({ maxResults: 100 }) + : []; + + for (const tracker of staleTrackers) { + const trackerLogger = logger.with({ + trackerId: tracker.id, + threadId: tracker.threadId, + }); + + try { + const hasLabel = await hasFollowUpLabel({ + provider, + threadId: tracker.threadId, + logger: trackerLogger, + }); + + if (!hasLabel) { + trackerLogger.info("Thread no longer has follow-up label, skipping"); + continue; + } + + const threadDrafts = allDrafts.filter( + (draft) => draft.threadId === tracker.threadId, + ); + + for (const draft of threadDrafts) { + try { + await provider.deleteDraft(draft.id); + trackerLogger.info("Deleted stale draft", { draftId: draft.id }); + } catch (error) { + trackerLogger.warn("Failed to delete stale draft", { + draftId: draft.id, + error, + }); + } + } + + trackerLogger.info("Cleaned up stale drafts for thread", { + deletedCount: threadDrafts.length, + }); + } catch (error) { + trackerLogger.error("Failed to cleanup stale drafts for thread", { + error, + }); + } + } + + logger.info("Finished cleaning up stale drafts"); +} diff --git a/apps/web/utils/follow-up/generate-draft.ts b/apps/web/utils/follow-up/generate-draft.ts new file mode 100644 index 0000000000..a6df33c403 --- /dev/null +++ b/apps/web/utils/follow-up/generate-draft.ts @@ -0,0 +1,54 @@ +import { fetchMessagesAndGenerateDraft } from "@/utils/reply-tracker/generate-draft"; +import type { EmailProvider } from "@/utils/email/types"; +import type { Logger } from "@/utils/logger"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; + +/** + * Generates a follow-up draft for a thread that's awaiting a reply. + * This is used when the cron job detects threads past their follow-up threshold. + */ +export async function generateFollowUpDraft({ + emailAccount, + threadId, + provider, + logger, +}: { + emailAccount: EmailAccountWithAI; + threadId: string; + provider: EmailProvider; + logger: Logger; +}): Promise { + logger.info("Generating follow-up draft", { threadId }); + + try { + const thread = await provider.getThread(threadId); + if (!thread.messages?.length) { + logger.warn("Thread has no messages", { threadId }); + return; + } + + const lastMessage = thread.messages[thread.messages.length - 1]; + + const draftContent = await fetchMessagesAndGenerateDraft( + emailAccount, + threadId, + provider, + undefined, // no test message + logger, + ); + + const { draftId } = await provider.draftEmail( + lastMessage, + { + content: draftContent, + }, + emailAccount.email, + undefined, // no executed rule context for follow-up drafts + ); + + logger.info("Follow-up draft created", { threadId, draftId }); + } catch (error) { + logger.error("Failed to generate follow-up draft", { threadId, error }); + throw error; + } +} diff --git a/apps/web/utils/follow-up/labels.test.ts b/apps/web/utils/follow-up/labels.test.ts new file mode 100644 index 0000000000..8be1146064 --- /dev/null +++ b/apps/web/utils/follow-up/labels.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + getOrCreateFollowUpLabel, + applyFollowUpLabel, + removeFollowUpLabel, + hasFollowUpLabel, + clearFollowUpLabel, +} from "./labels"; +import { getMockMessage } from "@/__tests__/helpers"; +import { createScopedLogger } from "@/utils/logger"; +import { createMockEmailProvider } from "@/__tests__/mocks/email-provider.mock"; +import prisma from "@/utils/__mocks__/prisma"; + +vi.mock("@/utils/prisma"); + +const logger = createScopedLogger("test"); + +describe("getOrCreateFollowUpLabel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns existing label if found", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi + .fn() + .mockResolvedValue({ id: "label-123", name: "Follow-up" }), + }); + + const result = await getOrCreateFollowUpLabel(mockProvider); + + expect(result).toEqual({ id: "label-123", name: "Follow-up" }); + expect(mockProvider.getLabelByName).toHaveBeenCalledWith("Follow-up"); + expect(mockProvider.createLabel).not.toHaveBeenCalled(); + }); + + it("creates new label if not found", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi.fn().mockResolvedValue(null), + createLabel: vi + .fn() + .mockResolvedValue({ id: "new-label-456", name: "Follow-up" }), + }); + + const result = await getOrCreateFollowUpLabel(mockProvider); + + expect(result).toEqual({ id: "new-label-456", name: "Follow-up" }); + expect(mockProvider.getLabelByName).toHaveBeenCalledWith("Follow-up"); + expect(mockProvider.createLabel).toHaveBeenCalledWith("Follow-up"); + }); +}); + +describe("applyFollowUpLabel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("applies label to message", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi + .fn() + .mockResolvedValue({ id: "label-123", name: "Follow-up" }), + labelMessage: vi.fn().mockResolvedValue(undefined), + }); + + await applyFollowUpLabel({ + provider: mockProvider, + threadId: "thread-1", + messageId: "msg-1", + logger, + }); + + expect(mockProvider.labelMessage).toHaveBeenCalledWith({ + messageId: "msg-1", + labelId: "label-123", + labelName: "Follow-up", + }); + }); + + it("creates label if not exists before applying", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi.fn().mockResolvedValue(null), + createLabel: vi + .fn() + .mockResolvedValue({ id: "new-label", name: "Follow-up" }), + labelMessage: vi.fn().mockResolvedValue(undefined), + }); + + await applyFollowUpLabel({ + provider: mockProvider, + threadId: "thread-1", + messageId: "msg-1", + logger, + }); + + expect(mockProvider.createLabel).toHaveBeenCalledWith("Follow-up"); + expect(mockProvider.labelMessage).toHaveBeenCalledWith({ + messageId: "msg-1", + labelId: "new-label", + labelName: "Follow-up", + }); + }); +}); + +describe("removeFollowUpLabel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("removes label from thread if exists", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi + .fn() + .mockResolvedValue({ id: "label-123", name: "Follow-up" }), + removeThreadLabel: vi.fn().mockResolvedValue(undefined), + }); + + await removeFollowUpLabel({ + provider: mockProvider, + threadId: "thread-1", + logger, + }); + + expect(mockProvider.removeThreadLabel).toHaveBeenCalledWith( + "thread-1", + "label-123", + ); + }); + + it("does nothing if label does not exist", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi.fn().mockResolvedValue(null), + }); + + await removeFollowUpLabel({ + provider: mockProvider, + threadId: "thread-1", + logger, + }); + + expect(mockProvider.removeThreadLabel).not.toHaveBeenCalled(); + }); + + it("handles error when removing label (label not on thread)", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi + .fn() + .mockResolvedValue({ id: "label-123", name: "Follow-up" }), + removeThreadLabel: vi + .fn() + .mockRejectedValue(new Error("Label not on thread")), + }); + + await expect( + removeFollowUpLabel({ + provider: mockProvider, + threadId: "thread-1", + logger, + }), + ).resolves.not.toThrow(); + }); +}); + +describe("hasFollowUpLabel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true if any message has the label", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi + .fn() + .mockResolvedValue({ id: "label-123", name: "Follow-up" }), + getThread: vi.fn().mockResolvedValue({ + id: "thread-1", + messages: [ + getMockMessage({ id: "msg-1", labelIds: ["other-label"] }), + getMockMessage({ id: "msg-2", labelIds: ["label-123", "another"] }), + ], + }), + }); + + const result = await hasFollowUpLabel({ + provider: mockProvider, + threadId: "thread-1", + logger, + }); + + expect(result).toBe(true); + }); + + it("returns false if no message has the label", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi + .fn() + .mockResolvedValue({ id: "label-123", name: "Follow-up" }), + getThread: vi.fn().mockResolvedValue({ + id: "thread-1", + messages: [ + getMockMessage({ id: "msg-1", labelIds: ["other-label"] }), + getMockMessage({ id: "msg-2", labelIds: ["another"] }), + ], + }), + }); + + const result = await hasFollowUpLabel({ + provider: mockProvider, + threadId: "thread-1", + logger, + }); + + expect(result).toBe(false); + }); + + it("returns false if label does not exist", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi.fn().mockResolvedValue(null), + }); + + const result = await hasFollowUpLabel({ + provider: mockProvider, + threadId: "thread-1", + logger, + }); + + expect(result).toBe(false); + expect(mockProvider.getThread).not.toHaveBeenCalled(); + }); + + it("returns false if thread has no messages", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi + .fn() + .mockResolvedValue({ id: "label-123", name: "Follow-up" }), + getThread: vi.fn().mockResolvedValue({ + id: "thread-1", + messages: [], + }), + }); + + const result = await hasFollowUpLabel({ + provider: mockProvider, + threadId: "thread-1", + logger, + }); + + expect(result).toBe(false); + }); + + it("returns false on error", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi + .fn() + .mockResolvedValue({ id: "label-123", name: "Follow-up" }), + getThread: vi.fn().mockRejectedValue(new Error("Thread not found")), + }); + + const result = await hasFollowUpLabel({ + provider: mockProvider, + threadId: "thread-1", + logger, + }); + + expect(result).toBe(false); + }); +}); + +describe("clearFollowUpLabel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("removes label and clears tracker when thread has follow-up label in DB", async () => { + const mockProvider = createMockEmailProvider({ + getLabelByName: vi + .fn() + .mockResolvedValue({ id: "label-123", name: "Follow-up" }), + }); + + prisma.threadTracker.updateMany.mockResolvedValue({ count: 1 }); + + await clearFollowUpLabel({ + emailAccountId: "account-1", + threadId: "thread-1", + provider: mockProvider, + logger, + }); + + expect(prisma.threadTracker.updateMany).toHaveBeenCalledWith({ + where: { + emailAccountId: "account-1", + threadId: "thread-1", + followUpAppliedAt: { not: null }, + resolved: false, + }, + data: { + followUpAppliedAt: null, + }, + }); + expect(mockProvider.removeThreadLabel).toHaveBeenCalledWith( + "thread-1", + "label-123", + ); + }); + + it("does nothing when no trackers updated", async () => { + const mockProvider = createMockEmailProvider(); + prisma.threadTracker.updateMany.mockResolvedValue({ count: 0 }); + + await clearFollowUpLabel({ + emailAccountId: "account-1", + threadId: "thread-1", + provider: mockProvider, + logger, + }); + + expect(mockProvider.removeThreadLabel).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/utils/follow-up/labels.ts b/apps/web/utils/follow-up/labels.ts new file mode 100644 index 0000000000..83061372d5 --- /dev/null +++ b/apps/web/utils/follow-up/labels.ts @@ -0,0 +1,144 @@ +import prisma from "@/utils/prisma"; +import type { EmailProvider } from "@/utils/email/types"; +import { FOLLOW_UP_LABEL } from "@/utils/label"; +import type { Logger } from "@/utils/logger"; + +export async function getOrCreateFollowUpLabel( + provider: EmailProvider, +): Promise<{ id: string; name: string }> { + const existingLabel = await provider.getLabelByName(FOLLOW_UP_LABEL); + if (existingLabel) { + return { id: existingLabel.id, name: existingLabel.name }; + } + + const createdLabel = await provider.createLabel(FOLLOW_UP_LABEL); + return { id: createdLabel.id, name: createdLabel.name }; +} + +export async function applyFollowUpLabel({ + provider, + threadId, + messageId, + labelId, + logger, +}: { + provider: EmailProvider; + threadId: string; + messageId: string; + labelId?: string; + logger: Logger; +}): Promise { + logger.info("Applying follow-up label", { threadId, messageId }); + + const finalLabelId = labelId ?? (await getOrCreateFollowUpLabel(provider)).id; + + await provider.labelMessage({ + messageId, + labelId: finalLabelId, + labelName: FOLLOW_UP_LABEL, + }); + + logger.info("Follow-up label applied", { threadId, labelId: finalLabelId }); +} + +export async function removeFollowUpLabel({ + provider, + threadId, + labelId, + logger, +}: { + provider: EmailProvider; + threadId: string; + labelId?: string; + logger: Logger; +}): Promise { + logger.info("Removing follow-up label", { threadId }); + + let finalLabelId = labelId; + if (!finalLabelId) { + const label = await provider.getLabelByName(FOLLOW_UP_LABEL); + if (!label) { + logger.info("Follow-up label does not exist, nothing to remove", { + threadId, + }); + return; + } + finalLabelId = label.id; + } + + try { + await provider.removeThreadLabel(threadId, finalLabelId); + logger.info("Follow-up label removed", { threadId, labelId: finalLabelId }); + } catch (error) { + logger.warn("Failed to remove follow-up label (may not exist on thread)", { + threadId, + error, + }); + } +} + +export async function hasFollowUpLabel({ + provider, + threadId, + logger, +}: { + provider: EmailProvider; + threadId: string; + logger: Logger; +}): Promise { + const label = await provider.getLabelByName(FOLLOW_UP_LABEL); + if (!label) return false; + + try { + const thread = await provider.getThread(threadId); + const messages = thread.messages; + if (!messages?.length) return false; + + return messages.some((message) => message.labelIds?.includes(label.id)); + } catch (error) { + logger.warn("Failed to check for follow-up label", { threadId, error }); + return false; + } +} + +export async function clearFollowUpLabel({ + emailAccountId, + threadId, + provider, + logger, +}: { + emailAccountId: string; + threadId: string; + provider: EmailProvider; + logger: Logger; +}): Promise { + if (!threadId) return; + + try { + const { count } = await prisma.threadTracker.updateMany({ + where: { + emailAccountId, + threadId, + followUpAppliedAt: { not: null }, + resolved: false, + }, + data: { + followUpAppliedAt: null, + }, + }); + + if (count === 0) { + return; + } + + logger.info("Removing follow-up label", { threadId }); + + await removeFollowUpLabel({ provider, threadId, logger }); + + logger.info("Removed follow-up label and cleared tracker", { + threadId, + }); + } catch (error) { + logger.error("Failed to remove follow-up label", { threadId, error }); + } +} diff --git a/apps/web/utils/label.ts b/apps/web/utils/label.ts index 33b1a45c40..87ce6647c1 100644 --- a/apps/web/utils/label.ts +++ b/apps/web/utils/label.ts @@ -62,6 +62,8 @@ export const inboxZeroLabels = { export type InboxZeroLabel = keyof typeof inboxZeroLabels; +export const FOLLOW_UP_LABEL = "Follow-up"; + export function getLabelColor(name: string) { switch (name) { case getRuleLabel(SystemType.TO_REPLY): @@ -84,6 +86,8 @@ export function getLabelColor(name: string) { return coral; case getRuleLabel(SystemType.COLD_EMAIL): return orange; + case FOLLOW_UP_LABEL: + return yellow; default: return getRandomLabelColor(); } diff --git a/apps/web/utils/reply-tracker/handle-outbound.ts b/apps/web/utils/reply-tracker/handle-outbound.ts index 866bd947f9..0dc6df1ec9 100644 --- a/apps/web/utils/reply-tracker/handle-outbound.ts +++ b/apps/web/utils/reply-tracker/handle-outbound.ts @@ -5,6 +5,7 @@ import type { Logger } from "@/utils/logger"; import { captureException } from "@/utils/error"; import { handleOutboundReply } from "./outbound"; import { trackSentDraftStatus, cleanupThreadAIDrafts } from "./draft-tracking"; +import { clearFollowUpLabel } from "@/utils/follow-up/labels"; export async function handleOutboundMessage({ emailAccount, @@ -57,4 +58,17 @@ export async function handleOutboundMessage({ logger.error("Error during thread draft cleanup", { error }); captureException(error, { emailAccountId: emailAccount.id }); } + + // Remove follow-up label if present (user replied, so follow-up no longer needed) + try { + await clearFollowUpLabel({ + emailAccountId: emailAccount.id, + threadId: message.threadId, + provider, + logger, + }); + } catch (error) { + logger.error("Error removing follow-up label", { error }); + captureException(error, { emailAccountId: emailAccount.id }); + } } diff --git a/apps/web/utils/reply-tracker/label-helpers.ts b/apps/web/utils/reply-tracker/label-helpers.ts index c7af241d76..d5fd9a6f9f 100644 --- a/apps/web/utils/reply-tracker/label-helpers.ts +++ b/apps/web/utils/reply-tracker/label-helpers.ts @@ -174,7 +174,9 @@ export async function applyThreadStatusLabel({ logger.info("Thread status label applied successfully"); } -async function getLabelsFromDb(emailAccountId: string): Promise { +export async function getLabelsFromDb( + emailAccountId: string, +): Promise { const rules = await prisma.rule.findMany({ where: { emailAccountId, diff --git a/apps/web/utils/webhook/process-history-item.ts b/apps/web/utils/webhook/process-history-item.ts index dc95d57615..36c1eb55b3 100644 --- a/apps/web/utils/webhook/process-history-item.ts +++ b/apps/web/utils/webhook/process-history-item.ts @@ -4,6 +4,7 @@ import { categorizeSender } from "@/utils/categorize/senders/categorize"; import { isAssistantEmail } from "@/utils/assistant/is-assistant-email"; import { processAssistantEmail } from "@/utils/assistant/process-assistant-email"; import { handleOutboundMessage } from "@/utils/reply-tracker/handle-outbound"; +import { clearFollowUpLabel } from "@/utils/follow-up/labels"; import { NewsletterStatus } from "@/generated/prisma/enums"; import type { EmailAccount } from "@/generated/prisma/client"; import { extractEmailAddress } from "@/utils/email"; @@ -12,6 +13,7 @@ import type { EmailProvider } from "@/utils/email/types"; import type { ParsedMessage, RuleWithActions } from "@/utils/types"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { Logger } from "@/utils/logger"; +import { captureException } from "@/utils/error"; export type SharedProcessHistoryOptions = { provider: EmailProvider; @@ -168,6 +170,20 @@ export async function processHistoryItem( logger, }); } + + // Remove follow-up label if present (they replied, so follow-up no longer needed) + // This handles the case where we were awaiting a reply from them + try { + await clearFollowUpLabel({ + emailAccountId, + threadId: actualThreadId, + provider, + logger, + }); + } catch (error) { + logger.error("Error removing follow-up label on inbound", { error }); + captureException(error, { emailAccountId }); + } } catch (error: unknown) { // Handle provider-specific "not found" errors if (error instanceof Error) { diff --git a/turbo.json b/turbo.json index dca7e3dd26..a4388e290b 100644 --- a/turbo.json +++ b/turbo.json @@ -134,6 +134,7 @@ "NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS", "NEXT_PUBLIC_DIGEST_ENABLED", "NEXT_PUBLIC_MEETING_BRIEFS_ENABLED", + "NEXT_PUBLIC_FOLLOW_UP_REMINDERS_ENABLED", "NEXT_PUBLIC_INTEGRATIONS_ENABLED", "NEXT_PUBLIC_CLEANER_ENABLED" ],