NeahNew/hooks/use-email-state.ts
2025-05-03 14:17:46 +02:00

1197 lines
45 KiB
TypeScript

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<number | null>(null);
const lastEmailViewedRef = useRef<number | null>(null);
const failedFetchCountRef = useRef<number>(0);
const lastFolderRef = useRef<string | null>(null);
const lastPageLoadedRef = useRef<number>(0);
const prevFolderRef = useRef<string | null>(null);
const loadMoreTriggerTimeRef = useRef<number>(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<typeof setTimeout>;
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<number | null>(null);
const fetchFailuresRef = useRef<number>(0);
const lastFetchFailureRef = useRef<number | null>(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
};
};