Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions apps/mail/actions/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,48 @@ export const muteThread = async ({ ids }: { ids: string[] }) => {
throw error;
}
};

export const checkSpamEmails = async () => {
try {
const driver = await getActiveDriver();

// Get all emails from spam folder
const spamEmails = await driver.list('spam');

// Return count and whether there are any emails
return {
hasEmails: !!spamEmails?.threads?.length,
count: spamEmails?.threads?.length || 0
};
} catch (error) {
console.error('Error checking spam emails:', error);
return { hasEmails: false, count: 0 };
}
};

export const deleteAllSpamEmails = async () => {
try {
const driver = await getActiveDriver();

// Get all emails from spam folder
const spamEmails = await driver.list('spam');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this deletes the default count of list which is 20 threads in the spam folder, which is the number of threads on the user's page, it doesn't delete all

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use trpc

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay!

Copy link
Contributor Author

@giteshsarvaiya giteshsarvaiya May 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yess, I see that the maxResult is set to 20 by default and can go upto 500(as per the gmail api provider). so yes, tRPC would be a better approach.

my next commit will have tRPC

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MrgSub pls check now

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if we introduced pagination(to fetch all spams with maxResult 500) and implemented it with the same logic as of previous commit, i.e. without tRPC ? is there a specific reason we chose tRPC over server action ?

console.log(spamEmails?.threads?.length);
if (!spamEmails || !spamEmails.threads || spamEmails.threads.length === 0) {
return { success: true, message: 'No spam emails to delete' };
}

// Extract email IDs
const emailIds = spamEmails.threads.map((thread) => thread.id);

// Use existing bulkDeleteThread function to move emails to trash
await bulkDeleteThread({ ids: emailIds });

return {
success: true,
message: `Successfully deleted ${emailIds.length} email(s) from spam folder`
};
} catch (error) {
console.error('Error deleting all spam emails:', error);
throw error;
}
};
13 changes: 13 additions & 0 deletions apps/mail/app/api/trpc/[trpc]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/app/trpc/root';
import { createTRPCContext } from '@/app/trpc/trpc';

// Handle tRPC requests
export const POST = async (req: Request) => {
return fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createTRPCContext({ headers: req.headers }),
});
};
13 changes: 8 additions & 5 deletions apps/mail/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Viewport } from 'next';
import { cn } from '@/lib/utils';
import Script from 'next/script';
import './globals.css';
import { TRPCProvider } from '@/components/providers/trpc-provider';

