From 8db5a64a99d2d015a4220d7ffdcc7457d1223dbc Mon Sep 17 00:00:00 2001 From: Aj Wazzan Date: Wed, 16 Apr 2025 15:47:35 -0700 Subject: [PATCH] Refactor mail actions and types to improve thread handling and response structure. Update getMail to return IGetThreadResponse, modify related components to utilize new structure, and enhance error handling in driver functions. --- apps/mail/actions/mail.ts | 13 +- apps/mail/app/(routes)/layout.tsx | 2 +- apps/mail/app/api/driver/[id]/route.ts | 15 +- apps/mail/app/api/driver/google.ts | 216 ++++++++++++------------ apps/mail/app/api/driver/route.ts | 26 +-- apps/mail/app/api/driver/types.ts | 11 +- apps/mail/components/mail/mail-list.tsx | 111 +++++++----- apps/mail/components/mail/mail.tsx | 2 +- apps/mail/hooks/use-threads.ts | 11 +- apps/mail/types/index.ts | 31 ++-- 10 files changed, 230 insertions(+), 208 deletions(-) diff --git a/apps/mail/actions/mail.ts b/apps/mail/actions/mail.ts index 0fc45fc78..3da50f068 100644 --- a/apps/mail/actions/mail.ts +++ b/apps/mail/actions/mail.ts @@ -1,5 +1,6 @@ 'use server'; import { deleteActiveConnection, FatalErrors, getActiveDriver } from './utils'; +import { IGetThreadResponse } from '@/app/api/driver/types'; import { ParsedMessage } from '@/types'; export const getMails = async ({ @@ -29,7 +30,7 @@ export const getMails = async ({ } }; -export const getMail = async ({ id }: { id: string }): Promise => { +export const getMail = async ({ id }: { id: string }): Promise => { if (!id) { throw new Error('Missing required fields'); } @@ -128,17 +129,17 @@ export const toggleStar = async ({ ids }: { ids: string[] }) => { return { success: false, error: 'No thread IDs provided' }; } - const threadResults = await Promise.allSettled( - threadIds.map(id => driver.get(id)) - ); + const threadResults = await Promise.allSettled(threadIds.map((id) => driver.get(id))); let anyStarred = false; let processedThreads = 0; for (const result of threadResults) { - if (result.status === 'fulfilled' && result.value && result.value.length > 0) { + if (result.status === 'fulfilled' && result.value && result.value.messages.length > 0) { processedThreads++; - const isThreadStarred = result.value.some((message: ParsedMessage) => message.tags?.includes('STARRED')); + const isThreadStarred = result.value.messages.some((message: ParsedMessage) => + message.tags?.includes('STARRED'), + ); if (isThreadStarred) { anyStarred = true; break; diff --git a/apps/mail/app/(routes)/layout.tsx b/apps/mail/app/(routes)/layout.tsx index a589635d8..7696a5e37 100644 --- a/apps/mail/app/(routes)/layout.tsx +++ b/apps/mail/app/(routes)/layout.tsx @@ -10,7 +10,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}) => { - const finalIp = processIP(req) + const finalIp = processIP(req); + const { id } = await params; const ratelimit = getRatelimitModule({ - prefix: `ratelimit:get-mail`, + prefix: `ratelimit:get-mail-${id}`, limiter: Ratelimit.slidingWindow(60, '1m'), - }) + }); const { success, headers } = await checkRateLimit(ratelimit, finalIp); if (!success) { return NextResponse.json( @@ -17,7 +17,6 @@ export const GET = async (req: NextRequest, { params }: { params: Promise<{ id: { status: 429, headers }, ); } - const { id } = await params; const threadResponse = await getMail({ id, diff --git a/apps/mail/app/api/driver/google.ts b/apps/mail/app/api/driver/google.ts index 6e27f242b..e73299d61 100644 --- a/apps/mail/app/api/driver/google.ts +++ b/apps/mail/app/api/driver/google.ts @@ -1,10 +1,10 @@ import { parseAddressList, parseFrom, wasSentWithTLS } from '@/lib/email-utils'; +import { IOutgoingMessage, Sender, type ParsedMessage } from '@/types'; import { type IConfig, type MailManager } from './types'; import { type gmail_v1, google } from 'googleapis'; import { EnableBrain } from '@/actions/brain'; -import { IOutgoingMessage, Sender, type ParsedMessage } from '@/types'; -import * as he from 'he'; import { createMimeMessage } from 'mimetext'; +import * as he from 'he'; function fromBase64Url(str: string) { return str.replace(/-/g, '+').replace(/_/g, '/'); @@ -80,7 +80,7 @@ const parseDraft = (draft: gmail_v1.Schema$Draft): ParsedDraft | null => { }; // Helper function for delays -const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export const driver = async (config: IConfig): Promise => { const auth = new google.auth.OAuth2( @@ -148,11 +148,12 @@ export const driver = async (config: IConfig): Promise => { .map((h) => h.value) .filter((v) => typeof v === 'string') || []; - const cc = ccHeaders.length > 0 - ? ccHeaders - .filter(header => header.trim().length > 0) - .flatMap(header => parseAddressList(header)) - : null; + const cc = + ccHeaders.length > 0 + ? ccHeaders + .filter((header) => header.trim().length > 0) + .flatMap((header) => parseAddressList(header)) + : null; const receivedHeaders = payload?.headers @@ -183,13 +184,21 @@ export const driver = async (config: IConfig): Promise => { messageId, }; }; - const parseOutgoing = async ({ to, subject, message, attachments, headers, cc, bcc }: IOutgoingMessage) => { + const parseOutgoing = async ({ + to, + subject, + message, + attachments, + headers, + cc, + bcc, + }: IOutgoingMessage) => { const msg = createMimeMessage(); const fromEmail = config.auth?.email || 'nobody@example.com'; console.log('Debug - From email:', fromEmail); console.log('Debug - Original to recipients:', JSON.stringify(to, null, 2)); - + msg.setSender({ name: '', addr: fromEmail }); // Track unique recipients to avoid duplicates @@ -207,7 +216,7 @@ export const driver = async (config: IConfig): Promise => { // Handle all To recipients const toRecipients = to - .filter(recipient => { + .filter((recipient) => { if (!recipient || !recipient.email) { console.log('Debug - Skipping invalid recipient:', recipient); return false; @@ -219,9 +228,9 @@ export const driver = async (config: IConfig): Promise => { normalizedEmail: email, fromEmail, isDuplicate: uniqueRecipients.has(email), - isSelf: email === fromEmail + isSelf: email === fromEmail, }); - + // Only check for duplicates, allow sending to yourself if (!uniqueRecipients.has(email)) { uniqueRecipients.add(email); @@ -229,9 +238,9 @@ export const driver = async (config: IConfig): Promise => { } return false; }) - .map(recipient => ({ + .map((recipient) => ({ name: recipient.name || '', - addr: recipient.email + addr: recipient.email, })); console.log('Debug - Filtered to recipients:', JSON.stringify(toRecipients, null, 2)); @@ -242,7 +251,7 @@ export const driver = async (config: IConfig): Promise => { console.error('Debug - No valid recipients after filtering:', { originalTo: to, filteredTo: toRecipients, - fromEmail + fromEmail, }); throw new Error('No valid recipients found in To field'); } @@ -250,7 +259,7 @@ export const driver = async (config: IConfig): Promise => { // Handle CC recipients if (Array.isArray(cc) && cc.length > 0) { const ccRecipients = cc - .filter(recipient => { + .filter((recipient) => { const email = recipient.email.toLowerCase(); if (!uniqueRecipients.has(email) && email !== fromEmail) { uniqueRecipients.add(email); @@ -258,9 +267,9 @@ export const driver = async (config: IConfig): Promise => { } return false; }) - .map(recipient => ({ + .map((recipient) => ({ name: recipient.name || '', - addr: recipient.email + addr: recipient.email, })); if (ccRecipients.length > 0) { @@ -271,7 +280,7 @@ export const driver = async (config: IConfig): Promise => { // Handle BCC recipients if (Array.isArray(bcc) && bcc.length > 0) { const bccRecipients = bcc - .filter(recipient => { + .filter((recipient) => { const email = recipient.email.toLowerCase(); if (!uniqueRecipients.has(email) && email !== fromEmail) { uniqueRecipients.add(email); @@ -279,11 +288,11 @@ export const driver = async (config: IConfig): Promise => { } return false; }) - .map(recipient => ({ + .map((recipient) => ({ name: recipient.name || '', - addr: recipient.email + addr: recipient.email, })); - + if (bccRecipients.length > 0) { msg.setBcc(bccRecipients); } @@ -293,7 +302,7 @@ export const driver = async (config: IConfig): Promise => { msg.addMessage({ contentType: 'text/html', - data: message.trim() + data: message.trim(), }); // Set headers for reply/reply-all/forward @@ -302,12 +311,15 @@ export const driver = async (config: IConfig): Promise => { if (value) { // Ensure References header includes all previous message IDs if (key.toLowerCase() === 'references' && value) { - const refs = value.split(' ').filter(Boolean).map(ref => { - // Add angle brackets if not present - if (!ref.startsWith('<')) ref = `<${ref}`; - if (!ref.endsWith('>')) ref = `${ref}>`; - return ref; - }); + const refs = value + .split(' ') + .filter(Boolean) + .map((ref) => { + // Add angle brackets if not present + if (!ref.startsWith('<')) ref = `<${ref}`; + if (!ref.endsWith('>')) ref = `${ref}>`; + return ref; + }); msg.setHeader(key, refs.join(' ')); } else { msg.setHeader(key, value); @@ -321,23 +333,23 @@ export const driver = async (config: IConfig): Promise => { for (const file of attachments) { const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - const base64Content = buffer.toString("base64"); + const base64Content = buffer.toString('base64'); msg.addAttachment({ filename: file.name, - contentType: file.type || "application/octet-stream", - data: base64Content + contentType: file.type || 'application/octet-stream', + data: base64Content, }); } } const emailContent = msg.asRaw(); - const encodedMessage = Buffer.from(emailContent).toString("base64"); + const encodedMessage = Buffer.from(emailContent).toString('base64'); return { raw: encodedMessage, - } - } + }; + }; const normalizeSearch = (folder: string, q: string) => { // Handle special folders if (folder === 'bin') { @@ -355,20 +367,20 @@ export const driver = async (config: IConfig): Promise => { const gmail = google.gmail({ version: 'v1', auth }); const modifyThreadLabels = async ( - threadIds: string[], - requestBody: gmail_v1.Schema$ModifyThreadRequest + threadIds: string[], + requestBody: gmail_v1.Schema$ModifyThreadRequest, ) => { - if (threadIds.length === 0) { - return; + if (threadIds.length === 0) { + return; } - const chunkSize = 15; + const chunkSize = 15; const delayBetweenChunks = 100; const allResults = []; for (let i = 0; i < threadIds.length; i += chunkSize) { const chunk = threadIds.slice(i, i + chunkSize); - + const promises = chunk.map(async (threadId) => { try { const response = await gmail.users.threads.modify({ @@ -377,7 +389,7 @@ export const driver = async (config: IConfig): Promise => { requestBody: requestBody, }); return { threadId, status: 'fulfilled' as const, value: response.data }; - } catch (error: any) { + } catch (error: any) { const errorMessage = error?.errors?.[0]?.message || error.message || error; console.error(`Failed bulk modify operation for thread ${threadId}:`, errorMessage); return { threadId, status: 'rejected' as const, reason: { error: errorMessage } }; @@ -392,10 +404,13 @@ export const driver = async (config: IConfig): Promise => { } } - const failures = allResults.filter(result => result.status === 'rejected'); + const failures = allResults.filter((result) => result.status === 'rejected'); if (failures.length > 0) { - const failureReasons = failures.map(f => ({ threadId: f.threadId, reason: f.reason })); - console.error(`Failed bulk modify operation for ${failures.length}/${threadIds.length} threads:`, failureReasons); + const failureReasons = failures.map((f) => ({ threadId: f.threadId, reason: f.reason })); + console.error( + `Failed bulk modify operation for ${failures.length}/${threadIds.length} threads:`, + failureReasons, + ); } }; @@ -488,45 +503,15 @@ export const driver = async (config: IConfig): Promise => { maxResults, pageToken: pageToken ? pageToken : undefined, }); - const threads = await Promise.all( - (res.data.threads || []) - .map(async (thread) => { - if (!thread.id) return null; - const msg = await gmail.users.threads.get({ - userId: 'me', - id: thread.id, - format: 'metadata', - metadataHeaders: ['From', 'Subject', 'Date'], - }); - const labelIds = [ - ...new Set(msg.data.messages?.flatMap((message) => message.labelIds || [])), - ]; - const latestMessage = msg.data.messages?.reverse()?.find((msg) => { - const parsedMessage = parse({ ...msg, labelIds }); - return parsedMessage.sender.email !== config.auth?.email - }) - const message = latestMessage ? latestMessage : msg.data.messages?.[0] - const parsed = parse({ ...message, labelIds }); - return { - ...parsed, - body: '', - processedHtml: '', - blobUrl: '', - totalReplies: msg.data.messages?.length || 0, - threadId: thread.id, - }; - }) - .filter((msg): msg is NonNullable => msg !== null), - ); - - return { ...res.data, threads } as any; + return { ...res.data, threads: res.data.threads } as any; }, - get: async (id: string): Promise => { + get: async (id: string) => { console.log('Fetching thread:', id); const res = await gmail.users.threads.get({ userId: 'me', id, format: 'full' }); - if (!res.data.messages) return []; - - const messages = await Promise.all( + if (!res.data.messages) + return { messages: [], latest: undefined, hasUnread: false, totalReplies: 0 }; + let hasUnread = false; + const messages: ParsedMessage[] = await Promise.all( res.data.messages.map(async (message) => { const bodyData = message.payload?.body?.data || @@ -546,19 +531,27 @@ export const driver = async (config: IConfig): Promise => { // Process inline images if present let processedBody = decodedBody; if (message.payload?.parts) { - const inlineImages = message.payload.parts - .filter(part => { - const contentDisposition = part.headers?.find(h => h.name?.toLowerCase() === 'content-disposition')?.value || ''; - const isInline = contentDisposition.toLowerCase().includes('inline'); - const hasContentId = part.headers?.some(h => h.name?.toLowerCase() === 'content-id'); - return isInline && hasContentId; - }); + const inlineImages = message.payload.parts.filter((part) => { + const contentDisposition = + part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value || + ''; + const isInline = contentDisposition.toLowerCase().includes('inline'); + const hasContentId = part.headers?.some( + (h) => h.name?.toLowerCase() === 'content-id', + ); + return isInline && hasContentId; + }); for (const part of inlineImages) { - const contentId = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value; + const contentId = part.headers?.find( + (h) => h.name?.toLowerCase() === 'content-id', + )?.value; if (contentId && part.body?.attachmentId) { try { - const imageData = await manager.getAttachment(message.id!, part.body.attachmentId); + const imageData = await manager.getAttachment( + message.id!, + part.body.attachmentId, + ); if (imageData) { // Remove < and > from Content-ID if present const cleanContentId = contentId.replace(/[<>]/g, ''); @@ -567,7 +560,7 @@ export const driver = async (config: IConfig): Promise => { // Replace cid: URL with data URL processedBody = processedBody.replace( new RegExp(`cid:${escapedContentId}`, 'g'), - `data:${part.mimeType};base64,${imageData}` + `data:${part.mimeType};base64,${imageData}`, ); } } catch (error) { @@ -588,12 +581,16 @@ export const driver = async (config: IConfig): Promise => { message.payload?.parts ?.filter((part) => { if (!part.filename || part.filename.length === 0) return false; - - const contentDisposition = part.headers?.find(h => h.name?.toLowerCase() === 'content-disposition')?.value || ''; + + const contentDisposition = + part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition') + ?.value || ''; const isInline = contentDisposition.toLowerCase().includes('inline'); - - const hasContentId = part.headers?.some(h => h.name?.toLowerCase() === 'content-id'); - + + const hasContentId = part.headers?.some( + (h) => h.name?.toLowerCase() === 'content-id', + ); + return !isInline || (isInline && !hasContentId); }) ?.map(async (part) => { @@ -651,27 +648,29 @@ export const driver = async (config: IConfig): Promise => { labels: fullEmailData.tags, }); + if (fullEmailData.unread) hasUnread = true; + return fullEmailData; }), ); - return messages; + return { messages, latest: messages[0], hasUnread, totalReplies: messages.length }; }, create: async (data) => { - const { raw } = await parseOutgoing(data) + const { raw } = await parseOutgoing(data); console.log('Debug - Sending message with threading info:', { threadId: data.threadId, - headers: data.headers + headers: data.headers, }); const res = await gmail.users.messages.send({ userId: 'me', requestBody: { raw, - threadId: data.threadId - } + threadId: data.threadId, + }, }); console.log('Debug - Message sent successfully:', { messageId: res.data.id, - threadId: res.data.threadId + threadId: res.data.threadId, }); return res.data; }, @@ -685,7 +684,10 @@ export const driver = async (config: IConfig): Promise => { ); return { threadIds }; }, - modifyLabels: async (threadIds: string[], options: { addLabels: string[]; removeLabels: string[] }) => { + modifyLabels: async ( + threadIds: string[], + options: { addLabels: string[]; removeLabels: string[] }, + ) => { await modifyThreadLabels(threadIds, { addLabelIds: options.addLabels, removeLabelIds: options.removeLabels, @@ -740,11 +742,11 @@ export const driver = async (config: IConfig): Promise => { console.log(`Fetched draft ${draft.id}:`, msg.data); const message = msg.data.message; if (!message) return null; - + const parsed = parse(message as any); const headers = message.payload?.headers || []; - const date = headers.find(h => h.name?.toLowerCase() === 'date')?.value; - + const date = headers.find((h) => h.name?.toLowerCase() === 'date')?.value; + return { ...parsed, id: draft.id, diff --git a/apps/mail/app/api/driver/route.ts b/apps/mail/app/api/driver/route.ts index 02709f9b5..487ebc9ba 100644 --- a/apps/mail/app/api/driver/route.ts +++ b/apps/mail/app/api/driver/route.ts @@ -1,22 +1,11 @@ +import { checkRateLimit, getRatelimitModule, processIP } from '../utils'; import { type NextRequest, NextResponse } from 'next/server'; import { Ratelimit } from '@upstash/ratelimit'; import { defaultPageSize } from '@/lib/utils'; import { getMails } from '@/actions/mail'; -import { checkRateLimit, getRatelimitModule, processIP } from '../utils'; export const GET = async (req: NextRequest) => { - const finalIp = processIP(req) - const ratelimit = getRatelimitModule({ - prefix: `ratelimit:list-threads`, - limiter: Ratelimit.slidingWindow(60, '1m'), - }) - const { success, headers } = await checkRateLimit(ratelimit, finalIp); - if (!success) { - return NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429, headers }, - ); - } + const finalIp = processIP(req); const searchParams = req.nextUrl.searchParams; let [folder, pageToken, q, max] = [ searchParams.get('folder'), @@ -24,6 +13,17 @@ export const GET = async (req: NextRequest) => { searchParams.get('q'), Number(searchParams.get('max')), ]; + const ratelimit = getRatelimitModule({ + prefix: `ratelimit:list-threads-${folder}`, + limiter: Ratelimit.slidingWindow(60, '1m'), + }); + const { success, headers } = await checkRateLimit(ratelimit, finalIp); + if (!success) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429, headers }, + ); + } if (!folder) folder = 'inbox'; if (!pageToken) pageToken = ''; if (!q) q = ''; diff --git a/apps/mail/app/api/driver/types.ts b/apps/mail/app/api/driver/types.ts index f3129ad76..6cb1bbce2 100644 --- a/apps/mail/app/api/driver/types.ts +++ b/apps/mail/app/api/driver/types.ts @@ -1,7 +1,14 @@ import { type IOutgoingMessage, type InitialThread, type ParsedMessage } from '@/types'; +export interface IGetThreadResponse { + messages: ParsedMessage[]; + latest: ParsedMessage | undefined; + hasUnread: boolean; + totalReplies: number; +} + export interface MailManager { - get(id: string): Promise; + get(id: string): Promise; create(data: IOutgoingMessage): Promise; createDraft(data: any): Promise; getDraft: (id: string) => Promise; @@ -35,6 +42,6 @@ export interface IConfig { auth?: { access_token: string; refresh_token: string; - email: string + email: string; }; } diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index 456236891..53bdff0b1 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -11,7 +11,7 @@ import { User, Users, } from 'lucide-react'; -import type { ConditionalThreadProps, InitialThread, MailListProps, MailSelectMode } from '@/types'; +import type { ConditionalThreadProps, InitialThread, MailListProps, MailSelectMode, ParsedMessage } from '@/types'; import { type ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { EmptyState, type FolderType } from '@/components/mail/empty-state'; @@ -20,7 +20,7 @@ import { useParams, useRouter } from 'next/navigation'; import { cn, FOLDERS, formatDate, getEmailLogo } from '@/lib/utils'; import { Avatar, AvatarImage, AvatarFallback } from '../ui/avatar'; import { useMailNavigation } from '@/hooks/use-mail-navigation'; -import { preloadThread, useThreads } from '@/hooks/use-threads'; +import { preloadThread, useThread, useThreads } from '@/hooks/use-threads'; import { useHotKey, useKeyState } from '@/hooks/use-hot-key'; import { useSearchValue } from '@/hooks/use-search-value'; import { markAsRead, markAsUnread } from '@/actions/mail'; @@ -36,6 +36,7 @@ import { useQueryState } from 'nuqs'; import { Categories } from './mail'; import items from './demo.json'; import { toast } from 'sonner'; +import { Skeleton } from '../ui/skeleton'; const HOVER_DELAY = 1000; const ThreadWrapper = ({ @@ -80,6 +81,7 @@ const Thread = memo( onClick, sessionData, isKeyboardFocused, + demoMessage, }: ConditionalThreadProps) => { const [mail] = useMail(); const [searchValue] = useSearchValue(); @@ -90,17 +92,22 @@ const Thread = memo( const hoverTimeoutRef = useRef | undefined>(undefined); const isHovering = useRef(false); const hasPrefetched = useRef(false); - const isMailSelected = useMemo(() => { - if (!threadId) return false; - const _threadId = message.threadId ?? message.id; - return _threadId === threadId || threadId === mail.selected; - }, [threadId, message.id, message.threadId, mail.selected]); + const {data: getThreadData, isLoading} = useThread(demo ? null : message.id); - const isMailBulkSelected = mail.bulkSelected.includes(message.threadId ?? message.id); + const latestMessage = demo ? demoMessage : getThreadData?.latest; + + const isMailSelected = useMemo(() => { + if (!threadId || !latestMessage) return false; + const _threadId = latestMessage.threadId ?? message.id; + return _threadId === threadId || threadId === mail.selected; + }, [threadId, message.id, latestMessage, mail.selected]); + + const isMailBulkSelected = mail.bulkSelected.includes(latestMessage?.threadId ?? message.id); const threadLabels = useMemo(() => { - return [...(message.tags || [])]; - }, [message.tags]); + if (!latestMessage) return []; + return [...(latestMessage.tags || [])]; + }, [latestMessage]); const isFolderInbox = folder === FOLDERS.INBOX || !folder; const isFolderSpam = folder === FOLDERS.SPAM; @@ -108,7 +115,7 @@ const Thread = memo( const isFolderBin = folder === FOLDERS.BIN; const handleMouseEnter = () => { - if (demo) return; + if (demo || !latestMessage) return; isHovering.current = true; // Prefetch only in single select mode @@ -121,7 +128,7 @@ const Thread = memo( // Set new timeout for prefetch hoverTimeoutRef.current = setTimeout(() => { if (isHovering.current) { - const messageId = message.threadId ?? message.id; + const messageId = latestMessage.threadId ?? message.id; // Only prefetch if still hovering and hasn't been prefetched console.log( `🕒 Hover threshold reached for email ${messageId}, initiating prefetch...`, @@ -154,15 +161,30 @@ const Thread = memo( }; }, []); - const demoContent =
+ if (isLoading || !latestMessage || !getThreadData) return
+
+
+ +
+ +
+ + +
+ + +
+
+ + const demoContent =
- {message?.sender?.name[0]?.toUpperCase()} + {latestMessage.sender?.name[0]?.toUpperCase()}
@@ -190,39 +212,39 @@ const Thread = memo(

- {highlightText(message.sender.name, searchValue.highlight)} + {highlightText(latestMessage.sender.name, searchValue.highlight)} {' '} - {message.unread && !isMailSelected ? ( + {latestMessage.unread && !isMailSelected ? ( ) : null}

- {message.totalReplies > 1 ? ( + {Math.random() > 0.5 ? ( - {message.totalReplies} + {Math.random() * 10} - {t('common.mail.replies', { count: message.totalReplies })} + {t('common.mail.replies', { count: Math.random() * 10 })} ) : null}
- {message.receivedOn ? ( + {latestMessage.receivedOn ? (

- {formatDate(message.receivedOn.split('.')[0] || '')} + {formatDate(latestMessage.receivedOn.split('.')[0] || '')}

) : null}
@@ -231,7 +253,7 @@ const Thread = memo( 'mt-1 line-clamp-1 text-xs opacity-70 transition-opacity', )} > - {highlightText(message.subject, searchValue.highlight)} + {highlightText(latestMessage.subject, searchValue.highlight)}

@@ -240,15 +262,15 @@ const Thread = memo(
const content = ( -
+
- {message?.sender?.name[0]?.toUpperCase()} + {latestMessage.sender.name[0]?.toUpperCase()}
@@ -276,46 +298,46 @@ const Thread = memo(

- {highlightText(message.sender.name, searchValue.highlight)} + {highlightText(latestMessage.sender.name, searchValue.highlight)} {' '} - {message.unread && !isMailSelected ? ( + {getThreadData.hasUnread && !isMailSelected ? ( ) : null}

- {message.totalReplies > 1 ? ( + {getThreadData.totalReplies > 1 ? ( - {message.totalReplies} + {getThreadData.totalReplies} - {t('common.mail.replies', { count: message.totalReplies })} + {t('common.mail.replies', { count: getThreadData.totalReplies })} ) : null}
- {message.receivedOn ? ( + {latestMessage.receivedOn ? (

- {formatDate(message.receivedOn.split('.')[0] || '')} + {formatDate(latestMessage.receivedOn.split('.')[0] || '')}

) : null}

- {highlightText(message.subject, searchValue.highlight)} + {highlightText(latestMessage.subject, searchValue.highlight)}

@@ -329,7 +351,7 @@ const Thread = memo( return demo ? demoContent : ( () => onSelectMail && onSelectMail(message)} + demoMessage={item as any} /> ) : null; })} @@ -563,7 +586,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => { }, [isKeyPressed]); const handleMailClick = useCallback( - (message: InitialThread) => () => { + (message: ParsedMessage) => () => { handleMouseEnter(message.id); const messageThreadId = message.threadId ?? message.id; diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 5d20e7c34..2fc576c42 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -451,7 +451,7 @@ function BulkSelectActions() { await new Promise((resolve) => setTimeout(resolve, 499)); const emailData = await getMail({ id: bulkSelected }); if (emailData) { - const [firstEmail] = emailData; + const firstEmail = emailData.latest; if (firstEmail) return handleUnsubscribe({ emailData: firstEmail }).catch((e) => { toast.error(e.message ?? 'Unknown error while unsubscribing'); diff --git a/apps/mail/hooks/use-threads.ts b/apps/mail/hooks/use-threads.ts index 0858f11b3..50620abdb 100644 --- a/apps/mail/hooks/use-threads.ts +++ b/apps/mail/hooks/use-threads.ts @@ -1,14 +1,15 @@ 'use client'; import { useParams, useSearchParams } from 'next/navigation'; +import { IGetThreadResponse } from '@/app/api/driver/types'; import type { InitialThread, ParsedMessage } from '@/types'; import { useSearchValue } from '@/hooks/use-search-value'; import { useSession } from '@/lib/auth-client'; import { defaultPageSize } from '@/lib/utils'; import useSWRInfinite from 'swr/infinite'; import useSWR, { preload } from 'swr'; +import { useQueryState } from 'nuqs'; import { useMemo } from 'react'; import axios from 'axios'; -import { useQueryState } from 'nuqs'; export const preloadThread = async (userId: string, threadId: string, connectionId: string) => { console.log(`🔄 Prefetching email ${threadId}...`); @@ -51,7 +52,7 @@ const fetchEmails = async ([ const fetchThread = async (args: any[]) => { const [_, id] = args; try { - const response = await axios.get(`/api/driver/${id}`); + const response = await axios.get(`/api/driver/${id}`); return response.data; } catch (error) { console.error('Error fetching email:', error); @@ -125,14 +126,14 @@ export const useThreads = () => { export const useThread = (threadId: string | null) => { const { data: session } = useSession(); const [_threadId] = useQueryState('threadId'); - const id = threadId ? threadId : _threadId + const id = threadId ? threadId : _threadId; - const { data, isLoading, error, mutate } = useSWR( + const { data, isLoading, error, mutate } = useSWR( session?.user.id && id ? [session.user.id, id, session.connectionId] : null, fetchThread, ); - const hasUnread = useMemo(() => data?.some((e) => e.unread), [data]); + const hasUnread = useMemo(() => data?.messages.some((e) => e.unread), [data]); return { data, isLoading, error, hasUnread, mutate }; }; diff --git a/apps/mail/types/index.ts b/apps/mail/types/index.ts index 281461a3e..84bb93804 100644 --- a/apps/mail/types/index.ts +++ b/apps/mail/types/index.ts @@ -70,16 +70,6 @@ export interface IConnection { export interface InitialThread { id: string; - threadId?: string; - title: string; - tags: string[]; - sender: Sender; - receivedOn: string; - unread: boolean; - subject: string; - totalReplies: number; - references?: string; - inReplyTo?: string; } export interface Attachment { @@ -95,19 +85,20 @@ export interface MailListProps { isCompact?: boolean; } -export type MailSelectMode = "mass" | "range" | "single" | "selectAllBelow"; +export type MailSelectMode = 'mass' | 'range' | 'single' | 'selectAllBelow'; export type ThreadProps = { - message: InitialThread; + message: { id: string }; selectMode: MailSelectMode; // TODO: enforce types instead of sprinkling "any" - onClick?: (message: InitialThread) => () => void; + onClick?: (message: ParsedMessage) => () => void; isCompact?: boolean; folder?: string; isKeyboardFocused?: boolean; isInQuickActionMode?: boolean; selectedQuickActionIndex?: number; resetNavigation?: () => void; + demoMessage?: ParsedMessage; }; export type ConditionalThreadProps = ThreadProps & @@ -116,15 +107,13 @@ export type ConditionalThreadProps = ThreadProps & | { demo?: false; sessionData: { userId: string; connectionId: string | null } } ); - - export interface IOutgoingMessage { to: Sender[]; cc?: Sender[]; bcc?: Sender[]; - subject: string - message: string - attachments: any[] - headers: Record - threadId?: string -} \ No newline at end of file + subject: string; + message: string; + attachments: any[]; + headers: Record; + threadId?: string; +}