import { useReducer, useCallback, useEffect, useRef } from 'react'; import { useSession } from 'next-auth/react'; import { useToast } from './use-toast'; import { emailReducer, initialState, EmailState, EmailAction, normalizeFolderAndAccount, Account } from '@/lib/reducers/emailReducer'; import { getCachedEmailsWithTimeout, refreshEmailsInBackground } from '@/lib/services/prefetch-service'; import { Email, EmailData } from './use-courrier'; import { formatEmailForReplyOrForward } from '@/lib/utils/email-utils'; // Add a global dispatcher for compatibility with older code // This is a temporary solution until we fully migrate to the reducer pattern declare global { interface Window { dispatchEmailAction?: (action: EmailAction) => void; __emailStateDispatch?: (action: EmailAction) => void; } } export const useEmailState = () => { const [state, dispatch] = useReducer(emailReducer, initialState); const { data: session } = useSession(); const { toast } = useToast(); // Refs to track state const updateUnreadTimerRef = useRef(null); const lastEmailViewedRef = useRef(null); const failedFetchCountRef = useRef(0); const lastFolderRef = useRef(null); const lastPageLoadedRef = useRef(0); const prevFolderRef = useRef(null); const loadMoreTriggerTimeRef = useRef(0); // Expose dispatch function to window for external components useEffect(() => { // Make dispatch available globally for older code window.dispatchEmailAction = dispatch; window.__emailStateDispatch = dispatch; // Clean up on unmount return () => { window.dispatchEmailAction = undefined; window.__emailStateDispatch = undefined; }; }, [dispatch]); // Helper function to log operations const logEmailOp = useCallback((operation: string, details: string, data?: any) => { const timestamp = new Date().toISOString().split('T')[1].substring(0, 12); console.log(`[${timestamp}][EMAIL-STATE][${operation}] ${details}`); if (data) { console.log(`[${timestamp}][EMAIL-STATE][DATA]`, data); } }, []); // Load emails from the server const loadEmails = useCallback(async (page: number, perPage: number, isLoadMore: boolean = false) => { // CRITICAL FIX: Do important validation before setting loading state if (!session?.user?.id) return; // CRITICAL FIX: Always log the isLoadMore parameter console.log(`[DEBUG-LOAD_EMAILS] Called with isLoadMore=${isLoadMore}, page=${page}, currentEmails=${state.emails.length}`); // Set the current folder and account being loaded to detect changes const startFolder = state.currentFolder; const startAccount = state.selectedAccount ? state.selectedAccount.id : 'default'; // CRITICAL FIX: Force loading state to true dispatch({ type: 'SET_LOADING', payload: true }); try { // Get normalized parameters using helper function with proper account ID handling const accountId = state.selectedAccount ? state.selectedAccount.id : undefined; const { normalizedFolder, effectiveAccountId, prefixedFolder } = normalizeFolderAndAccount(state.currentFolder, accountId); logEmailOp('LOAD_EMAILS', `Loading emails for ${prefixedFolder} (account: ${effectiveAccountId}, isLoadMore: ${isLoadMore}, page: ${page})`); // Construct query parameters const queryParams = new URLSearchParams({ folder: normalizedFolder, page: page.toString(), perPage: perPage.toString(), accountId: effectiveAccountId }); // Debug log existing emails count if (isLoadMore) { console.log(`[DEBUG-PAGINATION] Loading more emails. Current page: ${page}, existing emails: ${state.emails.length}`); } // Try to get cached emails first logEmailOp('CACHE_CHECK', `Checking cache for ${prefixedFolder}, page: ${page}`); const cachedEmails = await getCachedEmailsWithTimeout( session.user.id, prefixedFolder, page, perPage, 100, effectiveAccountId ); if (cachedEmails) { logEmailOp('CACHE_HIT', `Using cached data for ${prefixedFolder}, page: ${page}, emails: ${cachedEmails.emails?.length || 0}, isLoadMore: ${isLoadMore}`); // Ensure cached data has emails array property if (Array.isArray(cachedEmails.emails)) { // CRITICAL FIX: Double check we're using the right action type based on isLoadMore param console.log(`[DEBUG-CACHE_HIT] Dispatching ${isLoadMore ? 'APPEND_EMAILS' : 'SET_EMAILS'} with ${cachedEmails.emails.length} emails`); // Dispatch appropriate action based on if we're loading more - DO NOT OVERRIDE isLoadMore! dispatch({ type: isLoadMore ? 'APPEND_EMAILS' : 'SET_EMAILS', payload: cachedEmails.emails }); // Set pagination info from cache if available if (cachedEmails.totalEmails) { dispatch({ type: 'SET_TOTAL_EMAILS', payload: cachedEmails.totalEmails }); } if (cachedEmails.totalPages) { dispatch({ type: 'SET_TOTAL_PAGES', payload: cachedEmails.totalPages }); } // Update available mailboxes if provided if (cachedEmails.mailboxes && cachedEmails.mailboxes.length > 0) { dispatch({ type: 'SET_MAILBOXES', payload: cachedEmails.mailboxes }); } } // CRITICAL FIX: If this was a loadMore operation, check the result after the dispatch if (isLoadMore) { setTimeout(() => { console.log(`[DEBUG-CACHE_HIT_APPEND] After ${isLoadMore ? 'APPEND' : 'SET'}, email count is now: ${state.emails.length}`); }, 0); } return; } // Fetch emails from API if no cache hit logEmailOp('API_FETCH', `Fetching emails from API: ${queryParams.toString()}, isLoadMore: ${isLoadMore}`); console.log(`[DEBUG-API_FETCH] Fetching from /api/courrier/emails?${queryParams.toString()}`); const response = await fetch(`/api/courrier/emails?${queryParams.toString()}`); if (!response.ok) { // CRITICAL FIX: Try to recover from fetch errors by retrying with different pagination if (isLoadMore && page > 1) { logEmailOp('ERROR_RECOVERY', `Failed to fetch emails for page ${page}, attempting to recover by decrementing page`); console.log(`[DEBUG-ERROR] API returned ${response.status} for page ${page}`); // If we're loading more and there's an error, just decrement the page to avoid getting stuck dispatch({ type: 'SET_PAGE', payload: page - 1 }); dispatch({ type: 'SET_LOADING', payload: false }); // Also reset total pages to try again dispatch({ type: 'SET_TOTAL_PAGES', payload: page }); return; } const errorData = await response.json(); throw new Error(errorData.error || 'Failed to fetch emails'); } const data = await response.json(); console.log(`[DEBUG-API_RESPONSE] Got response with ${data.emails?.length || 0} emails, totalPages: ${data.totalPages}, totalEmails: ${data.totalEmails}, isLoadMore: ${isLoadMore}`); // CRITICAL FIX: Enhanced empty results handling if (!data.emails || data.emails.length === 0) { console.log(`[DEBUG-EMPTY] No emails in response for page ${page}`); // If we're at a page > 1 and got no results, the paging is off, so try again with page 1 if (page > 1 && !isLoadMore) { logEmailOp('EMPTY_RESULTS', `No emails returned for page ${page}, resetting to page 1`); dispatch({ type: 'SET_PAGE', payload: 1 }); dispatch({ type: 'SET_LOADING', payload: false }); return; } // If we're already at page 1, just update the state with no emails if (!isLoadMore) { logEmailOp('EMPTY_RESULTS', `No emails found in ${state.currentFolder}`); dispatch({ type: 'SET_EMAILS', payload: [] }); dispatch({ type: 'SET_TOTAL_EMAILS', payload: 0 }); dispatch({ type: 'SET_TOTAL_PAGES', payload: 0 }); } else { // For load more, just set loading to false but keep existing emails dispatch({ type: 'SET_LOADING', payload: false }); } return; } // Ensure all emails have proper account ID and folder format if (Array.isArray(data.emails)) { // Log email dates for debugging if (data.emails.length > 0) { logEmailOp('EMAIL_DATES', `First few email dates before processing:`, data.emails.slice(0, 5).map((e: any) => ({ id: e.id.substring(0, 8), subject: e.subject?.substring(0, 20), date: e.date, dateObj: new Date(e.date), timestamp: new Date(e.date).getTime() })) ); } data.emails.forEach((email: Email) => { // If email doesn't have an accountId, set it to the effective one if (!email.accountId) { email.accountId = effectiveAccountId; } // Ensure folder has the proper prefix format if (email.folder && !email.folder.includes(':')) { email.folder = `${email.accountId}:${email.folder}`; } // Ensure date is a valid Date object (handle strings or timestamps) if (email.date && !(email.date instanceof Date)) { try { // Convert to a proper Date object if it's a string or number const dateObj = new Date(email.date); // Verify it's a valid date if (!isNaN(dateObj.getTime())) { email.date = dateObj; } } catch (err) { // If conversion fails, log and use current date as fallback console.error(`Invalid date format for email ${email.id}: ${email.date}`); email.date = new Date(); } } }); } // CRITICAL FIX: Log what we're about to do console.log(`[DEBUG-DISPATCH] About to dispatch ${isLoadMore ? 'APPEND_EMAILS' : 'SET_EMAILS'} with ${data.emails?.length || 0} emails`); // Update state with fetched data dispatch({ type: isLoadMore ? 'APPEND_EMAILS' : 'SET_EMAILS', payload: Array.isArray(data.emails) ? data.emails : [] }); // Double-check that we've updated the email list correctly after dispatch setTimeout(() => { console.log(`[DEBUG-AFTER-DISPATCH] Email count is now: ${state.emails.length}, should include the ${data.emails?.length || 0} new emails we just loaded`); }, 0); if (data.totalEmails) { dispatch({ type: 'SET_TOTAL_EMAILS', payload: data.totalEmails }); } if (data.totalPages) { dispatch({ type: 'SET_TOTAL_PAGES', payload: data.totalPages }); } // Update available mailboxes if provided if (data.mailboxes && data.mailboxes.length > 0) { dispatch({ type: 'SET_MAILBOXES', payload: data.mailboxes }); } } catch (err) { logEmailOp('ERROR', `Failed to load emails: ${err instanceof Error ? err.message : String(err)}`); dispatch({ type: 'SET_ERROR', payload: err instanceof Error ? err.message : 'Failed to load emails' }); toast({ variant: "destructive", title: "Error", description: err instanceof Error ? err.message : 'Failed to load emails' }); } finally { // CRITICAL FIX: Only clear loading state if the folder/account hasn't changed if (startFolder === state.currentFolder && (startAccount === (state.selectedAccount?.id || 'default'))) { // Safe to clear loading state dispatch({ type: 'SET_LOADING', payload: false }); } else { console.log(`[DEBUG-LOAD_EMAILS] Folder/account changed during load, not clearing loading state`); } } }, [session?.user?.id, state.currentFolder, state.selectedAccount, state.page, state.perPage, state.emails.length, toast, logEmailOp]); // Change folder const changeFolder = useCallback(async (folder: string, accountId?: string) => { logEmailOp('CHANGE_FOLDER', `Changing to folder ${folder} with account ${accountId || 'default'}`); try { // CRITICAL FIX: Reset pagination state immediately lastPageLoadedRef.current = 0; // Reset page to 1 directly to prevent any issues with page effects // This will be atomic with the CHANGE_FOLDER action dispatch({ type: 'SET_PAGE', payload: 1 }); // Clear existing emails - don't show old emails during load dispatch({ type: 'SET_EMAILS', payload: [] }); // Set loading state explicitly - this is critical dispatch({ type: 'SET_LOADING', payload: true }); // This will handle folder setting in a single atomic operation dispatch({ type: 'CHANGE_FOLDER', payload: { folder, accountId: accountId || 'default' } }); // CRITICAL: The email loading will be triggered by the folder effect // We don't need to call loadEmails directly here } catch (error) { logEmailOp('ERROR', `Failed to change folder: ${error instanceof Error ? error.message : String(error)}`); dispatch({ type: 'SET_ERROR', payload: error instanceof Error ? error.message : 'Failed to change folder' }); // Always ensure loading state is cleared on error dispatch({ type: 'SET_LOADING', payload: false }); } }, [logEmailOp, dispatch]); // Select account const selectAccount = useCallback((account: Account) => { logEmailOp('SELECT_ACCOUNT', `Selecting account: ${account.email} (${account.id})`); // Handle the entire account selection in a single atomic operation dispatch({ type: 'SELECT_ACCOUNT', payload: account }); // The folder loading will be triggered by the useEffect watching for currentFolder changes }, [logEmailOp]); // Handle email selection const handleEmailSelect = useCallback(async (emailId: string, accountId: string, folder: string) => { logEmailOp('SELECT_EMAIL', `Selecting email ${emailId} from account ${accountId} in folder ${folder}`); if (!emailId) { dispatch({ type: 'SELECT_EMAIL', payload: { emailId: '', accountId: '', folder: '', email: null } }); return; } try { // Find the email in the current list const existingEmail = state.emails.find(e => e.id === emailId); if (existingEmail && existingEmail.contentFetched) { // Use the existing email if it has content already dispatch({ type: 'SELECT_EMAIL', payload: { emailId, accountId, folder, email: existingEmail } }); // Mark as read if not already if (!existingEmail.flags.seen) { markEmailAsRead(emailId, true, accountId); } return; } // Need to fetch the email content dispatch({ type: 'SET_LOADING', payload: true }); // Extract account ID from folder name if present and none was explicitly provided const { normalizedFolder, effectiveAccountId } = normalizeFolderAndAccount(folder, accountId); // Fetch email content from API const response = await fetch(`/api/courrier/${emailId}?folder=${normalizedFolder}&accountId=${effectiveAccountId}`); if (!response.ok) { throw new Error(`Failed to fetch email content: ${response.status}`); } const emailData = await response.json(); // Mark the email as read on the server markEmailAsRead(emailId, true, effectiveAccountId); // Select the email dispatch({ type: 'SELECT_EMAIL', payload: { emailId, accountId: effectiveAccountId, folder, email: emailData } }); } catch (error) { logEmailOp('ERROR', `Failed to select email: ${error instanceof Error ? error.message : String(error)}`); dispatch({ type: 'SET_ERROR', payload: error instanceof Error ? error.message : 'Failed to select email' }); } finally { dispatch({ type: 'SET_LOADING', payload: false }); } }, [state.emails, logEmailOp]); // Toggle email selection for multi-select const toggleEmailSelection = useCallback((emailId: string) => { dispatch({ type: 'TOGGLE_EMAIL_SELECTION', payload: emailId }); }, []); // Toggle select all const toggleSelectAll = useCallback(() => { dispatch({ type: 'TOGGLE_SELECT_ALL' }); }, []); // Mark email as read/unread const markEmailAsRead = useCallback(async (emailId: string, isRead: boolean, accountId?: string) => { try { // Find the email to get its account ID if not provided const email = state.emails.find(e => e.id === emailId); const effectiveAccountId = accountId || email?.accountId || 'default'; const folder = email?.folder || state.currentFolder; // Extract normalized folder const { normalizedFolder } = normalizeFolderAndAccount(folder, effectiveAccountId); logEmailOp('MARK_READ', `Marking email ${emailId} as ${isRead ? 'read' : 'unread'} in ${normalizedFolder}`); // Update UI state immediately (optimistic update) dispatch({ type: 'MARK_EMAIL_AS_READ', payload: { emailId, isRead, accountId: effectiveAccountId } }); // NOTE: Don't update unread counts here - that's now handled by the updateUnreadCounts function // which is triggered by the email update above via the useEffect // Make API call to update on server const response = await fetch(`/api/courrier/${emailId}/mark-read`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isRead, folder: normalizedFolder, accountId: effectiveAccountId }) }); if (!response.ok) { throw new Error('Failed to mark email as read'); } return true; } catch (error) { logEmailOp('ERROR', `Failed to mark email as read: ${error instanceof Error ? error.message : String(error)}`); toast({ variant: "destructive", title: "Error", description: 'Failed to update email read status' }); return false; } }, [state.emails, state.currentFolder, toast, logEmailOp]); // Toggle starred status const toggleStarred = useCallback(async (emailId: string) => { try { // Find the email in current list const email = state.emails.find(e => e.id === emailId); if (!email) { throw new Error('Email not found'); } const newFlaggedStatus = !email.flags.flagged; logEmailOp('TOGGLE_STAR', `Setting starred status to ${newFlaggedStatus} for email ${emailId}`); // TODO: Implement optimistic update // Make API call const response = await fetch(`/api/courrier/${emailId}/flag`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ flagged: newFlaggedStatus, folder: email.folder, accountId: email.accountId }) }); if (!response.ok) { throw new Error('Failed to update star status'); } // Reload emails to get updated state loadEmails(state.page, state.perPage, true); return true; } catch (error) { logEmailOp('ERROR', `Failed to toggle star: ${error instanceof Error ? error.message : String(error)}`); toast({ variant: "destructive", title: "Error", description: 'Failed to update star status' }); return false; } }, [state.emails, toast, loadEmails, logEmailOp]); // Function to check for new emails without disrupting the user const checkForNewEmails = useCallback(async () => { if (!session?.user?.id) return; // Don't check if already loading emails if (state.isLoading) return; try { // Get normalized parameters using helper function const accountId = state.selectedAccount ? state.selectedAccount.id : undefined; const { normalizedFolder, effectiveAccountId, prefixedFolder } = normalizeFolderAndAccount(state.currentFolder, accountId); logEmailOp('CHECK_NEW_EMAILS', `Checking for new emails in ${prefixedFolder}`); // Quietly check for new emails with a special parameter const queryParams = new URLSearchParams({ folder: normalizedFolder, page: '1', perPage: '1', // We only need to check the newest email accountId: effectiveAccountId, checkOnly: 'true' // Special parameter to indicate this is just a check }); const response = await fetch(`/api/courrier/emails?${queryParams.toString()}`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, cache: 'no-cache' }); if (!response.ok) { throw new Error(`Failed to check for new emails: ${response.status}`); } const data = await response.json(); // Store the latest email's ID for easier reference const lastKnownEmailId = state.emails.length > 0 ? parseInt(state.emails[0].id) : 0; // Use newestEmailId from API response (more reliable than checking emails array) if (data.newestEmailId && data.newestEmailId > lastKnownEmailId) { logEmailOp('NEW_EMAILS', `Found new emails, newest ID: ${data.newestEmailId} (current: ${lastKnownEmailId})`); // Show a toast notification with the new custom variant toast({ variant: "new-email", // Use our new custom variant title: "New emails", description: "You have new emails in your inbox", duration: 5000 }); // Full refresh just like the refresh button in sidebar // Reset to page 1 to ensure we get the newest emails dispatch({ type: 'SET_PAGE', payload: 1 }); loadEmails(1, state.perPage, false); // Also update unread counts - this will be handled in the effect // The fetchUnreadCounts function will be available when this callback is called } else { logEmailOp('CHECK_NEW_EMAILS', 'No new emails found'); } } catch (error) { console.error('Error checking for new emails:', error); } }, [session?.user?.id, state.currentFolder, state.isLoading, state.emails, state.perPage, toast, loadEmails, logEmailOp, dispatch]); // Delete emails const deleteEmails = useCallback(async (emailIds: string[]) => { if (emailIds.length === 0) return; dispatch({ type: 'SET_LOADING', payload: true }); try { logEmailOp('DELETE', `Deleting ${emailIds.length} emails`); // Find the first email to get account ID and folder const firstEmail = state.emails.find(e => e.id === emailIds[0]); const accountId = firstEmail?.accountId || 'default'; const folder = firstEmail?.folder || state.currentFolder; const { normalizedFolder } = normalizeFolderAndAccount(folder, accountId); // Make API call to delete emails const response = await fetch('/api/courrier/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ emailIds, folder: normalizedFolder, accountId }) }); if (!response.ok) { throw new Error('Failed to delete emails'); } // Clear selections dispatch({ type: 'CLEAR_SELECTED_EMAILS' }); // Show toast notification toast({ title: "Emails Deleted", description: `${emailIds.length} email(s) moved to trash` }); // Full refresh just like the refresh button in sidebar // Reset to page 1 to ensure we get the updated email list dispatch({ type: 'SET_PAGE', payload: 1 }); loadEmails(1, state.perPage, false); // Also update unread counts - this will be handled in the effect // The fetchUnreadCounts function will be available when this callback is called return true; } catch (error) { logEmailOp('ERROR', `Failed to delete emails: ${error instanceof Error ? error.message : String(error)}`); toast({ variant: "destructive", title: "Error", description: 'Failed to delete emails' }); return false; } finally { dispatch({ type: 'SET_LOADING', payload: false }); } }, [state.emails, state.currentFolder, state.perPage, toast, loadEmails, logEmailOp, dispatch]); // Send email const sendEmail = useCallback(async (emailData: EmailData) => { dispatch({ type: 'SET_LOADING', payload: true }); try { logEmailOp('SEND', `Sending email to ${emailData.to}`); // Make API call to send email const response = await fetch('/api/courrier/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(emailData) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to send email'); } const result = await response.json(); toast({ title: "Email Sent", description: "Your message has been sent successfully" }); // Wait a moment for the email to be available in the sent folder // (emails may need time to be stored on IMAP server) setTimeout(() => { // Check for new emails and refresh mailbox checkForNewEmails(); // Refresh emails to show the sent email in current view loadEmails(state.page, state.perPage, false); }, 1500); return { success: true, ...result }; } catch (error) { logEmailOp('ERROR', `Failed to send email: ${error instanceof Error ? error.message : String(error)}`); toast({ variant: "destructive", title: "Error", description: error instanceof Error ? error.message : 'Failed to send email' }); return { success: false, error: error instanceof Error ? error.message : 'Failed to send email' }; } finally { dispatch({ type: 'SET_LOADING', payload: false }); } }, [toast, loadEmails, logEmailOp, checkForNewEmails]); // Search emails const searchEmails = useCallback(async (query: string) => { // Set loading state dispatch({ type: 'SET_LOADING', payload: true }); try { if (!session?.user?.id) return; logEmailOp('SEARCH', `Searching for "${query}" in ${state.currentFolder}`); // Extract account ID from current folder const { normalizedFolder, effectiveAccountId } = normalizeFolderAndAccount(state.currentFolder); // Construct query params for search const queryParams = new URLSearchParams({ folder: normalizedFolder, search: query, accountId: effectiveAccountId }); // Call API for search const response = await fetch(`/api/courrier/search?${queryParams.toString()}`); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to search emails'); } const results = await response.json(); // Update emails with search results dispatch({ type: 'SET_EMAILS', payload: results.emails || [] }); if (results.totalEmails) { dispatch({ type: 'SET_TOTAL_EMAILS', payload: results.totalEmails }); } if (results.totalPages) { dispatch({ type: 'SET_TOTAL_PAGES', payload: results.totalPages }); } } catch (error) { logEmailOp('ERROR', `Search failed: ${error instanceof Error ? error.message : String(error)}`); dispatch({ type: 'SET_ERROR', payload: error instanceof Error ? error.message : 'Failed to search emails' }); toast({ variant: "destructive", title: "Error", description: 'Failed to search emails' }); } finally { dispatch({ type: 'SET_LOADING', payload: false }); } }, [session?.user?.id, state.currentFolder, toast, logEmailOp]); // Format email for reply, reply all, or forward const formatEmailForAction = useCallback((email: any, type: 'reply' | 'reply-all' | 'forward') => { return formatEmailForReplyOrForward(email, type); }, []); // Update page const setPage = useCallback((page: number) => { dispatch({ type: 'SET_PAGE', payload: page }); }, []); // Set emails directly const setEmails = useCallback((emails: Email[]) => { dispatch({ type: 'SET_EMAILS', payload: emails }); }, []); // Handle loading more emails const handleLoadMore = useCallback(() => { // Don't load more if already loading or if there are no more pages if (state.isLoading || state.page >= state.totalPages) { console.log(`[LOAD_MORE] Skipping load more - already loading: ${state.isLoading}, page: ${state.page}, totalPages: ${state.totalPages}`); return; } // Log the current state console.log(`[LOAD_MORE] Loading more emails for ${state.currentFolder}, currentPage: ${state.page}, totalPages: ${state.totalPages}, current email count: ${state.emails.length}`); // Set loading state immediately to prevent double-loading dispatch({ type: 'SET_LOADING', payload: true }); // Calculate next page const nextPage = state.page + 1; // Update the page state - fix type issue dispatch({ type: 'SET_PAGE', payload: nextPage }); // CRITICAL FIX: Update the lastLoadedPage ref to track pagination state lastPageLoadedRef.current = nextPage; // Load the next page loadEmails(nextPage, state.perPage, true).then(() => { console.log(`[LOAD_MORE] Completed loading more emails for page ${nextPage}`); }); }, [state.isLoading, state.page, state.totalPages, state.currentFolder, state.emails.length, state.perPage, dispatch, loadEmails]); // Effect to load emails when folder changes useEffect(() => { if (session?.user?.id && state.currentFolder) { // CRITICAL FIX: REMOVE this check that's causing the problem // Instead, detect a real folder change and always load when that happens // Extract account ID for consistent loading const { effectiveAccountId } = normalizeFolderAndAccount(state.currentFolder); // Track if the folder actually changed const folderChanged = prevFolderRef.current !== state.currentFolder; if (folderChanged) { console.log(`[DEBUG-FOLDER_EFFECT] Folder changed from ${prevFolderRef.current} to ${state.currentFolder}`); prevFolderRef.current = state.currentFolder; // CRITICAL FIX: Always reset pagination state when folder actually changes console.log(`[DEBUG-FOLDER_EFFECT] Folder changed - resetting pagination state`); // Reset page to 1 AND reset lastPageLoadedRef to ensure we load lastPageLoadedRef.current = 0; if (state.page !== 1) { console.log(`[DEBUG-FOLDER_EFFECT] Resetting page to 1 because folder changed`); dispatch({ type: 'SET_PAGE', payload: 1 }); } // CRITICAL FIX: Clear emails and set loading when folder changes dispatch({ type: 'SET_EMAILS', payload: [] }); dispatch({ type: 'SET_LOADING', payload: true }); // CRITICAL FIX: Always load emails when folder changes, no matter what console.log(`[DEBUG-FOLDER_EFFECT] Loading emails for new folder: ${state.currentFolder}`); loadEmails(1, state.perPage, false); return; // Exit early after handling folder change } // If no folder change detected, only load if on page 1 and not already loaded if (state.page === 1 && lastPageLoadedRef.current === 0) { logEmailOp('FOLDER_LOAD', `Loading initial emails for folder ${state.currentFolder}`); loadEmails(state.page, state.perPage, false); } } }, [session?.user?.id, state.currentFolder, state.page, state.perPage, loadEmails, logEmailOp, dispatch]); // Effect to load more emails when page changes useEffect(() => { if (!session?.user?.id || !state.currentFolder) return; // Make sure we're on at least page 1 if (state.page < 1) { dispatch({ type: 'SET_PAGE', payload: 1 }); return; } console.log(`[DEBUG-PAGE_EFFECT] Page changed to ${state.page}`); // CRITICAL FIX: Add a special case for page 1 loads - we should never skip loading the first page // This ensures that after a folder change, page 1 always loads even if loading state is true if (state.page === 1) { const currentFolder = state.currentFolder; const lastLoadedFolder = prevFolderRef.current; // Check if this is a fresh folder load (folder changed or first time loading) if (currentFolder !== lastLoadedFolder || lastPageLoadedRef.current === 0) { // Force loading page 1 for new folders, regardless of loading state console.log(`[DEBUG-PAGE_EFFECT] Force loading page 1 for folder: ${currentFolder}`); // Set the loading state explicitly (might already be true) dispatch({ type: 'SET_LOADING', payload: true }); // Update refs to track the current state prevFolderRef.current = currentFolder; lastPageLoadedRef.current = 1; // Call loadEmails to load the first page - never skip this! loadEmails(1, state.perPage, false); return; } } // For pages > 1 or already loaded folders, follow normal rules // Skip if already loading if (state.isLoading) { console.log(`[DEBUG-PAGE_EFFECT] Skipping effect execution entirely - already loading`); return; } // Normalize folder and get account ID const { effectiveAccountId } = normalizeFolderAndAccount(state.currentFolder); // Check if this is a duplicate page load if (state.page === lastPageLoadedRef.current) { console.log(`[DEBUG-PAGE_EFFECT] Skipping - already loaded page ${state.page}`); return; } // Skip loads for zero-based pages if (state.page === 0) { console.log(`[DEBUG-PAGE_EFFECT] Skipping load for invalid page ${state.page}`); return; } // Update our reference to prevent duplicate loads lastPageLoadedRef.current = state.page; // Always use isLoadMore=true when page > 1 console.log(`[DEBUG-PAGE_EFFECT] Calling loadEmails with isLoadMore=true for page ${state.page}`); loadEmails(state.page, state.perPage, true); // Do NOT include state.emails.length here to prevent infinite loops }, [session?.user?.id, state.page, state.currentFolder, state.isLoading, state.perPage, loadEmails, logEmailOp, dispatch]); // Fetch unread counts from API const fetchUnreadCounts = useCallback(async () => { if (!session?.user) return; // Don't fetch if we're already fetching if (state.isLoadingUnreadCounts) return; // Skip fetching if an email was viewed recently (within last 5 seconds) const now = Date.now(); // Initialize the ref to the current time if it's null if (lastEmailViewedRef.current === null) { lastEmailViewedRef.current = now; } // Now we can safely use it since we've initialized it if (now - lastEmailViewedRef.current < 5000) { console.log('Skipping unread count update - email viewed recently'); return; } // Try to get from sessionStorage first for faster response try { const storageKey = `unread_counts_${session.user.id}`; const storedData = sessionStorage.getItem(storageKey); if (storedData) { const { data, timestamp } = JSON.parse(storedData); // Use stored data if it's less than 30 seconds old if (now - timestamp < 30000) { logEmailOp('FETCH_UNREAD', 'Using sessionStorage data', { age: Math.round((now - timestamp)/1000) + 's' }); dispatch({ type: 'SET_UNREAD_COUNTS', payload: data }); return; } } } catch (err) { // Ignore storage errors } // Reset failure tracking if it's been more than 1 minute since last failure if ((window as any).__unreadCountFailures?.lastFailureTime && now - (window as any).__unreadCountFailures.lastFailureTime > 60000) { (window as any).__unreadCountFailures = { count: 0, lastFailureTime: 0 }; } // Exponential backoff for failures with proper tracking object if (!(window as any).__unreadCountFailures) { (window as any).__unreadCountFailures = { count: 0, lastFailureTime: 0 }; } if ((window as any).__unreadCountFailures.count > 0) { const failures = (window as any).__unreadCountFailures.count; const backoffMs = Math.min(30000, 1000 * Math.pow(2, failures - 1)); if (now - (window as any).__unreadCountFailures.lastFailureTime < backoffMs) { logEmailOp('BACKOFF', `Skipping unread fetch, in backoff period (${backoffMs}ms)`); return; } } try { dispatch({ type: 'SET_LOADING_UNREAD_COUNTS', payload: true }); const timeBeforeCall = performance.now(); logEmailOp('FETCH_UNREAD', 'Fetching unread counts from API'); const response = await fetch('/api/courrier/unread-counts', { method: 'GET', headers: { 'Content-Type': 'application/json' }, // Add cache control headers cache: 'no-cache', next: { revalidate: 0 } }); if (!response.ok) { // If request failed, track failures properly (window as any).__unreadCountFailures.count = Math.min((window as any).__unreadCountFailures.count + 1, 10); (window as any).__unreadCountFailures.lastFailureTime = now; const failures = (window as any).__unreadCountFailures.count; if (failures > 3) { // After 3 failures, slow down requests with exponential backoff const backoffTime = Math.min(Math.pow(2, failures - 3) * 1000, 30000); // Max 30 seconds logEmailOp('FETCH_UNREAD', `API failure #${failures}, backing off for ${backoffTime}ms`); // Schedule next attempt with backoff if ((window as any).__failureBackoffTimer) { clearTimeout((window as any).__failureBackoffTimer); } (window as any).__failureBackoffTimer = setTimeout(() => { fetchUnreadCounts(); }, backoffTime); throw new Error(`Failed to fetch unread counts: ${response.status}`); } } else { // Reset failure counter on success (window as any).__unreadCountFailures = { count: 0, lastFailureTime: 0 }; const data = await response.json(); const timeAfterCall = performance.now(); // Skip if we got the "pending_refresh" status if (data._status === 'pending_refresh') { logEmailOp('FETCH_UNREAD', 'Server is refreshing counts, will try again soon'); // Retry after a short delay setTimeout(() => { fetchUnreadCounts(); }, 2000); return; } logEmailOp('FETCH_UNREAD', `Received unread counts in ${(timeAfterCall - timeBeforeCall).toFixed(2)}ms`); if (data && typeof data === 'object') { dispatch({ type: 'SET_UNREAD_COUNTS', payload: data }); // Store in sessionStorage for faster future access try { sessionStorage.setItem( `unread_counts_${session.user.id}`, JSON.stringify({ data, timestamp: now }) ); } catch (err) { // Ignore storage errors } } } } catch (error) { console.error('Error fetching unread counts:', error); } finally { dispatch({ type: 'SET_LOADING_UNREAD_COUNTS', payload: false }); } }, [dispatch, session?.user, state.isLoadingUnreadCounts, logEmailOp]); // Calculate and update unread counts const updateUnreadCounts = useCallback(() => { // Skip if no emails or accounts if (state.emails.length === 0 || state.accounts.length === 0) return; // To avoid running this too frequently, check the timestamp of last update if (!(window as any).__lastUnreadUpdate) { (window as any).__lastUnreadUpdate = { timestamp: 0 }; } const now = Date.now(); const lastUpdate = (window as any).__lastUnreadUpdate; const MIN_UPDATE_INTERVAL = 10000; // 10 seconds minimum between updates (increased from 2s) if (now - lastUpdate.timestamp < MIN_UPDATE_INTERVAL) { return; // Skip if updated too recently } // Rather than calculating locally, fetch from the API fetchUnreadCounts(); // Update timestamp of last update lastUpdate.timestamp = now; }, [state.emails.length, state.accounts.length, fetchUnreadCounts]); // Call updateUnreadCounts when relevant state changes useEffect(() => { if (!state.emails || state.emails.length === 0) return; // Debounce unread count updates to prevent rapid multiple updates let updateTimeoutId: ReturnType; const debounceMs = 5000; // Increase debounce to 5 seconds (from 2s) // Function to call after debounce period const debouncedUpdate = () => { updateTimeoutId = setTimeout(() => { updateUnreadCounts(); }, debounceMs); }; // Clear any existing timeout and start a new one debouncedUpdate(); // Also set up a periodic refresh every minute if the tab is active const periodicRefreshId = setInterval(() => { if (document.visibilityState === 'visible') { updateUnreadCounts(); } }, 60000); // 1 minute // Cleanup timeout on unmount or state change return () => { clearTimeout(updateTimeoutId); clearInterval(periodicRefreshId); }; // Deliberately exclude unreadCountMap to prevent infinite loops }, [state.emails, updateUnreadCounts]); // Set up periodic check for new emails useEffect(() => { if (!state.emails || state.emails.length === 0) return; // Set up a periodic check for new emails at the same interval as unread counts const checkNewEmailsId = setInterval(() => { if (document.visibilityState === 'visible') { checkForNewEmails(); } }, 60000); // 1 minute - same as unread count refresh // Cleanup interval on unmount or state change return () => { clearInterval(checkNewEmailsId); }; }, [state.emails, checkForNewEmails]); // Tracking when an email is viewed to optimize unread count refreshes const lastViewedEmailRef = useRef(null); const fetchFailuresRef = useRef(0); const lastFetchFailureRef = useRef(null); // Modify viewEmail to track when an email is viewed const viewEmail = useCallback((emailId: string, accountId: string, folder: string, email: Email | null) => { dispatch({ type: 'SELECT_EMAIL', payload: { emailId, accountId, folder, email } }); // Track when an email is viewed to delay unread count refresh if (email) { lastViewedEmailRef.current = Date.now(); // If email is unread, mark it as read if (email.flags && !email.flags.seen) { dispatch({ type: 'MARK_EMAIL_AS_READ', payload: { emailId, isRead: true, accountId } }); } } else { // Email was deselected, schedule a refresh of unread counts after delay setTimeout(() => { fetchUnreadCounts(); }, 2000); } }, [dispatch, fetchUnreadCounts]); // Set up a function to manually trigger checking for new emails const forceCheckForNewEmails = useCallback(() => { // Don't check if we're already loading if (state.isLoading) return; // Log that we're manually checking logEmailOp('MANUAL_CHECK', 'Manually checking for new emails'); // Reset to page 1 to ensure we get the newest emails dispatch({ type: 'SET_PAGE', payload: 1 }); // Perform a complete refresh of emails loadEmails(1, state.perPage, false); // Also update unread counts fetchUnreadCounts(); }, [state.isLoading, state.perPage, loadEmails, logEmailOp, dispatch, fetchUnreadCounts]); // Return all state values and actions return { // State values ...state, // Actions loadEmails, handleEmailSelect, toggleEmailSelection, toggleSelectAll, markEmailAsRead, toggleStarred, changeFolder, deleteEmails, sendEmail, searchEmails, formatEmailForAction, setPage, setEmails, selectAccount, handleLoadMore, fetchUnreadCounts, viewEmail, checkForNewEmails, forceCheckForNewEmails }; };