courrier multi account restore compose
This commit is contained in:
parent
63eeb10033
commit
a9e85ca5f2
@ -41,12 +41,16 @@ import EmailDetailView from '@/components/email/EmailDetailView';
|
|||||||
import ComposeEmail from '@/components/email/ComposeEmail';
|
import ComposeEmail from '@/components/email/ComposeEmail';
|
||||||
import { DeleteConfirmDialog } from '@/components/email/EmailDialogs';
|
import { DeleteConfirmDialog } from '@/components/email/EmailDialogs';
|
||||||
|
|
||||||
// Import the custom hook
|
// Import the custom hooks
|
||||||
import { useCourrier, EmailData } from '@/hooks/use-courrier';
|
import { useCourrier, EmailData } from '@/hooks/use-courrier';
|
||||||
|
import { useEmailState } from '@/hooks/use-email-state';
|
||||||
|
|
||||||
// Import the prefetching function
|
// Import the prefetching function
|
||||||
import { prefetchFolderEmails } from '@/lib/services/prefetch-service';
|
import { prefetchFolderEmails } from '@/lib/services/prefetch-service';
|
||||||
|
|
||||||
|
// Import Account type from the reducer
|
||||||
|
import { Account } from '@/lib/reducers/emailReducer';
|
||||||
|
|
||||||
// Simplified version for this component
|
// Simplified version for this component
|
||||||
function SimplifiedLoadingFix() {
|
function SimplifiedLoadingFix() {
|
||||||
// In production, don't render anything
|
// In production, don't render anything
|
||||||
@ -62,14 +66,6 @@ function SimplifiedLoadingFix() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Account {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
color: string;
|
|
||||||
folders: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EmailWithFlags {
|
interface EmailWithFlags {
|
||||||
id: string;
|
id: string;
|
||||||
read?: boolean;
|
read?: boolean;
|
||||||
|
|||||||
585
hooks/use-email-state.ts
Normal file
585
hooks/use-email-state.ts
Normal file
@ -0,0 +1,585 @@
|
|||||||
|
import { useReducer, useCallback, useEffect } 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';
|
||||||
|
|
||||||
|
export const useEmailState = () => {
|
||||||
|
const [state, dispatch] = useReducer(emailReducer, initialState);
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// 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)) {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
};
|
||||||
324
lib/reducers/emailReducer.ts
Normal file
324
lib/reducers/emailReducer.ts
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
import { Email } from '@/hooks/use-courrier';
|
||||||
|
|
||||||
|
// Define all possible state types
|
||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
color: string;
|
||||||
|
folders: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailState {
|
||||||
|
accounts: Account[];
|
||||||
|
selectedAccount: Account | null;
|
||||||
|
selectedFolders: Record<string, string>;
|
||||||
|
currentFolder: string;
|
||||||
|
emails: Email[];
|
||||||
|
selectedEmail: Email | null;
|
||||||
|
selectedEmailIds: string[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalEmails: number;
|
||||||
|
mailboxes: string[];
|
||||||
|
unreadCountMap: Record<string, Record<string, number>>;
|
||||||
|
showFolders: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define all possible action types
|
||||||
|
export type EmailAction =
|
||||||
|
| { type: 'SET_ACCOUNTS', payload: Account[] }
|
||||||
|
| { type: 'SELECT_ACCOUNT', payload: Account }
|
||||||
|
| { type: 'CHANGE_FOLDER', payload: { folder: string, accountId: string } }
|
||||||
|
| { type: 'SET_EMAILS', payload: Email[] }
|
||||||
|
| { type: 'APPEND_EMAILS', payload: Email[] }
|
||||||
|
| { type: 'SELECT_EMAIL', payload: { emailId: string, accountId: string, folder: string, email: Email | null } }
|
||||||
|
| { type: 'TOGGLE_EMAIL_SELECTION', payload: string }
|
||||||
|
| { type: 'TOGGLE_SELECT_ALL' }
|
||||||
|
| { type: 'CLEAR_SELECTED_EMAILS' }
|
||||||
|
| { type: 'SET_LOADING', payload: boolean }
|
||||||
|
| { type: 'SET_ERROR', payload: string | null }
|
||||||
|
| { type: 'SET_PAGE', payload: number }
|
||||||
|
| { type: 'INCREMENT_PAGE' }
|
||||||
|
| { type: 'SET_TOTAL_PAGES', payload: number }
|
||||||
|
| { type: 'SET_TOTAL_EMAILS', payload: number }
|
||||||
|
| { type: 'SET_MAILBOXES', payload: string[] }
|
||||||
|
| { type: 'UPDATE_UNREAD_COUNT', payload: { accountId: string, folder: string, count: number } }
|
||||||
|
| { type: 'TOGGLE_SHOW_FOLDERS', payload: boolean }
|
||||||
|
| { type: 'MARK_EMAIL_AS_READ', payload: { emailId: string, isRead: boolean, accountId?: string } };
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
export const initialState: EmailState = {
|
||||||
|
accounts: [],
|
||||||
|
selectedAccount: null,
|
||||||
|
selectedFolders: {},
|
||||||
|
currentFolder: 'INBOX',
|
||||||
|
emails: [],
|
||||||
|
selectedEmail: null,
|
||||||
|
selectedEmailIds: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
page: 1,
|
||||||
|
perPage: 20,
|
||||||
|
totalPages: 0,
|
||||||
|
totalEmails: 0,
|
||||||
|
mailboxes: [],
|
||||||
|
unreadCountMap: {},
|
||||||
|
showFolders: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions for consistency
|
||||||
|
export const normalizeFolderAndAccount = (folder: string, accountId?: string) => {
|
||||||
|
let normalizedFolder: string;
|
||||||
|
let effectiveAccountId: string = accountId || 'default';
|
||||||
|
|
||||||
|
// First, handle the folder format
|
||||||
|
if (folder.includes(':')) {
|
||||||
|
// Extract parts if folder already has a prefix
|
||||||
|
const parts = folder.split(':');
|
||||||
|
const folderAccountId = parts[0];
|
||||||
|
normalizedFolder = parts[1];
|
||||||
|
|
||||||
|
// If explicit accountId is provided, it ALWAYS takes precedence
|
||||||
|
if (accountId) {
|
||||||
|
console.log(`Using provided accountId (${accountId}) over folder prefix (${folderAccountId})`);
|
||||||
|
effectiveAccountId = accountId;
|
||||||
|
} else {
|
||||||
|
effectiveAccountId = folderAccountId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No folder prefix, use the folder name as is
|
||||||
|
normalizedFolder = folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalizedFolder,
|
||||||
|
effectiveAccountId,
|
||||||
|
prefixedFolder: `${effectiveAccountId}:${normalizedFolder}`
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reducer function
|
||||||
|
export function emailReducer(state: EmailState, action: EmailAction): EmailState {
|
||||||
|
console.log(`[EMAIL_REDUCER] Action: ${action.type}`, action);
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_ACCOUNTS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
accounts: action.payload
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SELECT_ACCOUNT': {
|
||||||
|
// This is a critical action that needs special handling
|
||||||
|
const account = action.payload;
|
||||||
|
const inboxFolder = `${account.id}:INBOX`;
|
||||||
|
|
||||||
|
console.log(`[EMAIL_REDUCER] Selecting account: ${account.email} (${account.id})`);
|
||||||
|
|
||||||
|
// Return a completely new state that's atomically consistent
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedAccount: account,
|
||||||
|
currentFolder: inboxFolder,
|
||||||
|
selectedFolders: {
|
||||||
|
...state.selectedFolders,
|
||||||
|
[account.id]: inboxFolder
|
||||||
|
},
|
||||||
|
// Clear email selections as part of the atomic account switch
|
||||||
|
selectedEmail: null,
|
||||||
|
selectedEmailIds: [],
|
||||||
|
emails: [],
|
||||||
|
isLoading: true,
|
||||||
|
showFolders: true,
|
||||||
|
page: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CHANGE_FOLDER': {
|
||||||
|
const { folder, accountId } = action.payload;
|
||||||
|
|
||||||
|
// Use our helper to ensure consistent folder/account handling
|
||||||
|
const { normalizedFolder, effectiveAccountId, prefixedFolder } =
|
||||||
|
normalizeFolderAndAccount(folder, accountId);
|
||||||
|
|
||||||
|
console.log(`[EMAIL_REDUCER] Changing folder to: ${prefixedFolder} (account: ${effectiveAccountId})`);
|
||||||
|
|
||||||
|
// Return a new state with consistent folder and account info
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentFolder: prefixedFolder,
|
||||||
|
selectedFolders: {
|
||||||
|
...state.selectedFolders,
|
||||||
|
[effectiveAccountId]: prefixedFolder
|
||||||
|
},
|
||||||
|
// Clear email-specific state when changing folders
|
||||||
|
selectedEmail: null,
|
||||||
|
selectedEmailIds: [],
|
||||||
|
emails: [],
|
||||||
|
isLoading: true,
|
||||||
|
page: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SET_EMAILS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
emails: action.payload,
|
||||||
|
isLoading: false
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'APPEND_EMAILS': {
|
||||||
|
// Create a set of existing email IDs to avoid duplicates
|
||||||
|
const existingIds = new Set(state.emails.map(email => email.id));
|
||||||
|
|
||||||
|
// Filter out any duplicates before appending
|
||||||
|
const newEmails = action.payload.filter(email => !existingIds.has(email.id));
|
||||||
|
|
||||||
|
// Combine and sort emails by date (newest first)
|
||||||
|
const combinedEmails = [...state.emails, ...newEmails].sort(
|
||||||
|
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
emails: combinedEmails,
|
||||||
|
isLoading: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SELECT_EMAIL':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedEmail: action.payload.email,
|
||||||
|
// If an email is selected, add it to the selectedEmailIds if not already there
|
||||||
|
selectedEmailIds: action.payload.email && !state.selectedEmailIds.includes(action.payload.emailId)
|
||||||
|
? [...state.selectedEmailIds, action.payload.emailId]
|
||||||
|
: state.selectedEmailIds
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'TOGGLE_EMAIL_SELECTION': {
|
||||||
|
const emailId = action.payload;
|
||||||
|
const isSelected = state.selectedEmailIds.includes(emailId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedEmailIds: isSelected
|
||||||
|
? state.selectedEmailIds.filter(id => id !== emailId)
|
||||||
|
: [...state.selectedEmailIds, emailId]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'TOGGLE_SELECT_ALL': {
|
||||||
|
// If all emails are already selected, clear the selection
|
||||||
|
const allEmailIds = state.emails.map(email => email.id);
|
||||||
|
const allSelected = allEmailIds.every(id => state.selectedEmailIds.includes(id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedEmailIds: allSelected ? [] : allEmailIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLEAR_SELECTED_EMAILS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedEmailIds: [],
|
||||||
|
selectedEmail: null
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_LOADING':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isLoading: action.payload
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_ERROR':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
error: action.payload,
|
||||||
|
isLoading: false
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_PAGE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
page: action.payload
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'INCREMENT_PAGE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
page: state.page + 1
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_TOTAL_PAGES':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
totalPages: action.payload
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_TOTAL_EMAILS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
totalEmails: action.payload
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_MAILBOXES':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
mailboxes: action.payload
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'UPDATE_UNREAD_COUNT': {
|
||||||
|
const { accountId, folder, count } = action.payload;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
unreadCountMap: {
|
||||||
|
...state.unreadCountMap,
|
||||||
|
[accountId]: {
|
||||||
|
...(state.unreadCountMap[accountId] || {}),
|
||||||
|
[folder]: count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'TOGGLE_SHOW_FOLDERS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
showFolders: action.payload
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'MARK_EMAIL_AS_READ': {
|
||||||
|
const { emailId, isRead, accountId } = action.payload;
|
||||||
|
|
||||||
|
// Update emails list
|
||||||
|
const updatedEmails = state.emails.map(email =>
|
||||||
|
(email.id === emailId && (!accountId || email.accountId === accountId))
|
||||||
|
? { ...email, flags: { ...email.flags, seen: isRead } }
|
||||||
|
: email
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update selected email if it matches
|
||||||
|
const updatedSelectedEmail = state.selectedEmail &&
|
||||||
|
state.selectedEmail.id === emailId &&
|
||||||
|
(!accountId || state.selectedEmail.accountId === accountId)
|
||||||
|
? { ...state.selectedEmail, flags: { ...state.selectedEmail.flags, seen: isRead } }
|
||||||
|
: state.selectedEmail;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
emails: updatedEmails,
|
||||||
|
selectedEmail: updatedSelectedEmail
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user