const geistSans = Geist({
variable: '--font-geist-sans',
Expand Down Expand Up @@ -61,11 +62,13 @@ export default async function RootLayout({
>
<Providers attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<NextIntlClientProvider messages={messages}>
{children}
{cookies}
<CustomToaster />
<Analytics />
{/* {isEuRegion && <CookieConsent />} */}
<TRPCProvider>
{children}
{cookies}
<CustomToaster />
<Analytics />
{/* {isEuRegion && <CookieConsent />} */}
</TRPCProvider>
</NextIntlClientProvider>
</Providers>
</body>
Expand Down
8 changes: 8 additions & 0 deletions apps/mail/app/trpc/root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { router } from '@/app/trpc/trpc';
import { mailRouter } from '@/app/trpc/router/mail';

export const appRouter = router({
mail: mailRouter,
});

export type AppRouter = typeof appRouter;
86 changes: 86 additions & 0 deletions apps/mail/app/trpc/router/mail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { z } from 'zod';
import { procedure, router } from '@/app/trpc/trpc';
import { getActiveDriver } from '@/actions/utils';
import { bulkDeleteThread } from '@/actions/mail';
import type { InitialThread } from '@/types';

// Define interface for the response type
interface SpamFolderResponse {
threads: InitialThread[];
nextPageToken?: string;
}

// Function to get all spam emails with pagination
async function getAllSpamEmails(driver: any) {
const allThreads: InitialThread[] = [];
let pageToken: string | undefined = undefined;

do {
// Request a large batch size to minimize the number of API calls
const response: SpamFolderResponse = await driver.list(
'spam', // folder
undefined, // query
500, // maxResults - Maximum allowed by Gmail API
undefined, // labelIds
pageToken // pageToken
);

if (response.threads && response.threads.length > 0) {
allThreads.push(...response.threads);
}

pageToken = response.nextPageToken;
} while (pageToken);

return { threads: allThreads };
}

export const mailRouter = router({
deleteAllSpam: procedure
.mutation(async () => {
try {
const driver = await getActiveDriver();

// Get ALL emails from spam folder using pagination
const spamEmails = await getAllSpamEmails(driver);

if (!spamEmails?.threads?.length) {
return {
success: true,
message: 'No spam emails to delete'
};
}

// Extract all email IDs
const emailIds = spamEmails.threads.map((thread) => thread.id);

// Move them to trash
await bulkDeleteThread({ ids: emailIds });

return {
success: true,
message: `Successfully deleted ${emailIds.length} email(s) from spam folder`
};
} catch (error) {
console.error('Error deleting all spam emails:', error);
throw error;
}
}),

// Add a separate procedure to check if spam folder has emails
checkSpamEmails: procedure
.query(async () => {
try {
const driver = await getActiveDriver();
const spamEmails = await driver.list('spam');

return {
hasEmails: !!spamEmails?.threads?.length,
count: spamEmails?.threads?.length || 0
};
} catch (error) {
console.error('Error checking spam emails:', error);
return { hasEmails: false, count: 0 };
}
}),
});
28 changes: 28 additions & 0 deletions apps/mail/app/trpc/trpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { initTRPC } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';

// Create context for tRPC requests
export const createTRPCContext = async (opts: { headers: Headers }) => {
return {
headers: opts.headers,
};
};

// Initialize tRPC
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});

// Export tRPC utilities
export const { router, procedure, middleware } = t;
83 changes: 80 additions & 3 deletions apps/mail/components/mail/mail.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use client';

import { api } from '../../utils/trpc';

import {
Archive2,
Bell,
Expand Down Expand Up @@ -33,6 +35,8 @@ import {
getMail,
markAsImportant,
markAsRead,
deleteAllSpamEmails,
checkSpamEmails
} from '@/actions/mail';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
Expand Down Expand Up @@ -75,6 +79,25 @@ export function MailLayout() {
const prevFolderRef = useRef(folder);
const { enableScope, disableScope } = useHotkeysContext();
const { data: brainState } = useBrainState();
const { mutate: mutateThreads } = useThreads();
const { mutate: mutateStats } = useStats();
const [hasSpamEmails, setHasSpamEmails] = useState(false);
// Track when spam emails are deleted to refresh the UI
const [spamDeleteCounter, setSpamDeleteCounter] = useState(0);

// Create the mutation at the top level of the component
const deleteAllSpamMutation = api.mail.deleteAllSpam.useMutation({
onSuccess: (data: { success: boolean; message: string }) => {
mutateThreads();
mutateStats();
setSpamDeleteCounter(prev => prev + 1);
toast.success(data.message || 'Successfully deleted all spam emails');
},
onError: (error: { message?: string }) => {
console.error("Failed to delete spam emails:", error);
toast.error(error.message || 'Failed to delete spam emails');
}
});

useEffect(() => {
if (prevFolderRef.current !== folder && mail.bulkSelected.length > 0) {
Expand Down Expand Up @@ -131,6 +154,24 @@ export function MailLayout() {
setActiveReplyId(null);
}, [setThreadId]);

// Callback that uses the mutation
const onDeleteAllSpam = useCallback(() => {
// Use toast.promise instead of separate loading/success/error toasts
toast.promise(
new Promise<{ success: boolean; message: string }>((resolve, reject) => {
deleteAllSpamMutation.mutate(undefined, {
onSuccess: (data: { success: boolean; message: string }) => resolve(data),
onError: (error: { message?: string }) => reject(error)
});
}),
{
loading: 'Deleting all spam emails...',
success: (data: { success: boolean; message: string }) => data.message || 'Successfully deleted all spam emails',
error: (err: { message?: string }) => err.message || 'Failed to delete spam emails',
}
);
}, [deleteAllSpamMutation]);

// Add mailto protocol handler registration
useEffect(() => {
// Register as a mailto protocol handler if browser supports it
Expand All @@ -155,6 +196,26 @@ export function MailLayout() {

const category = useQueryState('category');

// Check if spam folder has emails when in spam folder
useEffect(() => {
if(folder === 'bin') {
return;
}
if (folder === 'spam') {
const checkForSpamEmails = async () => {
try {
const result = await checkSpamEmails();
setHasSpamEmails(result.hasEmails);
} catch (error) {
console.error('Error checking spam emails:', error);
setHasSpamEmails(false);
}
};

checkForSpamEmails();
}
}, [folder, spamDeleteCounter]);

return (
<TooltipProvider delayDuration={0}>
<div className="rounded-inherit relative z-[5] flex p-0 md:mt-1">
Expand Down Expand Up @@ -215,6 +276,19 @@ export function MailLayout() {
</div>
<div className="p-2 px-[22px]">
<SearchBar />
{folder === 'spam' && hasSpamEmails && (
<div className="mt-2">
<Button
variant="outline"
size="sm"
className="flex items-center justify-center gap-2 w-full border-[#FCCDD5] hover:bg-[#FDE4E9]/10 dark:border-[#6E2532] dark:hover:bg-[#411D23]/60"
onClick={onDeleteAllSpam}
>
<Trash className="h-4 w-4 text-[#F43F5E] fill-[#F43F5E]" />
<span>Delete All Spam</span>
</Button>
</div>
)}
<div className="mt-2">
{folder === 'inbox' && (
<CategorySelect isMultiSelectMode={mail.bulkSelected.length > 0} />
Expand Down Expand Up @@ -408,7 +482,7 @@ function BulkSelectActions() {
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<button className="flex aspect-square h-8 items-center justify-center gap-1 overflow-hidden rounded-md border bg-white px-2 text-sm transition-all duration-300 ease-out hover:bg-gray-100 dark:border-none dark:bg-[#313131] dark:hover:bg-[#313131]/80">
<button className="flex aspect-square h-8 items-center justify-center gap-1 overflow-hidden rounded-md border border-[#FCCDD5] bg-[#FDE4E9] px-2 text-sm transition-all duration-300 ease-out hover:bg-[#FDE4E9]/80 dark:border-[#6E2532] dark:bg-[#411D23] dark:hover:bg-[#313131]/80 hover:dark:bg-[#411D23]/60">
<div className="relative overflow-visible">
<svg
xmlns="http://www.w3.org/2000/svg"
Expand Down Expand Up @@ -483,7 +557,7 @@ function BulkSelectActions() {
}}
>
<div className="relative overflow-visible">
<Trash className="fill-[#F43F5E]" />
<Trash className="h-4 w-4 text-[#F43F5E] fill-[#F43F5E]" />
</div>
</button>
</TooltipTrigger>
Expand Down Expand Up @@ -674,7 +748,10 @@ function MailCategoryTabs({
onCategoryChange?: (category: string) => void;
initialCategory?: string;
}) {
const [, setSearchValue] = useSearchValue();
const t = useTranslations();
const [category] = useQueryState('category', {
defaultValue: 'Important',
});
const categories = Categories();

// Initialize with just the initialCategory or "Primary"
Expand Down
33 changes: 33 additions & 0 deletions apps/mail/components/providers/trpc-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// components/providers/trpc-provider.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { useState } from 'react';
import { api } from '@/utils/trpc';
import superjson from 'superjson';

export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === 'development' ||
(opts.direction === 'down' && opts.result instanceof Error),
}),
httpBatchLink({
url: '/api/trpc',
}),
],
transformer: superjson,
})
);

return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</api.Provider>
);
}
Loading