diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/CategoriesSetup.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/CategoriesSetup.tsx index 6a447c2ded..b40d4a1e47 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/CategoriesSetup.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/CategoriesSetup.tsx @@ -5,25 +5,10 @@ import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import type { ControllerRenderProps } from "react-hook-form"; -import { - Mail, - Newspaper, - Megaphone, - Calendar, - Receipt, - Bell, - Users, -} from "lucide-react"; import { TypographyH3, TypographyP } from "@/components/Typography"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, -} from "@/components/ui/form"; +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Select, SelectContent, @@ -31,7 +16,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Checkbox } from "@/components/ui/checkbox"; import { createRulesOnboardingAction } from "@/utils/actions/rule"; import { createRulesOnboardingBody, @@ -43,13 +27,12 @@ import { markOnboardingAsCompleted, } from "@/utils/cookies"; import { prefixPath } from "@/utils/path"; -import { useDigestEnabled } from "@/hooks/useFeatureFlags"; -import { ClientOnly } from "@/components/ClientOnly"; import Image from "next/image"; import { ExampleDialog, SeeExampleDialogButton, } from "@/app/(app)/[emailAccountId]/assistant/onboarding/ExampleDialog"; +import { categoryConfig } from "@/utils/category-config"; const NEXT_URL = "/assistant/onboarding/draft-replies"; @@ -69,31 +52,24 @@ export function CategoriesSetup({ defaultValues: { toReply: { action: defaultValues?.toReply?.action || "label", - hasDigest: defaultValues?.toReply?.hasDigest || false, }, newsletter: { action: defaultValues?.newsletter?.action || "label", - hasDigest: defaultValues?.newsletter?.hasDigest || false, }, marketing: { action: defaultValues?.marketing?.action || "label_archive", - hasDigest: defaultValues?.marketing?.hasDigest || false, }, calendar: { action: defaultValues?.calendar?.action || "label", - hasDigest: defaultValues?.calendar?.hasDigest || false, }, receipt: { action: defaultValues?.receipt?.action || "label", - hasDigest: defaultValues?.receipt?.hasDigest || false, }, notification: { action: defaultValues?.notification?.action || "label", - hasDigest: defaultValues?.notification?.hasDigest || false, }, coldEmail: { action: defaultValues?.coldEmail?.action || "label_archive", - hasDigest: defaultValues?.coldEmail?.hasDigest || false, }, }, }); @@ -139,55 +115,16 @@ export function CategoriesSetup({ />
- } - form={form} - /> - } - form={form} - /> - } - form={form} - /> - } - form={form} - /> - } - form={form} - /> - } - form={form} - /> - } - form={form} - /> + {categoryConfig.map((category) => ( + + ))}
@@ -239,9 +176,6 @@ function CategoryCard({ )}
- - - ); } - -function DigestCheckbox({ - form, - id, -}: { - form: ReturnType>; - id: keyof CreateRulesOnboardingBody; -}) { - const digestEnabled = useDigestEnabled(); - - if (!digestEnabled) return null; - - return ( - ; - }) => ( - - - { - field.onChange({ - ...field.value, - hasDigest: checked, - }); - }} - /> - - Digest - - )} - /> - ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/DigestFrequencyDialog.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/DigestFrequencyDialog.tsx new file mode 100644 index 0000000000..7123919c32 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/DigestFrequencyDialog.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { SchedulePicker } from "@/app/(app)/[emailAccountId]/settings/SchedulePicker"; +import { updateDigestScheduleAction } from "@/utils/actions/settings"; +import { toastError, toastSuccess } from "@/components/Toast"; +import type { SaveDigestScheduleBody } from "@/utils/actions/settings.validation"; +import { useAccount } from "@/providers/EmailAccountProvider"; + +interface DigestFrequencyDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DigestFrequencyDialog({ + open, + onOpenChange, +}: DigestFrequencyDialogProps) { + const { emailAccountId } = useAccount(); + const [isLoading, setIsLoading] = useState(false); + const [digestScheduleValue, setDigestScheduleValue] = useState< + SaveDigestScheduleBody["schedule"] + >({ + intervalDays: 7, + daysOfWeek: 1 << (6 - 1), // Monday (1) + timeOfDay: new Date(new Date().setHours(11, 0, 0, 0)), // 11 AM + occurrences: 1, + }); + + const updateDigestSchedule = updateDigestScheduleAction.bind( + null, + emailAccountId, + ); + + const handleSave = async () => { + if (!digestScheduleValue) return; + + setIsLoading(true); + try { + const result = await updateDigestSchedule({ + schedule: digestScheduleValue, + }); + + if (result?.serverError) { + toastError({ + description: "Failed to save digest frequency. Please try again.", + }); + } else { + toastSuccess({ description: "Digest frequency saved!" }); + onOpenChange(false); + } + } catch (error) { + toastError({ + description: "Failed to save digest frequency. Please try again.", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + Digest Email Frequency + + Choose how often you want to receive your digest emails. These + emails will include a summary of the actions taken on your behalf. + + +
+ +
+ + + + +
+
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/digest-frequency/page.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/digest-frequency/page.tsx index c12a59a14f..eb8eb40bf8 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/digest-frequency/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/digest-frequency/page.tsx @@ -1,15 +1,14 @@ "use client"; +import useSWR from "swr"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { SchedulePicker } from "@/app/(app)/[emailAccountId]/settings/SchedulePicker"; -import { useState } from "react"; -import { updateDigestScheduleAction } from "@/utils/actions/settings"; +import { useState, useEffect } from "react"; +import { updateDigestCategoriesAction } from "@/utils/actions/settings"; import { toastError, toastSuccess } from "@/components/Toast"; import { prefixPath } from "@/utils/path"; -import type { SaveDigestScheduleBody } from "@/utils/actions/settings.validation"; import { useAccount } from "@/providers/EmailAccountProvider"; import { markOnboardingAsCompleted, @@ -19,41 +18,64 @@ import { ExampleDialog, SeeExampleDialogButton, } from "@/app/(app)/[emailAccountId]/assistant/onboarding/ExampleDialog"; +import { DigestFrequencyDialog } from "@/app/(app)/[emailAccountId]/assistant/onboarding/DigestFrequencyDialog"; +import { Toggle } from "@/components/Toggle"; +import { TooltipExplanation } from "@/components/TooltipExplanation"; +import type { UpdateDigestCategoriesBody } from "@/utils/actions/settings.validation"; +import { categoryConfig } from "@/utils/category-config"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { GetDigestSettingsResponse } from "@/app/api/user/digest-settings/route"; export default function DigestFrequencyPage() { const { emailAccountId } = useAccount(); const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [showExampleDialog, setShowExampleDialog] = useState(false); - const [digestScheduleValue, setDigestScheduleValue] = useState< - SaveDigestScheduleBody["schedule"] - >({ - intervalDays: 7, - daysOfWeek: 1 << (6 - 1), // Monday (1) - timeOfDay: new Date(new Date().setHours(11, 0, 0, 0)), // 11 AM - occurrences: 1, + const [showFrequencyDialog, setShowFrequencyDialog] = useState(false); + + const { data: digestSettings, isLoading: isLoadingSettings } = + useSWR("/api/user/digest-settings"); + + const [settings, setSettings] = useState({ + toReply: false, + newsletter: false, + marketing: false, + calendar: false, + receipt: false, + notification: false, + coldEmail: false, }); - const updateDigestSchedule = updateDigestScheduleAction.bind( + // Update local state when digest settings are loaded + useEffect(() => { + if (digestSettings) { + setSettings(digestSettings); + } + }, [digestSettings]); + + const updateDigestCategories = updateDigestCategoriesAction.bind( null, emailAccountId, ); - const handleFinish = async () => { - if (!digestScheduleValue) return; + const handleToggle = (key: keyof UpdateDigestCategoriesBody) => { + setSettings((prev) => ({ + ...prev, + [key]: !prev[key], + })); + }; + const handleFinish = async () => { setIsLoading(true); try { - const result = await updateDigestSchedule({ - schedule: digestScheduleValue, - }); + const result = await updateDigestCategories(settings); if (result?.serverError) { toastError({ - description: "Failed to save digest frequency. Please try again.", + description: "Failed to save digest settings. Please try again.", }); } else { - toastSuccess({ description: "Digest frequency saved!" }); + toastSuccess({ description: "Digest settings saved!" }); markOnboardingAsCompleted(ASSISTANT_ONBOARDING_COOKIE); router.push( prefixPath(emailAccountId, "/assistant/onboarding/completed"), @@ -61,7 +83,7 @@ export default function DigestFrequencyPage() { } } catch (error) { toastError({ - description: "Failed to save digest frequency. Please try again.", + description: "Failed to save digest settings. Please try again.", }); } finally { setIsLoading(false); @@ -72,24 +94,79 @@ export default function DigestFrequencyPage() {
- Digest email + Digest Email

- Choose how often you want to receive your digest emails. These - emails will include a summary of the actions taken on your behalf, - based on your selected preferences.{" "} + Get a summary of actions taken on your behalf in a single email.{" "} setShowExampleDialog(true)} />

- + +
+
+

+ Choose categories to include: +

+ +
+ + {isLoadingSettings ? ( +
+ {categoryConfig.map((category) => ( +
+ +
+ +
+ +
+ ))} +
+ ) : ( +
+ {categoryConfig.map((category) => ( +
+ {category.icon} +
+ {category.label} + {category.tooltipText && ( + + )} +
+ handleToggle(category.key)} + /> +
+ ))} +
+ )} +
+ @@ -112,6 +189,11 @@ export default function DigestFrequencyPage() { /> } /> + +
); } diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/page.tsx index 23aec6cefd..cfafb6ea62 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/page.tsx @@ -1,139 +1,43 @@ +"use client"; + +import useSWR from "swr"; import { Card } from "@/components/ui/card"; import { CategoriesSetup } from "./CategoriesSetup"; -import prisma from "@/utils/prisma"; -import { - ActionType, - ColdEmailSetting, - SystemType, - type Prisma, -} from "@prisma/client"; -import type { - CategoryAction, - CreateRulesOnboardingBody, -} from "@/utils/actions/rule.validation"; - -type CategoryConfig = { - action: CategoryAction | undefined; - hasDigest: boolean | undefined; -}; - -export default async function OnboardingPage({ - params, -}: { - params: Promise<{ emailAccountId: string }>; -}) { - const { emailAccountId } = await params; - const defaultValues = await getUserPreferences({ emailAccountId }); +import type { GetOnboardingPreferencesResponse } from "@/app/api/user/onboarding-preferences/route"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAccount } from "@/providers/EmailAccountProvider"; + +export default function OnboardingPage() { + const { emailAccountId } = useAccount(); + + const { data: defaultValues, isLoading } = + useSWR( + "/api/user/onboarding-preferences", + ); + + if (isLoading) { + return ( + +
+ + +
+ {[...Array(7)].map((_, i) => ( + + ))} +
+ +
+
+ ); + } return ( ); } - -type UserPreferences = Prisma.EmailAccountGetPayload<{ - select: { - rules: { - select: { - systemType: true; - actions: { - select: { type: true }; - }; - }; - }; - coldEmailBlocker: true; - }; -}>; - -async function getUserPreferences({ - emailAccountId, -}: { - emailAccountId: string; -}): Promise | undefined> { - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - rules: { - select: { - systemType: true, - actions: { - select: { - type: true, - }, - }, - }, - }, - coldEmailBlocker: true, - coldEmailDigest: true, - }, - }); - if (!emailAccount) return undefined; - - return { - toReply: getToReplySetting(SystemType.TO_REPLY, emailAccount.rules), - coldEmail: getColdEmailSetting( - emailAccount.coldEmailBlocker, - emailAccount.coldEmailDigest, - ), - newsletter: getRuleSetting(SystemType.NEWSLETTER, emailAccount.rules), - marketing: getRuleSetting(SystemType.MARKETING, emailAccount.rules), - calendar: getRuleSetting(SystemType.CALENDAR, emailAccount.rules), - receipt: getRuleSetting(SystemType.RECEIPT, emailAccount.rules), - notification: getRuleSetting(SystemType.NOTIFICATION, emailAccount.rules), - }; -} - -function getToReplySetting( - systemType: SystemType, - rules: UserPreferences["rules"], -): CategoryConfig | undefined { - if (!rules.length) return undefined; - const rule = rules.find((rule) => - rule.actions.some((action) => action.type === ActionType.TRACK_THREAD), - ); - const replyRules = rules.find((rule) => rule.systemType === systemType); - const hasDigest = replyRules?.actions.some( - (action) => action.type === ActionType.DIGEST, - ); - - if (rule) return { action: "label", hasDigest }; - return { action: "none", hasDigest }; -} - -function getRuleSetting( - systemType: SystemType, - rules?: UserPreferences["rules"], -): CategoryConfig | undefined { - const rule = rules?.find((rule) => rule.systemType === systemType); - const hasDigest = rule?.actions.some( - (action) => action.type === ActionType.DIGEST, - ); - if (!rule) return undefined; - - if (rule.actions.some((action) => action.type === ActionType.ARCHIVE)) - return { action: "label_archive", hasDigest }; - if (rule.actions.some((action) => action.type === ActionType.LABEL)) - return { action: "label", hasDigest }; - return { action: "none", hasDigest }; -} - -function getColdEmailSetting( - setting?: ColdEmailSetting | null, - hasDigest?: boolean, -): CategoryConfig | undefined { - if (!setting) return undefined; - - switch (setting) { - case ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL: - case ColdEmailSetting.ARCHIVE_AND_LABEL: - return { action: "label_archive", hasDigest }; - case ColdEmailSetting.LABEL: - return { action: "label", hasDigest }; - default: - return { action: "none", hasDigest }; - } -} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/page.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/page.tsx index c6dead6851..83c1fbdd13 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/page.tsx @@ -7,6 +7,7 @@ import { GmailProvider } from "@/providers/GmailProvider"; import { ASSISTANT_ONBOARDING_COOKIE } from "@/utils/cookies"; import { prefixPath } from "@/utils/path"; import { Chat } from "@/components/assistant-chat/chat"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; export const maxDuration = 300; // Applies to the actions @@ -16,6 +17,7 @@ export default async function AssistantPage({ params: Promise<{ emailAccountId: string }>; }) { const { emailAccountId } = await params; + await checkUserOwnsEmailAccount({ emailAccountId }); // onboarding redirect const cookieStore = await cookies(); diff --git a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx index 9c3c17759f..a4ffc4056b 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx @@ -19,6 +19,7 @@ import { ASSISTANT_ONBOARDING_COOKIE } from "@/utils/cookies"; import { prefixPath } from "@/utils/path"; import { Button } from "@/components/ui/button"; import { PremiumAlertWithData } from "@/components/PremiumAlert"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; export const maxDuration = 300; // Applies to the actions @@ -28,6 +29,7 @@ export default async function AutomationPage({ params: Promise<{ emailAccountId: string }>; }) { const { emailAccountId } = await params; + await checkUserOwnsEmailAccount({ emailAccountId }); // onboarding redirect const cookieStore = await cookies(); diff --git a/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx index ebb214c428..da298fad98 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx @@ -8,6 +8,7 @@ import { getUnhandledCount } from "@/utils/assess"; import { CleanStep } from "@/app/(app)/[emailAccountId]/clean/types"; import { CleanAction } from "@prisma/client"; import { getGmailClientForEmailId } from "@/utils/account"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; export default async function CleanPage(props: { params: Promise<{ emailAccountId: string }>; @@ -23,8 +24,8 @@ export default async function CleanPage(props: { skipAttachment?: string; }>; }) { - const params = await props.params; - const emailAccountId = params.emailAccountId; + const { emailAccountId } = await props.params; + await checkUserOwnsEmailAccount({ emailAccountId }); const gmail = await getGmailClientForEmailId({ emailAccountId }); const { unhandledCount } = await getUnhandledCount(gmail); diff --git a/apps/web/app/(app)/[emailAccountId]/clean/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/page.tsx index ff22e5ae8b..7a1776a641 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/page.tsx @@ -3,6 +3,7 @@ import { getLastJob } from "@/app/(app)/[emailAccountId]/clean/helpers"; import { ConfirmationStep } from "@/app/(app)/[emailAccountId]/clean/ConfirmationStep"; import { Card } from "@/components/ui/card"; import { prefixPath } from "@/utils/path"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; export default async function CleanPage({ params, @@ -10,6 +11,7 @@ export default async function CleanPage({ params: Promise<{ emailAccountId: string }>; }) { const { emailAccountId } = await params; + await checkUserOwnsEmailAccount({ emailAccountId }); const lastJob = await getLastJob({ emailAccountId }); if (!lastJob) redirect(prefixPath(emailAccountId, "/clean/onboarding")); diff --git a/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx index b7629e7a9a..385fedf44c 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx @@ -6,13 +6,14 @@ import { getLastJob, } from "@/app/(app)/[emailAccountId]/clean/helpers"; import { CleanRun } from "@/app/(app)/[emailAccountId]/clean/CleanRun"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; export default async function CleanRunPage(props: { params: Promise<{ emailAccountId: string }>; searchParams: Promise<{ jobId: string; isPreviewBatch: string }>; }) { - const params = await props.params; - const emailAccountId = params.emailAccountId; + const { emailAccountId } = await props.params; + await checkUserOwnsEmailAccount({ emailAccountId }); const searchParams = await props.searchParams; diff --git a/apps/web/app/(app)/[emailAccountId]/reply-zero/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/reply-zero/onboarding/page.tsx index 17240aa288..64bea5ce38 100644 --- a/apps/web/app/(app)/[emailAccountId]/reply-zero/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/reply-zero/onboarding/page.tsx @@ -1,15 +1,17 @@ import { EnableReplyTracker } from "@/app/(app)/[emailAccountId]/reply-zero/EnableReplyTracker"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; import prisma from "@/utils/prisma"; import { ActionType } from "@prisma/client"; export default async function OnboardingReplyTracker(props: { params: Promise<{ emailAccountId: string }>; }) { - const params = await props.params; + const { emailAccountId } = await props.params; + await checkUserOwnsEmailAccount({ emailAccountId }); const trackerRule = await prisma.rule.findFirst({ where: { - emailAccount: { id: params.emailAccountId }, + emailAccountId, actions: { some: { type: ActionType.TRACK_THREAD } }, }, select: { id: true }, diff --git a/apps/web/app/(app)/[emailAccountId]/reply-zero/page.tsx b/apps/web/app/(app)/[emailAccountId]/reply-zero/page.tsx index b84f8876e9..82f2f16ad1 100644 --- a/apps/web/app/(app)/[emailAccountId]/reply-zero/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/reply-zero/page.tsx @@ -14,6 +14,7 @@ import { cookies } from "next/headers"; import { REPLY_ZERO_ONBOARDING_COOKIE } from "@/utils/cookies"; import { ActionType } from "@prisma/client"; import { prefixPath } from "@/utils/path"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; export const maxDuration = 300; @@ -26,6 +27,8 @@ export default async function ReplyTrackerPage(props: { }>; }) { const { emailAccountId } = await props.params; + await checkUserOwnsEmailAccount({ emailAccountId }); + const searchParams = await props.searchParams; const cookieStore = await cookies(); diff --git a/apps/web/app/(app)/[emailAccountId]/setup/page.tsx b/apps/web/app/(app)/[emailAccountId]/setup/page.tsx index be4cbbe853..70be1f3059 100644 --- a/apps/web/app/(app)/[emailAccountId]/setup/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/setup/page.tsx @@ -14,11 +14,13 @@ import { LoadStats } from "@/providers/StatLoaderProvider"; import { Card } from "@/components/ui/card"; import { REPLY_ZERO_ONBOARDING_COOKIE } from "@/utils/cookies"; import { prefixPath } from "@/utils/path"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; export default async function SetupPage(props: { params: Promise<{ emailAccountId: string }>; }) { const { emailAccountId } = await props.params; + await checkUserOwnsEmailAccount({ emailAccountId }); const emailAccount = await prisma.emailAccount.findUnique({ where: { id: emailAccountId }, diff --git a/apps/web/app/(app)/[emailAccountId]/simple/completed/page.tsx b/apps/web/app/(app)/[emailAccountId]/simple/completed/page.tsx index 14c6c7247e..d268a2c7e9 100644 --- a/apps/web/app/(app)/[emailAccountId]/simple/completed/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/completed/page.tsx @@ -10,20 +10,22 @@ import { SimpleProgressCompleted } from "@/app/(app)/[emailAccountId]/simple/Sim import { ShareOnTwitterButton } from "@/app/(app)/[emailAccountId]/simple/completed/ShareOnTwitterButton"; import { getGmailAndAccessTokenForEmail } from "@/utils/account"; import prisma from "@/utils/prisma"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; export default async function SimpleCompletedPage(props: { params: Promise<{ emailAccountId: string }>; }) { - const params = await props.params; + const { emailAccountId } = await props.params; + await checkUserOwnsEmailAccount({ emailAccountId }); const { gmail, accessToken } = await getGmailAndAccessTokenForEmail({ - emailAccountId: params.emailAccountId, + emailAccountId, }); if (!accessToken) throw new Error("Account not found"); const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: params.emailAccountId }, + where: { id: emailAccountId }, select: { email: true }, }); @@ -35,7 +37,7 @@ export default async function SimpleCompletedPage(props: { query: { q: "newer_than:1d in:inbox" }, gmail, accessToken, - emailAccountId: params.emailAccountId, + emailAccountId, }); return ( diff --git a/apps/web/app/(app)/[emailAccountId]/simple/page.tsx b/apps/web/app/(app)/[emailAccountId]/simple/page.tsx index b169a9648e..0edb493f75 100644 --- a/apps/web/app/(app)/[emailAccountId]/simple/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/page.tsx @@ -12,6 +12,7 @@ import { ClientOnly } from "@/components/ClientOnly"; import { getMessage, getMessages } from "@/utils/gmail/message"; import { getGmailClientForEmailId } from "@/utils/account"; import { prefixPath } from "@/utils/path"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; export const dynamic = "force-dynamic"; @@ -19,8 +20,8 @@ export default async function SimplePage(props: { params: Promise<{ emailAccountId: string }>; searchParams: Promise<{ pageToken?: string; type?: string }>; }) { - const params = await props.params; - const emailAccountId = params.emailAccountId; + const { emailAccountId } = await props.params; + await checkUserOwnsEmailAccount({ emailAccountId }); const searchParams = await props.searchParams; diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx index bded4ff40c..7ab4b396df 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx @@ -26,6 +26,7 @@ import { Button } from "@/components/ui/button"; import { CategorizeSendersProgress } from "@/app/(app)/[emailAccountId]/smart-categories/CategorizeProgress"; import { getCategorizationProgress } from "@/utils/redis/categorization-progress"; import { prefixPath } from "@/utils/path"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; export const dynamic = "force-dynamic"; export const maxDuration = 300; @@ -36,6 +37,7 @@ export default async function CategoriesPage({ params: Promise<{ emailAccountId: string }>; }) { const { emailAccountId } = await params; + await checkUserOwnsEmailAccount({ emailAccountId }); const [senders, categories, emailAccount, progress] = await Promise.all([ prisma.newsletter.findMany({ diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/page.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/page.tsx index 1405b80394..8b16510432 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/page.tsx @@ -2,12 +2,13 @@ import { SetUpCategories } from "@/app/(app)/[emailAccountId]/smart-categories/s import { SmartCategoriesOnboarding } from "@/app/(app)/[emailAccountId]/smart-categories/setup/SmartCategoriesOnboarding"; import { ClientOnly } from "@/components/ClientOnly"; import { getUserCategories } from "@/utils/category.server"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; export default async function SetupCategoriesPage(props: { params: Promise<{ emailAccountId: string }>; }) { - const params = await props.params; - const emailAccountId = params.emailAccountId; + const { emailAccountId } = await props.params; + await checkUserOwnsEmailAccount({ emailAccountId }); const categories = await getUserCategories({ emailAccountId }); diff --git a/apps/web/app/(app)/[emailAccountId]/usage/page.tsx b/apps/web/app/(app)/[emailAccountId]/usage/page.tsx index 3a5549404f..d685a7df1f 100644 --- a/apps/web/app/(app)/[emailAccountId]/usage/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/usage/page.tsx @@ -2,12 +2,13 @@ import { getUsage } from "@/utils/redis/usage"; import { TopSection } from "@/components/TopSection"; import { Usage } from "@/app/(app)/[emailAccountId]/usage/usage"; import prisma from "@/utils/prisma"; +import { checkUserOwnsEmailAccount } from "@/utils/email-account"; export default async function UsagePage(props: { params: Promise<{ emailAccountId: string }>; }) { - const params = await props.params; - const emailAccountId = params.emailAccountId; + const { emailAccountId } = await props.params; + await checkUserOwnsEmailAccount({ emailAccountId }); const emailAccount = await prisma.emailAccount.findUnique({ where: { id: emailAccountId }, diff --git a/apps/web/app/api/user/digest-settings/route.ts b/apps/web/app/api/user/digest-settings/route.ts new file mode 100644 index 0000000000..1848c51b85 --- /dev/null +++ b/apps/web/app/api/user/digest-settings/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server"; +import { withEmailAccount } from "@/utils/middleware"; +import prisma from "@/utils/prisma"; +import { ActionType, SystemType } from "@prisma/client"; + +export type GetDigestSettingsResponse = Awaited< + ReturnType +>; + +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + + const result = await getDigestSettings({ emailAccountId }); + return NextResponse.json(result); +}); + +async function getDigestSettings({ + emailAccountId, +}: { + emailAccountId: string; +}) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + coldEmailDigest: true, + rules: { + where: { + systemType: { + in: [ + SystemType.TO_REPLY, + SystemType.NEWSLETTER, + SystemType.MARKETING, + SystemType.CALENDAR, + SystemType.RECEIPT, + SystemType.NOTIFICATION, + ], + }, + }, + select: { + systemType: true, + actions: { + where: { + type: ActionType.DIGEST, + }, + }, + }, + }, + }, + }); + + if (!emailAccount) { + return { + toReply: false, + newsletter: false, + marketing: false, + calendar: false, + receipt: false, + notification: false, + coldEmail: false, + }; + } + + // Build digest settings object + const digestSettings = { + toReply: false, + newsletter: false, + marketing: false, + calendar: false, + receipt: false, + notification: false, + coldEmail: emailAccount.coldEmailDigest || false, + }; + + // Map system types to digest settings + const systemTypeToKey: Record = { + [SystemType.TO_REPLY]: "toReply", + [SystemType.NEWSLETTER]: "newsletter", + [SystemType.MARKETING]: "marketing", + [SystemType.CALENDAR]: "calendar", + [SystemType.RECEIPT]: "receipt", + [SystemType.NOTIFICATION]: "notification", + }; + + emailAccount.rules.forEach((rule) => { + if (rule.systemType && rule.systemType in systemTypeToKey) { + const key = systemTypeToKey[rule.systemType]; + digestSettings[key] = rule.actions.length > 0; + } + }); + + return digestSettings; +} diff --git a/apps/web/app/api/user/onboarding-preferences/route.ts b/apps/web/app/api/user/onboarding-preferences/route.ts new file mode 100644 index 0000000000..8e9a73ff9f --- /dev/null +++ b/apps/web/app/api/user/onboarding-preferences/route.ts @@ -0,0 +1,133 @@ +import { NextResponse } from "next/server"; +import { withEmailAccount } from "@/utils/middleware"; +import prisma from "@/utils/prisma"; +import { + ActionType, + ColdEmailSetting, + SystemType, + type Prisma, +} from "@prisma/client"; +import type { + CategoryAction, + CreateRulesOnboardingBody, +} from "@/utils/actions/rule.validation"; + +type CategoryConfig = { + action: CategoryAction | undefined; + hasDigest: boolean | undefined; +}; + +export type GetOnboardingPreferencesResponse = Awaited< + ReturnType +>; + +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + + const result = await getUserPreferences({ emailAccountId }); + return NextResponse.json(result); +}); + +type UserPreferences = Prisma.EmailAccountGetPayload<{ + select: { + rules: { + select: { + systemType: true; + actions: { + select: { type: true }; + }; + }; + }; + coldEmailBlocker: true; + coldEmailDigest: true; + }; +}>; + +async function getUserPreferences({ + emailAccountId, +}: { + emailAccountId: string; +}): Promise | null> { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + rules: { + select: { + systemType: true, + actions: { + select: { + type: true, + }, + }, + }, + }, + coldEmailBlocker: true, + coldEmailDigest: true, + }, + }); + if (!emailAccount) return null; + + return { + toReply: getToReplySetting(SystemType.TO_REPLY, emailAccount.rules), + coldEmail: getColdEmailSetting( + emailAccount.coldEmailBlocker, + emailAccount.coldEmailDigest, + ), + newsletter: getRuleSetting(SystemType.NEWSLETTER, emailAccount.rules), + marketing: getRuleSetting(SystemType.MARKETING, emailAccount.rules), + calendar: getRuleSetting(SystemType.CALENDAR, emailAccount.rules), + receipt: getRuleSetting(SystemType.RECEIPT, emailAccount.rules), + notification: getRuleSetting(SystemType.NOTIFICATION, emailAccount.rules), + }; +} + +function getToReplySetting( + systemType: SystemType, + rules: UserPreferences["rules"], +): CategoryConfig | undefined { + if (!rules.length) return undefined; + const rule = rules.find((rule) => + rule.actions.some((action) => action.type === ActionType.TRACK_THREAD), + ); + const replyRules = rules.find((rule) => rule.systemType === systemType); + const hasDigest = replyRules?.actions.some( + (action) => action.type === ActionType.DIGEST, + ); + + if (rule) return { action: "label", hasDigest }; + return { action: "none", hasDigest }; +} + +function getRuleSetting( + systemType: SystemType, + rules?: UserPreferences["rules"], +): CategoryConfig | undefined { + const rule = rules?.find((rule) => rule.systemType === systemType); + const hasDigest = rule?.actions.some( + (action) => action.type === ActionType.DIGEST, + ); + if (!rule) return undefined; + + if (rule.actions.some((action) => action.type === ActionType.ARCHIVE)) + return { action: "label_archive", hasDigest }; + if (rule.actions.some((action) => action.type === ActionType.LABEL)) + return { action: "label", hasDigest }; + return { action: "none", hasDigest }; +} + +function getColdEmailSetting( + setting?: ColdEmailSetting | null, + hasDigest?: boolean, +): CategoryConfig | undefined { + if (!setting) return undefined; + + switch (setting) { + case ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL: + case ColdEmailSetting.ARCHIVE_AND_LABEL: + return { action: "label_archive", hasDigest }; + case ColdEmailSetting.LABEL: + return { action: "label", hasDigest }; + default: + return { action: "none", hasDigest }; + } +} diff --git a/apps/web/utils/actions/settings.ts b/apps/web/utils/actions/settings.ts index 6bf13a0741..5fd71394b2 100644 --- a/apps/web/utils/actions/settings.ts +++ b/apps/web/utils/actions/settings.ts @@ -5,12 +5,14 @@ import { saveAiSettingsBody, saveEmailUpdateSettingsBody, saveDigestScheduleBody, + updateDigestCategoriesBody, } from "@/utils/actions/settings.validation"; import { DEFAULT_PROVIDER } from "@/utils/llms/config"; import prisma from "@/utils/prisma"; import { calculateNextScheduleDate } from "@/utils/schedule"; import { actionClientUser } from "@/utils/actions/safe-action"; import { SafeError } from "@/utils/error"; +import { SystemType, ActionType } from "@prisma/client"; export const updateEmailSettingsAction = actionClient .metadata({ name: "updateEmailSettings" }) @@ -99,3 +101,97 @@ export const updateDigestScheduleAction = actionClient throw new SafeError("Failed to update settings", 500); } }); + +export const updateDigestCategoriesAction = actionClient + .metadata({ name: "updateDigestCategories" }) + .schema(updateDigestCategoriesBody) + .action( + async ({ + ctx: { emailAccountId }, + parsedInput: { + toReply, + newsletter, + marketing, + calendar, + receipt, + notification, + coldEmail, + }, + }) => { + const promises: Promise[] = []; + + // Update cold email digest setting + if (coldEmail !== undefined) { + promises.push( + prisma.emailAccount.update({ + where: { id: emailAccountId }, + data: { coldEmailDigest: coldEmail }, + }), + ); + } + + // Update rule digest settings + const systemTypeMap = { + toReply: SystemType.TO_REPLY, + newsletter: SystemType.NEWSLETTER, + marketing: SystemType.MARKETING, + calendar: SystemType.CALENDAR, + receipt: SystemType.RECEIPT, + notification: SystemType.NOTIFICATION, + }; + + for (const [key, systemType] of Object.entries(systemTypeMap)) { + const value = { + toReply, + newsletter, + marketing, + calendar, + receipt, + notification, + }[key as keyof typeof systemTypeMap]; + + if (value !== undefined) { + const promise = async () => { + const rule = await prisma.rule.findUnique({ + where: { + emailAccountId_systemType: { + emailAccountId, + systemType, + }, + }, + select: { id: true, actions: true }, + }); + + if (!rule) return; + + const hasDigestAction = rule.actions.some( + (action) => action.type === ActionType.DIGEST, + ); + + if (value && !hasDigestAction) { + // Add DIGEST action + await prisma.action.create({ + data: { + ruleId: rule.id, + type: ActionType.DIGEST, + }, + }); + } else if (!value && hasDigestAction) { + // Remove DIGEST action + await prisma.action.deleteMany({ + where: { + ruleId: rule.id, + type: ActionType.DIGEST, + }, + }); + } + }; + + promises.push(promise()); + } + } + + await Promise.all(promises); + return { success: true }; + }, + ); diff --git a/apps/web/utils/actions/settings.validation.ts b/apps/web/utils/actions/settings.validation.ts index 2c8cbf8dd5..b2d8cf122c 100644 --- a/apps/web/utils/actions/settings.validation.ts +++ b/apps/web/utils/actions/settings.validation.ts @@ -9,9 +9,7 @@ const scheduleSchema = z.object({ occurrences: z.number().nullable(), }); -export const saveDigestScheduleBody = z.object({ - schedule: scheduleSchema.nullable(), -}); +export const saveDigestScheduleBody = z.object({ schedule: scheduleSchema }); export type SaveDigestScheduleBody = z.infer; export const saveEmailUpdateSettingsBody = z.object({ @@ -52,3 +50,16 @@ export const saveAiSettingsBody = z } }); export type SaveAiSettingsBody = z.infer; + +export const updateDigestCategoriesBody = z.object({ + toReply: z.boolean().optional(), + newsletter: z.boolean().optional(), + marketing: z.boolean().optional(), + calendar: z.boolean().optional(), + receipt: z.boolean().optional(), + notification: z.boolean().optional(), + coldEmail: z.boolean().optional(), +}); +export type UpdateDigestCategoriesBody = z.infer< + typeof updateDigestCategoriesBody +>; diff --git a/apps/web/utils/category-config.tsx b/apps/web/utils/category-config.tsx new file mode 100644 index 0000000000..609b0d4902 --- /dev/null +++ b/apps/web/utils/category-config.tsx @@ -0,0 +1,58 @@ +import { + Mail, + Newspaper, + Megaphone, + Calendar, + Receipt, + Bell, + Users, +} from "lucide-react"; + +export const categoryConfig = [ + { + key: "toReply" as const, + label: "To Reply", + tooltipText: + "Emails you need to reply to and those where you're awaiting a reply. The label will update automatically as the conversation progresses", + icon: , + }, + { + key: "newsletter" as const, + label: "Newsletter", + tooltipText: "Newsletters, blogs, and publications", + icon: , + }, + { + key: "marketing" as const, + label: "Marketing", + tooltipText: "Promotional emails about sales and offers", + icon: , + }, + { + key: "calendar" as const, + label: "Calendar", + tooltipText: "Events, appointments, and reminders", + icon: , + }, + { + key: "receipt" as const, + label: "Receipt", + tooltipText: "Invoices, receipts, and payments", + icon: , + }, + { + key: "notification" as const, + label: "Notification", + tooltipText: "Alerts, status updates, and system messages", + icon: , + }, + { + key: "coldEmail" as const, + label: "Cold Email", + tooltipText: + "Unsolicited sales pitches and cold emails. We'll never block someone that's emailed you before", + icon: , + }, +]; + +export type CategoryKey = (typeof categoryConfig)[number]["key"]; diff --git a/apps/web/utils/email-account.ts b/apps/web/utils/email-account.ts new file mode 100644 index 0000000000..75d0c6ab03 --- /dev/null +++ b/apps/web/utils/email-account.ts @@ -0,0 +1,19 @@ +import { notFound } from "next/navigation"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import prisma from "@/utils/prisma"; + +export async function checkUserOwnsEmailAccount({ + emailAccountId, +}: { + emailAccountId: string; +}) { + const session = await auth(); + const userId = session?.user.id; + if (!userId) notFound(); + + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId, userId }, + select: { id: true }, + }); + if (!emailAccount) notFound(); +} diff --git a/version.txt b/version.txt index 5f152d81a5..a9bbd79f98 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v1.7.3 +v1.7.4