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-formatter'; // 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(); // 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 (isLoadMore = false, accountId?: string) => { if (!session?.user?.id) return; dispatch({ type: 'SET_LOADING', payload: true }); try { // Get normalized parameters using helper function const { normalizedFolder, effectiveAccountId, prefixedFolder } = normalizeFolderAndAccount(state.currentFolder, accountId); logEmailOp('LOAD_EMAILS', `Loading emails for ${prefixedFolder} (account: ${effectiveAccountId})`); // Construct query parameters const queryParams = new URLSearchParams({ folder: normalizedFolder, page: state.page.toString(), perPage: state.perPage.toString(), accountId: effectiveAccountId }); // Try to get cached emails first logEmailOp('CACHE_CHECK', `Checking cache for ${prefixedFolder}, page: ${state.page}`); const cachedEmails = await getCachedEmailsWithTimeout( session.user.id, prefixedFolder, state.page, state.perPage, 100, effectiveAccountId ); if (cachedEmails) { logEmailOp('CACHE_HIT', `Using cached data for ${prefixedFolder}`); // Ensure cached data has emails array property if (Array.isArray(cachedEmails.emails)) { // Dispatch appropriate action based on if we're loading more 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 }); } } // Still refresh in background for fresh data logEmailOp('BACKGROUND_REFRESH', `Starting background refresh for ${prefixedFolder}`); refreshEmailsInBackground( session.user.id, normalizedFolder, state.page, state.perPage, effectiveAccountId ).catch(err => { console.error('Background refresh error:', err); }); return; } // Fetch emails from API if no cache hit logEmailOp('API_FETCH', `Fetching emails from API: ${queryParams.toString()}`); const response = await fetch(`/api/courrier?${queryParams.toString()}`); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to fetch emails'); } const data = await response.json(); // 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(); } } }); } // Update state with fetched data dispatch({ type: isLoadMore ? 'APPEND_EMAILS' : 'SET_EMAILS', payload: Array.isArray(data.emails) ? data.emails : [] }); 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' }); } }, [session?.user?.id, state.currentFolder, state.page, state.perPage, toast, logEmailOp]); // Change folder const changeFolder = useCallback(async (folder: string, accountId?: string) => { logEmailOp('CHANGE_FOLDER', `Changing to folder ${folder} with account ${accountId || 'default'}`); try { // This will handle all the state updates in a single atomic operation dispatch({ type: 'CHANGE_FOLDER', payload: { folder, accountId: accountId || 'default' } }); // After dispatch, the state will be updated with consistent values // We'll load emails in the useEffect that watches for folder changes } 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' }); } }, [logEmailOp]); // 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(); 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]); // 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' }); // Reload emails to get updated list loadEmails(); toast({ title: "Emails Deleted", description: `${emailIds.length} email(s) moved to trash` }); 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, toast, loadEmails, logEmailOp]); // 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" }); // Refresh emails to show the sent email loadEmails(); 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]); // 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(() => { if (state.page < state.totalPages && !state.isLoading) { dispatch({ type: 'INCREMENT_PAGE' }); } }, [state.page, state.totalPages, state.isLoading]); // Effect to load emails when folder changes useEffect(() => { if (session?.user?.id && state.currentFolder) { // Extract account ID from folder for consistent loading const { effectiveAccountId } = normalizeFolderAndAccount(state.currentFolder); logEmailOp('FOLDER_CHANGE', `Loading emails for folder ${state.currentFolder} with account ${effectiveAccountId}`); // Load emails with the correct account ID loadEmails(false, effectiveAccountId); } }, [session?.user?.id, state.currentFolder, loadEmails, logEmailOp]); // Effect to load more emails when page changes useEffect(() => { if (session?.user?.id && state.page > 1) { // Extract account ID for consistency const { effectiveAccountId } = normalizeFolderAndAccount(state.currentFolder); logEmailOp('PAGINATION', `Loading page ${state.page} for ${state.currentFolder} with account ${effectiveAccountId}`); // Load more emails with the correct account ID loadEmails(true, effectiveAccountId); } }, [session?.user?.id, state.page, state.currentFolder, loadEmails, logEmailOp]); // Fetch unread counts from API const fetchUnreadCounts = useCallback(async () => { // Don't fetch if user is not logged in 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(); const lastViewedTimestamp = (window as any).__lastViewedEmailTimestamp || 0; if (lastViewedTimestamp && now - lastViewedTimestamp < 5000) { 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]); // 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]); // 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 }; };