From 286ffbd42e51ad4b1522f4c30838a848e7961948 Mon Sep 17 00:00:00 2001 From: amrit Date: Tue, 17 Jun 2025 20:15:45 +0530 Subject: [PATCH 01/15] update locale --- apps/mail/locales/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mail/locales/en.json b/apps/mail/locales/en.json index 4a4e552a3f..08972903e4 100644 --- a/apps/mail/locales/en.json +++ b/apps/mail/locales/en.json @@ -355,6 +355,7 @@ "signatures": "Signatures", "shortcuts": "Shortcuts", "labels": "Labels", + "categories": "Categories", "dangerZone": "Danger Zone", "deleteAccount": "Delete Account", "privacy": "Privacy" From 18eeedc4f441b61621ed0e05357ff8ee7e3c7bde Mon Sep 17 00:00:00 2001 From: amrit Date: Tue, 17 Jun 2025 20:15:59 +0530 Subject: [PATCH 02/15] introduce a new route --- apps/mail/app/routes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mail/app/routes.ts b/apps/mail/app/routes.ts index dea514cd7b..ba6841e338 100644 --- a/apps/mail/app/routes.ts +++ b/apps/mail/app/routes.ts @@ -53,6 +53,7 @@ export default [ route('/danger-zone', '(routes)/settings/danger-zone/page.tsx'), route('/general', '(routes)/settings/general/page.tsx'), route('/labels', '(routes)/settings/labels/page.tsx'), + route('/categories', '(routes)/settings/categories/page.tsx'), route('/notifications', '(routes)/settings/notifications/page.tsx'), route('/privacy', '(routes)/settings/privacy/page.tsx'), route('/security', '(routes)/settings/security/page.tsx'), From e4fd349560410d0a4ae43acd865b04dd8f9c6969 Mon Sep 17 00:00:00 2001 From: amrit Date: Tue, 17 Jun 2025 20:16:30 +0530 Subject: [PATCH 03/15] Add categories settings and navigation in mail app --- apps/mail/config/navigation.ts | 5 ++ apps/mail/hooks/use-categories.ts | 90 +++++++++++++++++++++++++++++++ apps/server/src/lib/schemas.ts | 79 +++++++++++++++++++++++---- 3 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 apps/mail/hooks/use-categories.ts diff --git a/apps/mail/config/navigation.ts b/apps/mail/config/navigation.ts index c37431a2b6..2a105a2e58 100644 --- a/apps/mail/config/navigation.ts +++ b/apps/mail/config/navigation.ts @@ -172,6 +172,11 @@ export const navigationConfig: Record = { url: '/settings/labels', icon: Sheet, }, + { + title: 'navigation.settings.categories', + url: '/settings/categories', + icon: Tabs, + }, { title: 'navigation.settings.signatures', url: '/settings/signatures', diff --git a/apps/mail/hooks/use-categories.ts b/apps/mail/hooks/use-categories.ts new file mode 100644 index 0000000000..2e9228106a --- /dev/null +++ b/apps/mail/hooks/use-categories.ts @@ -0,0 +1,90 @@ +import { useSettings } from '@/hooks/use-settings'; +import { useMemo } from 'react'; + +export interface CategorySetting { + id: 'Important' | 'All Mail' | 'Personal' | 'Promotions' | 'Updates' | 'Unread'; + name: string; + searchValue: string; + order: number; + isDefault?: boolean; +} + +/** + * Returns the user customised category settings if present, falling back to sensible defaults. + */ +export function useCategorySettings(): CategorySetting[] { + const { data } = useSettings(); + + // Fallback defaults – must stay in sync with server defaults + const defaultCategories: CategorySetting[] = [ + { + id: 'Important', + name: 'Important', + searchValue: 'is:important NOT is:sent NOT is:draft', + order: 0, + isDefault: false, + }, + { + id: 'All Mail', + name: 'All Mail', + searchValue: 'NOT is:draft (is:inbox OR (is:sent AND to:me))', + order: 1, + isDefault: true, + }, + { + id: 'Personal', + name: 'Personal', + searchValue: 'is:personal NOT is:sent NOT is:draft', + order: 2, + isDefault: false, + }, + { + id: 'Promotions', + name: 'Promotions', + searchValue: 'is:promotions NOT is:sent NOT is:draft', + order: 3, + isDefault: false, + }, + { + id: 'Updates', + name: 'Updates', + searchValue: 'is:updates NOT is:sent NOT is:draft', + order: 4, + isDefault: false, + }, + { + id: 'Unread', + name: 'Unread', + searchValue: 'is:unread NOT is:sent NOT is:draft', + order: 5, + isDefault: false, + }, + ]; + + const merged = useMemo(() => { + const overrides = (data?.settings.categories as CategorySetting[] | undefined) ?? []; + + const overridden = defaultCategories.map((cat) => { + const custom = overrides.find((c) => c.id === cat.id); + return custom + ? { + ...cat, + ...custom, + } + : cat; + }); + + // Ensure every override id present – ignore unknown ids for safety + const sorted = overridden.sort((a, b) => a.order - b.order); + return sorted; + }, [data?.settings.categories]); + + return merged; +} + +// Added util to easily get default category id +export function useDefaultCategoryId(): string { + const categories = useCategorySettings(); + const defaultCat = categories.find((c) => c.isDefault) ?? categories[0]; + return defaultCat?.id ?? 'All Mail'; +} \ No newline at end of file diff --git a/apps/server/src/lib/schemas.ts b/apps/server/src/lib/schemas.ts index f804bcf93f..c6b8f6b6b3 100644 --- a/apps/server/src/lib/schemas.ts +++ b/apps/server/src/lib/schemas.ts @@ -34,17 +34,60 @@ export const createDraftData = z.object({ export type CreateDraftData = z.infer; -export const defaultUserSettings = { - language: 'en', - timezone: 'UTC', - dynamicContent: false, - externalImages: true, - customPrompt: '', - trustedSenders: [], - isOnboarded: false, - colorTheme: 'system', - zeroSignature: true, -} satisfies UserSettings; +export const mailCategorySchema = z.object({ + id: z.enum(['Important', 'All Mail', 'Personal', 'Promotions', 'Updates', 'Unread']), + name: z.string(), + searchValue: z.string(), + order: z.number().int(), + isDefault: z.boolean().optional().default(false), +}); + +export type MailCategory = z.infer; + +export const defaultMailCategories: MailCategory[] = [ + { + id: 'Important', + name: 'Important', + searchValue: 'is:important NOT is:sent NOT is:draft', + order: 0, + isDefault: false, + }, + { + id: 'All Mail', + name: 'All Mail', + searchValue: 'NOT is:draft (is:inbox OR (is:sent AND to:me))', + order: 1, + isDefault: true, + }, + { + id: 'Personal', + name: 'Personal', + searchValue: 'is:personal NOT is:sent NOT is:draft', + order: 2, + isDefault: false, + }, + { + id: 'Promotions', + name: 'Promotions', + searchValue: 'is:promotions NOT is:sent NOT is:draft', + order: 3, + isDefault: false, + }, + { + id: 'Updates', + name: 'Updates', + searchValue: 'is:updates NOT is:sent NOT is:draft', + order: 4, + isDefault: false, + }, + { + id: 'Unread', + name: 'Unread', + searchValue: 'is:unread NOT is:sent NOT is:draft', + order: 5, + isDefault: false, + }, +]; export const userSettingsSchema = z.object({ language: z.string(), @@ -56,6 +99,20 @@ export const userSettingsSchema = z.object({ trustedSenders: z.string().array().optional(), colorTheme: z.enum(['light', 'dark', 'system']).default('system'), zeroSignature: z.boolean().default(true), + categories: z.array(mailCategorySchema).optional(), }); export type UserSettings = z.infer; + +export const defaultUserSettings: UserSettings = { + language: 'en', + timezone: 'UTC', + dynamicContent: false, + externalImages: true, + customPrompt: '', + trustedSenders: [], + isOnboarded: false, + colorTheme: 'system', + zeroSignature: true, + categories: defaultMailCategories, +}; From d5054cb671af0642461fcd7bad36c2dbf947d226 Mon Sep 17 00:00:00 2001 From: amrit Date: Tue, 17 Jun 2025 20:17:05 +0530 Subject: [PATCH 04/15] add settings page for categories --- .../app/(routes)/settings/categories/page.tsx | 171 ++++++++++++++++++ apps/mail/components/mail/mail.tsx | 149 +++++++-------- 2 files changed, 232 insertions(+), 88 deletions(-) create mode 100644 apps/mail/app/(routes)/settings/categories/page.tsx diff --git a/apps/mail/app/(routes)/settings/categories/page.tsx b/apps/mail/app/(routes)/settings/categories/page.tsx new file mode 100644 index 0000000000..b5130dca17 --- /dev/null +++ b/apps/mail/app/(routes)/settings/categories/page.tsx @@ -0,0 +1,171 @@ +import { useSettings } from '@/hooks/use-settings'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { SettingsCard } from '@/components/settings/settings-card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { useState, useEffect } from 'react'; +import { useTRPC } from '@/providers/query-provider'; +import { toast } from 'sonner'; +import type { CategorySetting } from '@/hooks/use-categories'; + +export default function CategoriesSettingsPage() { + const { data } = useSettings(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { mutateAsync: saveUserSettings, isPending } = useMutation( + trpc.settings.save.mutationOptions(), + ); + + const [categories, setCategories] = useState([]); + + const defaultCategories: CategorySetting[] = [ + { + id: 'Important', + name: 'Important', + searchValue: 'is:important NOT is:sent NOT is:draft', + order: 0, + isDefault: false, + }, + { + id: 'All Mail', + name: 'All Mail', + searchValue: 'NOT is:draft (is:inbox OR (is:sent AND to:me))', + order: 1, + isDefault: true, + }, + { + id: 'Personal', + name: 'Personal', + searchValue: 'is:personal NOT is:sent NOT is:draft', + order: 2, + isDefault: false, + }, + { + id: 'Promotions', + name: 'Promotions', + searchValue: 'is:promotions NOT is:sent NOT is:draft', + order: 3, + isDefault: false, + }, + { + id: 'Updates', + name: 'Updates', + searchValue: 'is:updates NOT is:sent NOT is:draft', + order: 4, + isDefault: false, + }, + { + id: 'Unread', + name: 'Unread', + searchValue: 'is:unread NOT is:sent NOT is:draft', + order: 5, + isDefault: false, + }, + ]; + + useEffect(() => { + const stored = data?.settings?.categories ?? []; + + const merged = defaultCategories.map((def) => { + const override = stored.find((c: { id: string; }) => c.id === def.id); + return override ? { ...def, ...override } : def; + }); + + setCategories(merged.sort((a, b) => a.order - b.order)); + }, [data]); + + const handleFieldChange = (id: string, field: keyof CategorySetting, value: any) => { + setCategories((prev) => + prev.map((cat) => (cat.id === id ? { ...cat, [field]: value } : cat)), + ); + }; + + const handleSave = async () => { + if (categories.filter((c) => c.isDefault).length !== 1) { + toast.error('Please mark exactly one category as default'); + return; + } + + try { + await saveUserSettings({ categories }); + queryClient.setQueryData(trpc.settings.get.queryKey(), (updater: any) => { + if (!updater) return; + return { + settings: { ...updater.settings, categories }, + }; + }); + toast.success('Categories saved'); + } catch (e) { + console.error(e); + toast.error('Failed to save'); + } + }; + + if (!categories.length) { + return
Loading...
; + } + + return ( +
+ + {isPending ? 'Saving…' : 'Save Changes'} + + } + > +
+ {categories.map((cat) => ( +
+ +
+
+ + handleFieldChange(cat.id, 'name', e.target.value)} + /> +
+
+ + handleFieldChange(cat.id, 'searchValue', e.target.value)} + /> +
+
+ + handleFieldChange(cat.id, 'order', Number(e.target.value))} + /> +
+
+ { + const newCats = categories.map((c) => ({ + ...c, + isDefault: c.id === cat.id ? val : false, + })); + setCategories(newCats); + }} + /> + Default +
+
+
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 8435b8a912..81b08053c9 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -63,6 +63,7 @@ import { useTranslations } from 'use-intl'; import { useQueryState } from 'nuqs'; import { useAtom } from 'jotai'; import { toast } from 'sonner'; +import { useCategorySettings, useDefaultCategoryId } from '@/hooks/use-categories'; interface ITag { id: string; @@ -185,7 +186,7 @@ const AutoLabelingSettings = () => { }; const handleEnableBrain = useCallback(async () => { - toast.promise(EnableBrain({}), { + toast.promise(EnableBrain(), { loading: 'Enabling autolabeling...', success: 'Autolabeling enabled successfully', error: 'Failed to enable autolabeling', @@ -196,7 +197,7 @@ const AutoLabelingSettings = () => { }, []); const handleDisableBrain = useCallback(async () => { - toast.promise(DisableBrain({}), { + toast.promise(DisableBrain(), { loading: 'Disabling autolabeling...', success: 'Autolabeling disabled successfully', error: 'Failed to disable autolabeling', @@ -458,7 +459,8 @@ export function MailLayout() { } }, []); - const category = useQueryState('category', { defaultValue: 'All Mail' }); + const defaultCategoryId = useDefaultCategoryId(); + const category = useQueryState('category', { defaultValue: defaultCategoryId }); return ( @@ -820,91 +822,61 @@ function BulkSelectActions() { export const Categories = () => { const t = useTranslations(); - const [category] = useQueryState('category', { - defaultValue: 'All Mail', + const categorySettings = useCategorySettings(); + const [activeCategory] = useQueryState('category'); + + // Build category array, merging dynamic settings with icon/translation fallbacks + const categories = categorySettings.map((cat) => { + const base = { + id: cat.id, + name: + cat.name || + t(`common.mailCategories.${cat.id.toLowerCase().replace(' ', '')}` as any), + searchValue: cat.searchValue, + } as const; + + // Helper to decide fill colour depending on selection + const isSelected = activeCategory === cat.id; + + switch (cat.id) { + case 'Important': + return { + ...base, + icon: , + }; + case 'All Mail': + return { + ...base, + icon: , + colors: 'border-0 bg-[#006FFE] text-white dark:bg-[#006FFE] dark:text-white dark:hover:bg-[#006FFE]/90', + }; + case 'Personal': + return { + ...base, + icon: , + }; + case 'Promotions': + return { + ...base, + icon: , + }; + case 'Updates': + return { + ...base, + icon: , + }; + case 'Unread': + return { + ...base, + icon: , + }; + default: + return base as any; + } }); - return [ - { - id: 'Important', - name: t('common.mailCategories.important'), - searchValue: 'is:important NOT is:sent NOT is:draft', - icon: ( - - ), - }, - { - id: 'All Mail', - name: t('common.mailCategories.allMail'), - searchValue: 'NOT is:draft (is:inbox OR (is:sent AND to:me))', - icon: ( - - ), - colors: - 'border-0 bg-[#006FFE] text-white dark:bg-[#006FFE] dark:text-white dark:hover:bg-[#006FFE]/90', - }, - { - id: 'Personal', - name: t('common.mailCategories.personal'), - searchValue: 'is:personal NOT is:sent NOT is:draft', - icon: ( - - ), - }, - { - id: 'Updates', - name: t('common.mailCategories.updates'), - searchValue: 'is:updates NOT is:sent NOT is:draft', - icon: ( - - ), - }, - { - id: 'Promotions', - name: t('common.mailCategories.promotions'), - searchValue: 'is:promotions NOT is:sent NOT is:draft', - icon: ( - - ), - }, - { - id: 'Unread', - name: t('common.mailCategories.unread'), - searchValue: 'is:unread NOT is:sent NOT is:draft', - icon: ( - - ), - }, - ]; + + // Ensure order follows settings + return categories; }; type CategoryType = ReturnType[0]; @@ -936,8 +908,9 @@ function CategorySelect({ isMultiSelectMode }: { isMultiSelectMode: boolean }) { const categories = Categories(); const params = useParams<{ folder: string }>(); const folder = params?.folder ?? 'inbox'; + const defaultCategoryIdInner = useDefaultCategoryId(); const [category, setCategory] = useQueryState('category', { - defaultValue: 'All Mail', + defaultValue: defaultCategoryIdInner, }); const containerRef = useRef(null); const activeTabElementRef = useRef(null); From c971bce6baed895258c5e05d88749305f243b389 Mon Sep 17 00:00:00 2001 From: amrit Date: Tue, 17 Jun 2025 20:21:06 +0530 Subject: [PATCH 05/15] remove slop comments --- apps/mail/components/mail/mail.tsx | 2 -- apps/mail/hooks/use-categories.ts | 8 +------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 81b08053c9..07b2b86a2f 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -825,7 +825,6 @@ export const Categories = () => { const categorySettings = useCategorySettings(); const [activeCategory] = useQueryState('category'); - // Build category array, merging dynamic settings with icon/translation fallbacks const categories = categorySettings.map((cat) => { const base = { id: cat.id, @@ -875,7 +874,6 @@ export const Categories = () => { } }); - // Ensure order follows settings return categories; }; diff --git a/apps/mail/hooks/use-categories.ts b/apps/mail/hooks/use-categories.ts index 2e9228106a..5bd678fd72 100644 --- a/apps/mail/hooks/use-categories.ts +++ b/apps/mail/hooks/use-categories.ts @@ -9,13 +9,9 @@ export interface CategorySetting { isDefault?: boolean; } -/** - * Returns the user customised category settings if present, falling back to sensible defaults. - */ export function useCategorySettings(): CategorySetting[] { const { data } = useSettings(); - // Fallback defaults – must stay in sync with server defaults const defaultCategories: CategorySetting[] = [ { id: 'Important', @@ -74,7 +70,6 @@ export function useCategorySettings(): CategorySetting[] { : cat; }); - // Ensure every override id present – ignore unknown ids for safety const sorted = overridden.sort((a, b) => a.order - b.order); return sorted; }, [data?.settings.categories]); @@ -82,9 +77,8 @@ export function useCategorySettings(): CategorySetting[] { return merged; } -// Added util to easily get default category id export function useDefaultCategoryId(): string { const categories = useCategorySettings(); const defaultCat = categories.find((c) => c.isDefault) ?? categories[0]; return defaultCat?.id ?? 'All Mail'; -} \ No newline at end of file +} \ No newline at end of file From 4a9b9a7d88de2e45d43809f5b10f3e5f00ea6d28 Mon Sep 17 00:00:00 2001 From: amrit Date: Tue, 17 Jun 2025 21:04:10 +0530 Subject: [PATCH 06/15] Removed the in-file defaultCategories array and imported defaultMailCategories from the server schema --- .../app/(routes)/settings/categories/page.tsx | 48 +------------------ 1 file changed, 2 insertions(+), 46 deletions(-) diff --git a/apps/mail/app/(routes)/settings/categories/page.tsx b/apps/mail/app/(routes)/settings/categories/page.tsx index b5130dca17..9aed7dd714 100644 --- a/apps/mail/app/(routes)/settings/categories/page.tsx +++ b/apps/mail/app/(routes)/settings/categories/page.tsx @@ -9,6 +9,7 @@ import { useState, useEffect } from 'react'; import { useTRPC } from '@/providers/query-provider'; import { toast } from 'sonner'; import type { CategorySetting } from '@/hooks/use-categories'; +import { defaultMailCategories } from '../../../../../server/src/lib/schemas'; export default function CategoriesSettingsPage() { const { data } = useSettings(); @@ -20,55 +21,10 @@ export default function CategoriesSettingsPage() { const [categories, setCategories] = useState([]); - const defaultCategories: CategorySetting[] = [ - { - id: 'Important', - name: 'Important', - searchValue: 'is:important NOT is:sent NOT is:draft', - order: 0, - isDefault: false, - }, - { - id: 'All Mail', - name: 'All Mail', - searchValue: 'NOT is:draft (is:inbox OR (is:sent AND to:me))', - order: 1, - isDefault: true, - }, - { - id: 'Personal', - name: 'Personal', - searchValue: 'is:personal NOT is:sent NOT is:draft', - order: 2, - isDefault: false, - }, - { - id: 'Promotions', - name: 'Promotions', - searchValue: 'is:promotions NOT is:sent NOT is:draft', - order: 3, - isDefault: false, - }, - { - id: 'Updates', - name: 'Updates', - searchValue: 'is:updates NOT is:sent NOT is:draft', - order: 4, - isDefault: false, - }, - { - id: 'Unread', - name: 'Unread', - searchValue: 'is:unread NOT is:sent NOT is:draft', - order: 5, - isDefault: false, - }, - ]; - useEffect(() => { const stored = data?.settings?.categories ?? []; - const merged = defaultCategories.map((def) => { + const merged = defaultMailCategories.map((def) => { const override = stored.find((c: { id: string; }) => c.id === def.id); return override ? { ...def, ...override } : def; }); From 987175f00b3d78d773e14b0171c164640ab26503 Mon Sep 17 00:00:00 2001 From: amrit Date: Tue, 17 Jun 2025 21:11:58 +0530 Subject: [PATCH 07/15] If the value is empty or parses to NaN, the previous valid order is kept, so no NaN (or other invalid) numbers can make their way into state --- apps/mail/app/(routes)/settings/categories/page.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/mail/app/(routes)/settings/categories/page.tsx b/apps/mail/app/(routes)/settings/categories/page.tsx index 9aed7dd714..3a823ba0bc 100644 --- a/apps/mail/app/(routes)/settings/categories/page.tsx +++ b/apps/mail/app/(routes)/settings/categories/page.tsx @@ -101,7 +101,15 @@ export default function CategoriesSettingsPage() { type="number" value={cat.order} min={0} - onChange={(e) => handleFieldChange(cat.id, 'order', Number(e.target.value))} + onChange={(e) => { + const val = e.target.value; + const parsed = val === '' ? undefined : Number(val); + handleFieldChange( + cat.id, + 'order', + parsed === undefined || Number.isNaN(parsed) ? cat.order : parsed, + ); + }} />
From 71806cf374bc639b9a202631e14dee25eb25918e Mon Sep 17 00:00:00 2001 From: amrit Date: Tue, 17 Jun 2025 21:19:37 +0530 Subject: [PATCH 08/15] Replaced the single-variable tuple assignment with explicit destructuring --- apps/mail/components/mail/mail.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 07b2b86a2f..27b98a2b84 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -460,7 +460,7 @@ export function MailLayout() { }, []); const defaultCategoryId = useDefaultCategoryId(); - const category = useQueryState('category', { defaultValue: defaultCategoryId }); + const [category, setCategory] = useQueryState('category', { defaultValue: defaultCategoryId }); return ( @@ -591,7 +591,7 @@ export function MailLayout() {
Date: Tue, 17 Jun 2025 21:49:47 +0530 Subject: [PATCH 09/15] Implement category order validation and sorting in settings page, unique value for orders --- .../app/(routes)/settings/categories/page.tsx | 14 +++++++++++-- apps/server/src/lib/schemas.ts | 20 ++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/mail/app/(routes)/settings/categories/page.tsx b/apps/mail/app/(routes)/settings/categories/page.tsx index 3a823ba0bc..955629cd59 100644 --- a/apps/mail/app/(routes)/settings/categories/page.tsx +++ b/apps/mail/app/(routes)/settings/categories/page.tsx @@ -44,14 +44,24 @@ export default function CategoriesSettingsPage() { return; } + const orderValues = categories.map((c) => c.order); + const hasDuplicateOrders = new Set(orderValues).size !== orderValues.length; + if (hasDuplicateOrders) { + toast.error('Each category must have a unique order number'); + return; + } + + const sortedCategories = [...categories].sort((a, b) => a.order - b.order); + try { - await saveUserSettings({ categories }); + await saveUserSettings({ categories: sortedCategories }); queryClient.setQueryData(trpc.settings.get.queryKey(), (updater: any) => { if (!updater) return; return { - settings: { ...updater.settings, categories }, + settings: { ...updater.settings, categories: sortedCategories }, }; }); + setCategories(sortedCategories); toast.success('Categories saved'); } catch (e) { console.error(e); diff --git a/apps/server/src/lib/schemas.ts b/apps/server/src/lib/schemas.ts index c6b8f6b6b3..60f1f3df26 100644 --- a/apps/server/src/lib/schemas.ts +++ b/apps/server/src/lib/schemas.ts @@ -89,6 +89,24 @@ export const defaultMailCategories: MailCategory[] = [ }, ]; +const categoriesSchema = z.array(mailCategorySchema).superRefine((cats, ctx) => { + const orders = cats.map((c) => c.order); + if (new Set(orders).size !== orders.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Each mail category must have a unique order number', + }); + } + + const defaultCount = cats.filter((c) => c.isDefault).length; + if (defaultCount !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Exactly one mail category must be set as default', + }); + } +}); + export const userSettingsSchema = z.object({ language: z.string(), timezone: z.string(), @@ -99,7 +117,7 @@ export const userSettingsSchema = z.object({ trustedSenders: z.string().array().optional(), colorTheme: z.enum(['light', 'dark', 'system']).default('system'), zeroSignature: z.boolean().default(true), - categories: z.array(mailCategorySchema).optional(), + categories: categoriesSchema.optional(), }); export type UserSettings = z.infer; From 2496fa87244e9b3095a8e3bf2e5b08975173b8b4 Mon Sep 17 00:00:00 2001 From: amrit Date: Wed, 18 Jun 2025 09:13:37 +0530 Subject: [PATCH 10/15] some prompts for the LLM incase the user wants to update the search query --- apps/server/src/lib/prompts.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/server/src/lib/prompts.ts b/apps/server/src/lib/prompts.ts index 400399ec8e..8c4d921f02 100644 --- a/apps/server/src/lib/prompts.ts +++ b/apps/server/src/lib/prompts.ts @@ -261,6 +261,17 @@ export const GmailSearchAssistantSystemPrompt = () => When asked to search always use the OR operator to search for related terms, example: "emails from canva" should also be searched as "from:canva.com OR from:canva OR canva". + + Predefined Category Mappings: If the user's entire request (after trimming and case-folding) exactly matches one of these category names, output the associated query verbatim and do not add any other operators or words. + + NOT is:draft (is:inbox OR (is:sent AND to:me)) + is:important NOT is:sent NOT is:draft + is:personal NOT is:sent NOT is:draft + is:promotions NOT is:sent NOT is:draft + is:updates NOT is:sent NOT is:draft + is:unread NOT is:sent NOT is:draft + + Return only the final Gmail search query string, with no additional text, explanations, or formatting. @@ -298,6 +309,17 @@ export const OutlookSearchAssistantSystemPrompt = () => When asked to search always use the OR operator to search for related terms, example: "emails from canva" should also be searched as "from:canva.com OR from:canva OR canva". + + Predefined Category Mappings: If the user's entire request (after trimming and case-folding) exactly matches one of these category names, output the associated query verbatim and do not add any other operators or words. + + NOT is:draft (is:inbox OR (is:sent AND to:me)) + is:important NOT is:sent NOT is:draft + is:personal NOT is:sent NOT is:draft + is:promotions NOT is:sent NOT is:draft + is:updates NOT is:sent NOT is:draft + is:unread NOT is:sent NOT is:draft + + Return only the final Outlook search query string, with no additional text, explanations, or formatting. From 43d28b276d89b20339020d737ed48cd85530f735 Mon Sep 17 00:00:00 2001 From: amrit Date: Wed, 18 Jun 2025 09:13:52 +0530 Subject: [PATCH 11/15] new ui, add natural language query --- .../app/(routes)/settings/categories/page.tsx | 166 ++++++++++++++---- 1 file changed, 132 insertions(+), 34 deletions(-) diff --git a/apps/mail/app/(routes)/settings/categories/page.tsx b/apps/mail/app/(routes)/settings/categories/page.tsx index 955629cd59..64891a90b7 100644 --- a/apps/mail/app/(routes)/settings/categories/page.tsx +++ b/apps/mail/app/(routes)/settings/categories/page.tsx @@ -10,6 +10,10 @@ import { useTRPC } from '@/providers/query-provider'; import { toast } from 'sonner'; import type { CategorySetting } from '@/hooks/use-categories'; import { defaultMailCategories } from '../../../../../server/src/lib/schemas'; +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'; +import { Sparkles } from '@/components/icons/icons'; +import { Loader } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; export default function CategoriesSettingsPage() { const { data } = useSettings(); @@ -19,7 +23,13 @@ export default function CategoriesSettingsPage() { trpc.settings.save.mutationOptions(), ); + const { mutateAsync: generateSearchQuery, isPending: isGeneratingQuery } = useMutation( + trpc.ai.generateSearchQuery.mutationOptions(), + ); + const [categories, setCategories] = useState([]); + const [activeAiCat, setActiveAiCat] = useState(null); + const [promptValues, setPromptValues] = useState>({}); useEffect(() => { const stored = data?.settings?.categories ?? []; @@ -74,41 +84,142 @@ export default function CategoriesSettingsPage() { } return ( -
+
- {isPending ? 'Saving…' : 'Save Changes'} - +
+ +
} > -
+
{categories.map((cat) => ( -
- -
-
- +
+
+
+ + {cat.id} + + {cat.isDefault && ( + + Default + + )} +
+
+ { + const newCats = categories.map((c) => ({ + ...c, + isDefault: c.id === cat.id ? val : false, + })); + setCategories(newCats); + }} + /> + +
+
+ +
+
+ handleFieldChange(cat.id, 'name', e.target.value)} />
-
- - handleFieldChange(cat.id, 'searchValue', e.target.value)} - /> + +
+ +
+ handleFieldChange(cat.id, 'searchValue', e.target.value)} + /> + + { + if (open) { + setActiveAiCat(cat.id); + } else { + setActiveAiCat(null); + } + }} + > + + + + +
+ + + setPromptValues((prev) => ({ ...prev, [cat.id]: e.target.value })) + } + /> +
+
+ Example: "emails from my boss about quarterly reports" +
+ +
+
+
-
- + +
+ { @@ -122,19 +233,6 @@ export default function CategoriesSettingsPage() { }} />
-
- { - const newCats = categories.map((c) => ({ - ...c, - isDefault: c.id === cat.id ? val : false, - })); - setCategories(newCats); - }} - /> - Default -
))} @@ -142,4 +240,4 @@ export default function CategoriesSettingsPage() {
); -} \ No newline at end of file +} \ No newline at end of file From 1d7ab5be7dfb0425684c3f033232ef3145ee09d0 Mon Sep 17 00:00:00 2001 From: amrit Date: Wed, 18 Jun 2025 09:40:52 +0530 Subject: [PATCH 12/15] expose defaultMailCategories via tRPC and remove client-side duplicates --- .../app/(routes)/settings/categories/page.tsx | 13 +++-- apps/mail/hooks/use-categories.ts | 54 ++++--------------- apps/server/src/trpc/index.ts | 2 + apps/server/src/trpc/routes/categories.ts | 6 +++ 4 files changed, 26 insertions(+), 49 deletions(-) create mode 100644 apps/server/src/trpc/routes/categories.ts diff --git a/apps/mail/app/(routes)/settings/categories/page.tsx b/apps/mail/app/(routes)/settings/categories/page.tsx index 64891a90b7..d07c745a11 100644 --- a/apps/mail/app/(routes)/settings/categories/page.tsx +++ b/apps/mail/app/(routes)/settings/categories/page.tsx @@ -1,5 +1,5 @@ import { useSettings } from '@/hooks/use-settings'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import { SettingsCard } from '@/components/settings/settings-card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -9,7 +9,6 @@ import { useState, useEffect } from 'react'; import { useTRPC } from '@/providers/query-provider'; import { toast } from 'sonner'; import type { CategorySetting } from '@/hooks/use-categories'; -import { defaultMailCategories } from '../../../../../server/src/lib/schemas'; import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'; import { Sparkles } from '@/components/icons/icons'; import { Loader } from 'lucide-react'; @@ -27,20 +26,26 @@ export default function CategoriesSettingsPage() { trpc.ai.generateSearchQuery.mutationOptions(), ); + const { data: defaultMailCategories = [] } = useQuery( + trpc.categories.defaults.queryOptions(void 0, { staleTime: Infinity }), + ); + const [categories, setCategories] = useState([]); const [activeAiCat, setActiveAiCat] = useState(null); const [promptValues, setPromptValues] = useState>({}); useEffect(() => { + if (!defaultMailCategories.length) return; + const stored = data?.settings?.categories ?? []; const merged = defaultMailCategories.map((def) => { - const override = stored.find((c: { id: string; }) => c.id === def.id); + const override = stored.find((c: { id: string }) => c.id === def.id); return override ? { ...def, ...override } : def; }); setCategories(merged.sort((a, b) => a.order - b.order)); - }, [data]); + }, [data, defaultMailCategories]); const handleFieldChange = (id: string, field: keyof CategorySetting, value: any) => { setCategories((prev) => diff --git a/apps/mail/hooks/use-categories.ts b/apps/mail/hooks/use-categories.ts index 5bd678fd72..5a2de8e502 100644 --- a/apps/mail/hooks/use-categories.ts +++ b/apps/mail/hooks/use-categories.ts @@ -1,4 +1,6 @@ import { useSettings } from '@/hooks/use-settings'; +import { useTRPC } from '@/providers/query-provider'; +import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; export interface CategorySetting { @@ -12,50 +14,12 @@ export interface CategorySetting { export function useCategorySettings(): CategorySetting[] { const { data } = useSettings(); - const defaultCategories: CategorySetting[] = [ - { - id: 'Important', - name: 'Important', - searchValue: 'is:important NOT is:sent NOT is:draft', - order: 0, - isDefault: false, - }, - { - id: 'All Mail', - name: 'All Mail', - searchValue: 'NOT is:draft (is:inbox OR (is:sent AND to:me))', - order: 1, - isDefault: true, - }, - { - id: 'Personal', - name: 'Personal', - searchValue: 'is:personal NOT is:sent NOT is:draft', - order: 2, - isDefault: false, - }, - { - id: 'Promotions', - name: 'Promotions', - searchValue: 'is:promotions NOT is:sent NOT is:draft', - order: 3, - isDefault: false, - }, - { - id: 'Updates', - name: 'Updates', - searchValue: 'is:updates NOT is:sent NOT is:draft', - order: 4, - isDefault: false, - }, - { - id: 'Unread', - name: 'Unread', - searchValue: 'is:unread NOT is:sent NOT is:draft', - order: 5, - isDefault: false, - }, - ]; + const trpc = useTRPC(); + const { data: defaultCategories = [] } = useQuery( + trpc.categories.defaults.queryOptions(void 0, { staleTime: Infinity }), + ); + + if (!defaultCategories.length) return []; const merged = useMemo(() => { const overrides = (data?.settings.categories as CategorySetting[] | undefined) ?? []; @@ -72,7 +36,7 @@ export function useCategorySettings(): CategorySetting[] { const sorted = overridden.sort((a, b) => a.order - b.order); return sorted; - }, [data?.settings.categories]); + }, [data?.settings.categories, defaultCategories]); return merged; } diff --git a/apps/server/src/trpc/index.ts b/apps/server/src/trpc/index.ts index 52355bfdb7..2171a7700a 100644 --- a/apps/server/src/trpc/index.ts +++ b/apps/server/src/trpc/index.ts @@ -13,10 +13,12 @@ import { mailRouter } from './routes/mail'; import type { HonoContext } from '../ctx'; import { aiRouter } from './routes/ai'; import { router } from './trpc'; +import { categoriesRouter } from './routes/categories'; export const appRouter = router({ ai: aiRouter, brain: brainRouter, + categories: categoriesRouter, connections: connectionsRouter, cookiePreferences: cookiePreferencesRouter, drafts: draftsRouter, diff --git a/apps/server/src/trpc/routes/categories.ts b/apps/server/src/trpc/routes/categories.ts new file mode 100644 index 0000000000..f17c6051d6 --- /dev/null +++ b/apps/server/src/trpc/routes/categories.ts @@ -0,0 +1,6 @@ +import { router, publicProcedure } from '../trpc'; +import { defaultMailCategories } from '../../lib/schemas'; + +export const categoriesRouter = router({ + defaults: publicProcedure.query(() => defaultMailCategories), +}); \ No newline at end of file From e5815a892b7a49c8d4e7f9cbc5355c000ff67f59 Mon Sep 17 00:00:00 2001 From: amrit Date: Wed, 18 Jun 2025 09:41:38 +0530 Subject: [PATCH 13/15] Update example text --- apps/mail/app/(routes)/settings/categories/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mail/app/(routes)/settings/categories/page.tsx b/apps/mail/app/(routes)/settings/categories/page.tsx index d07c745a11..6d961f5388 100644 --- a/apps/mail/app/(routes)/settings/categories/page.tsx +++ b/apps/mail/app/(routes)/settings/categories/page.tsx @@ -188,7 +188,7 @@ export default function CategoriesSettingsPage() { />
- Example: "emails from my boss about quarterly reports" + Example: "emails that mention quarterly reports"