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.
+
+
+
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ Save
+
+
+
+
+ );
+}
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:
+
+ setShowFrequencyDialog(true)}
+ >
+ Set frequency
+
+
+
+ {isLoadingSettings ? (
+
+ {categoryConfig.map((category) => (
+
+ ))}
+
+ ) : (
+
+ {categoryConfig.map((category) => (
+
+ {category.icon}
+
+ {category.label}
+ {category.tooltipText && (
+
+ )}
+
+
handleToggle(category.key)}
+ />
+
+ ))}
+
+ )}
+
+
Next
@@ -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