From 9f0ca0e6f55246e6d9b5619f052255d9280d6968 Mon Sep 17 00:00:00 2001 From: alma Date: Wed, 30 Apr 2025 12:47:52 +0200 Subject: [PATCH] courrier multi account restore compose --- app/api/courrier/route.ts | 26 ++- hooks/use-courrier.ts | 335 +++++++++++++++++++++++--------------- 2 files changed, 232 insertions(+), 129 deletions(-) diff --git a/app/api/courrier/route.ts b/app/api/courrier/route.ts index b3795dfc..4d73ef0b 100644 --- a/app/api/courrier/route.ts +++ b/app/api/courrier/route.ts @@ -7,6 +7,9 @@ import { cacheEmailList, invalidateFolderCache } from '@/lib/redis'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); // Simple in-memory cache (will be removed in a future update) interface EmailCacheEntry { @@ -38,11 +41,32 @@ export async function GET(request: Request) { const accountId = searchParams.get("accountId") || ""; // Extract account ID from folder name if present and none was explicitly provided - const folderAccountId = folder.includes(':') ? folder.split(':')[0] : accountId; + const folderAccountId = folder.includes(':') ? folder.split(':')[0] : ""; // Use the most specific account ID available + // First from the folder, then from the explicit parameter, then default let effectiveAccountId = folderAccountId || accountId || 'default'; + // CRITICAL FIX: If effectiveAccountId is still 'default', try to find the first account for the user + if (effectiveAccountId === 'default') { + try { + const accounts = await prisma.mailCredentials.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: 'asc' }, + take: 1, + select: { id: true } + }); + + if (accounts && accounts.length > 0) { + effectiveAccountId = accounts[0].id; + console.log(`No specific account provided, using first available account: ${effectiveAccountId}`); + } + } catch (error) { + console.error("Error finding default account:", error); + // Continue with 'default' if there's an error + } + } + // Normalize folder name by removing account prefix if present const normalizedFolder = folder.includes(':') ? folder.split(':')[1] : folder; diff --git a/hooks/use-courrier.ts b/hooks/use-courrier.ts index 7b413dc2..8ccc5b5b 100644 --- a/hooks/use-courrier.ts +++ b/hooks/use-courrier.ts @@ -95,15 +95,23 @@ export const useCourrier = () => { // The currentFolder should already have the account prefix in format "accountId:folder" // We need to extract the base folder name for the API request let normalizedFolder = currentFolder; - let effectiveAccountId = accountId || 'default'; - if (currentFolder.includes(':')) { + // CRITICAL FIX: Handle account ID determination more robustly + // If specific account ID is provided, always use it + // Otherwise extract from currentFolder if possible + let effectiveAccountId: string; + + if (accountId) { + // Use explicitly provided accountId + effectiveAccountId = accountId; + } else if (currentFolder.includes(':')) { + // Extract from folder format const parts = currentFolder.split(':'); - // If no explicit accountId was provided, use the one from the folder name - if (!accountId) { - effectiveAccountId = parts[0]; - } + effectiveAccountId = parts[0]; normalizedFolder = parts[1]; + } else { + // Default case + effectiveAccountId = 'default'; } console.log(`Load emails - using normalized folder: ${normalizedFolder}, effectiveAccountId: ${effectiveAccountId}`); @@ -125,12 +133,14 @@ export const useCourrier = () => { // Try to get cached emails first const currentRequestPage = page; - // FIXED: Use the correct parameter order and normalized values - // Function signature: (userId: string, folder: string, page: number, perPage: number, timeoutMs: number = 100, accountId?: string) - console.log(`Getting cached emails for user ${session.user.id}, folder ${currentFolder}, normalizedFolder: ${normalizedFolder}, page ${currentRequestPage}, accountId ${effectiveAccountId}`); + // CRITICAL FIX: Use the correct cache key format + // We pass the full prefixed folder to ensure proper cache key consistency + const folderForCache = `${effectiveAccountId}:${normalizedFolder}`; + + console.log(`Getting cached emails for user ${session.user.id}, folder ${folderForCache}, page ${currentRequestPage}, accountId ${effectiveAccountId}`); const cachedEmails = await getCachedEmailsWithTimeout( session.user.id, // userId: string - currentFolder, // folder: string - use full prefixed folder for cache key + folderForCache, // folder: string - use consistently prefixed folder for cache key currentRequestPage, // page: number perPage, // perPage: number 100, // timeoutMs: number @@ -299,8 +309,13 @@ export const useCourrier = () => { const changeFolder = useCallback(async (folder: string, accountId?: string) => { console.log(`Changing folder to ${folder} for account ${accountId || 'default'}`); try { - // CRITICAL FIX: Better folder and account ID handling - // Extract account ID from folder name if present and none was explicitly provided + // Reset selected email and selection state immediately to avoid race conditions + setSelectedEmail(null); + setSelectedEmailIds([]); + setEmails([]); // Clear existing emails right away + setIsLoading(true); // Show loading state immediately + + // CRITICAL FIX: Extract account ID from folder name if present and none was explicitly provided const folderAccountId = folder.includes(':') ? folder.split(':')[0] : accountId; // Use the most specific account ID available @@ -314,13 +329,8 @@ export const useCourrier = () => { console.log(`Folder change: original=${folder}, normalized=${normalizedFolder}, accountId=${effectiveAccountId}, prefixed=${prefixedFolder}`); - // Reset selected email - setSelectedEmail(null); - setSelectedEmailIds([]); - - // Use the consistently prefixed folder name for state - // CRITICAL FIX: Always use the properly prefixed folder name in state - setCurrentFolder(prefixedFolder); + // CRITICAL FIX: Store the current account ID in state for all subsequent operations + // This ensures operations like markAsRead use the correct account context // Reset search query when changing folders setSearchQuery(''); @@ -328,19 +338,16 @@ export const useCourrier = () => { // Reset to page 1 setPage(1); - // Clear existing emails before loading new ones to prevent UI flicker - setEmails([]); - - // Show loading state - setIsLoading(true); + // CRITICAL FIX: We set the currentFolder state AFTER we have prepared all parameters + // This ensures any effects or functions triggered by currentFolder change have the correct context + setCurrentFolder(prefixedFolder); // Use a small delay to ensure state updates have propagated // This helps prevent race conditions when multiple folders are clicked quickly await new Promise(resolve => setTimeout(resolve, 100)); - // Call loadEmails with correct boolean parameter type and account ID - // CRITICAL FIX: Pass the properly formatted folder name to the cache lookup functions - console.log(`Loading emails for prefixed folder: ${prefixedFolder} with accountId: ${effectiveAccountId}`); + // CRITICAL FIX: Wait for the loadEmails operation to complete before considering the folder change done + // This prevents multiple concurrent folder changes from interfering with each other await loadEmails(false, effectiveAccountId); } catch (error) { console.error(`Error changing to folder ${folder}:`, error); @@ -408,24 +415,47 @@ export const useCourrier = () => { }, [currentFolder]); // Mark an email as read/unread - const markEmailAsRead = useCallback(async (emailId: string, isRead: boolean) => { + const markEmailAsRead = useCallback(async (emailId: string, isRead: boolean, providedAccountId?: string) => { try { - // Find the email to get its accountId - const emailToMark = emails.find(e => e.id === emailId); - if (!emailToMark) { - throw new Error('Email not found'); + // CRITICAL FIX: If an account ID is provided, use it directly + // Otherwise, find the email to get its accountId + let emailAccountId = providedAccountId; + let emailFolder = ''; + + if (!emailAccountId) { + // Find the email in the current list + const emailToMark = emails.find(e => e.id === emailId); + if (!emailToMark) { + throw new Error('Email not found'); + } + + // Get the accountId from the email + emailAccountId = emailToMark.accountId || 'default'; + emailFolder = emailToMark.folder; + } else { + // If providedAccountId exists but we don't have folder info, + // try to find the email in the list to get its folder + const emailToMark = emails.find(e => e.id === emailId && e.accountId === providedAccountId); + if (emailToMark) { + emailFolder = emailToMark.folder; + } } - - // Get the accountId from the email - const emailAccountId = emailToMark.accountId || 'default'; - + // Normalize folder name by removing account prefix if present - const normalizedFolder = emailToMark.folder.includes(':') - ? emailToMark.folder.split(':')[1] - : emailToMark.folder; - + let normalizedFolder = emailFolder; + if (emailFolder && emailFolder.includes(':')) { + normalizedFolder = emailFolder.split(':')[1]; + } else if (!emailFolder) { + // If folder isn't available from the email object, try to extract it from currentFolder + if (currentFolder.includes(':')) { + normalizedFolder = currentFolder.split(':')[1]; + } else { + normalizedFolder = currentFolder; + } + } + console.log(`Marking email ${emailId} as ${isRead ? 'read' : 'unread'} in folder ${normalizedFolder}, account: ${emailAccountId}`); - + const response = await fetch(`/api/courrier/${emailId}/mark-read`, { method: 'POST', headers: { @@ -437,27 +467,29 @@ export const useCourrier = () => { accountId: emailAccountId }) }); - + if (!response.ok) { throw new Error('Failed to mark email as read'); } - - // Update the email in the list + + // Update the email in the list - only update the specific email with matching ID AND account ID setEmails(emails.map(email => - email.id === emailId ? { ...email, flags: { ...email.flags, seen: isRead } } : email + (email.id === emailId && (!providedAccountId || email.accountId === providedAccountId)) + ? { ...email, flags: { ...email.flags, seen: isRead } } + : email )); - + // If the selected email is the one being marked, update it too - if (selectedEmail && selectedEmail.id === emailId) { + if (selectedEmail && selectedEmail.id === emailId && (!providedAccountId || selectedEmail.accountId === providedAccountId)) { setSelectedEmail({ ...selectedEmail, flags: { ...selectedEmail.flags, seen: isRead } }); } - + return true; } catch (error) { console.error('Error marking email as read:', error); return false; } - }, [emails, selectedEmail]); + }, [emails, selectedEmail, currentFolder]); // Select an email to view const handleEmailSelect = useCallback(async (emailId: string, accountId: string, folderOverride: string) => { @@ -473,8 +505,11 @@ export const useCourrier = () => { setIsLoading(true); try { - // Normalize account ID if not provided - const effectiveAccountId = accountId || 'default'; + // CRITICAL FIX: Always use the provided accountId, never use default + // This ensures we consistently use the correct account throughout the email selection process + if (!accountId) { + throw new Error('Account ID is required for email selection'); + } // Normalize folder name handling - ensure consistent format let normalizedFolder: string; @@ -483,76 +518,59 @@ export const useCourrier = () => { if (folderOverride.includes(':')) { // Extract parts if folder already has a prefix const parts = folderOverride.split(':'); - const folderAccountId = parts[0]; normalizedFolder = parts[1]; - // CRITICAL FIX: Always use the provided accountId instead of the one in the folder + // CRITICAL FIX: Always use the explicitly provided accountId // This ensures we're looking in the right account when an email is clicked - prefixedFolder = `${effectiveAccountId}:${normalizedFolder}`; - - if (folderAccountId !== effectiveAccountId) { - console.log(`WARNING: Folder account prefix mismatch. Folder has ${folderAccountId}, but using ${effectiveAccountId}`); - } + prefixedFolder = `${accountId}:${normalizedFolder}`; } else { - // No prefix, add one + // No prefix, add one using the provided account ID normalizedFolder = folderOverride; - prefixedFolder = `${effectiveAccountId}:${normalizedFolder}`; + prefixedFolder = `${accountId}:${normalizedFolder}`; } - console.log(`Email selection with normalized values: folder=${normalizedFolder}, prefixed=${prefixedFolder}, accountId=${effectiveAccountId}`); + console.log(`Email selection with normalized values: folder=${normalizedFolder}, prefixed=${prefixedFolder}, accountId=${accountId}`); - // More flexible email finding with detailed logging - console.log(`Looking for email with ID=${emailId}, account=${effectiveAccountId}, normalized folder=${normalizedFolder}, prefixed=${prefixedFolder}`); + // CRITICAL FIX: First search for email in current list using the EXACT account provided + // This ensures we don't mix emails from different accounts + console.log(`Looking for email with ID=${emailId}, account=${accountId}, normalized folder=${normalizedFolder}, prefixed=${prefixedFolder}`); - // First, try to find by exact match with account and folder + // First, try to find by exact match with the provided account and folder let email = emails.find(e => e.id === emailId && - e.accountId === effectiveAccountId && + e.accountId === accountId && ( e.folder === prefixedFolder || e.folder === normalizedFolder || - e.folder === folderOverride || - // Also check for case where email folder has its own prefix but with the same normalized folder (e.folder?.includes(':') && e.folder.split(':')[1] === normalizedFolder) ) ); - // If not found, try to find just by ID as fallback + // CRITICAL FIX: If not found, we do NOT try finding by ID only + // This prevents mixing emails across accounts if (!email) { - console.log(`No exact match found. Looking for email just by ID=${emailId}`); - email = emails.find(e => e.id === emailId); - - if (email) { - console.log(`Found email by ID only. Account=${email.accountId}, folder=${email.folder}`); - } - } - - if (!email) { - console.log(`Email ${emailId} not found in current list (searched ${emails.length} emails). Fetching from API.`); + console.log(`Email ${emailId} not found in current list for account ${accountId} (searched ${emails.length} emails). Fetching from API.`); try { - // CRITICAL FIX: Pass both normalized folder and account ID to fetch - // Using normalized folder (without prefix) for API compatibility - console.log(`Fetching email ${emailId} directly from API with normalizedFolder=${normalizedFolder}, accountId=${effectiveAccountId}`); - const fullEmail = await fetchEmailContent(emailId, effectiveAccountId, normalizedFolder); + // Use the provided account ID and normalized folder for the API request + console.log(`Fetching email ${emailId} directly from API with normalizedFolder=${normalizedFolder}, accountId=${accountId}`); + const fullEmail = await fetchEmailContent(emailId, accountId, normalizedFolder); - // Ensure the returned email has the proper accountId and prefixed folder name + // CRITICAL FIX: Always set the accountId correctly if (fullEmail) { - if (!fullEmail.accountId) { - fullEmail.accountId = effectiveAccountId; - } + fullEmail.accountId = accountId; // Make sure folder has the proper prefix for consistent lookup if (fullEmail.folder && !fullEmail.folder.includes(':')) { - fullEmail.folder = `${fullEmail.accountId}:${fullEmail.folder}`; + fullEmail.folder = `${accountId}:${fullEmail.folder}`; } + + console.log(`Successfully fetched email from API:`, { + id: fullEmail.id, + account: fullEmail.accountId, + folder: fullEmail.folder + }); + setSelectedEmail(fullEmail); } - - console.log(`Successfully fetched email from API:`, { - id: fullEmail.id, - account: fullEmail.accountId, - folder: fullEmail.folder - }); - setSelectedEmail(fullEmail); } catch (error) { // Type the error properly const fetchError = error instanceof Error ? error : new Error(String(error)); @@ -566,43 +584,35 @@ export const useCourrier = () => { if (!email.contentFetched) { console.log(`Email found but content not fetched. Getting full content for ${emailId}`); try { - // CRITICAL FIX: Extract normalized folder from email's folder if it has a prefix - const emailFolder = email.folder || normalizedFolder; - let emailNormalizedFolder = emailFolder; + // CRITICAL FIX: Use the provided accountId for fetching content, not the one from the email + // This ensures consistent account context - if (emailFolder.includes(':')) { - emailNormalizedFolder = emailFolder.split(':')[1]; - } + console.log(`Fetching content for email ${emailId} with accountId=${accountId}, folder=${normalizedFolder}`); - // Always use the email's own accountId if available - const emailAccountId = email.accountId || effectiveAccountId; - - console.log(`Fetching content for email ${emailId} with accountId=${emailAccountId}, folder=${emailNormalizedFolder}`); - - // Use the email's own accountId and normalized folder for fetching + // Use the provided accountId and normalized folder for fetching const fullEmail = await fetchEmailContent( emailId, - emailAccountId, - emailNormalizedFolder + accountId, + normalizedFolder ); - // Ensure the returned email has consistent format - if (fullEmail && !fullEmail.accountId) { - fullEmail.accountId = emailAccountId; + // CRITICAL FIX: Ensure the returned email has the correct account ID + if (fullEmail) { + fullEmail.accountId = accountId; } // Merge the full content with the email const updatedEmail = { ...email, - accountId: emailAccountId, // Ensure account ID is preserved - folder: email.folder, // Preserve original folder name with prefix + accountId, // CRITICAL FIX: Use the provided accountId + folder: prefixedFolder, // CRITICAL FIX: Use the consistently prefixed folder content: fullEmail.content, attachments: fullEmail.attachments, contentFetched: true }; // Update the email in the list - setEmails(emails.map(e => e.id === emailId ? updatedEmail : e)); + setEmails(emails.map(e => e.id === emailId && e.accountId === accountId ? updatedEmail : e)); setSelectedEmail(updatedEmail); console.log(`Successfully updated email with content`); } catch (error) { @@ -613,13 +623,21 @@ export const useCourrier = () => { } } else { console.log(`Email found with content already fetched, selecting directly`); + + // CRITICAL FIX: Ensure the email has the correct account ID before selecting + email = { + ...email, + accountId, // Always use the provided accountId + folder: prefixedFolder // Always use the consistently prefixed folder + }; + setSelectedEmail(email); } // Mark the email as read if it's not already if (!email.flags.seen) { - console.log(`Marking email ${emailId} as read`); - markEmailAsRead(emailId, true).catch(err => { + console.log(`Marking email ${emailId} as read for account ${accountId}`); + markEmailAsRead(emailId, true, accountId).catch(err => { console.error(`Failed to mark email as read: ${err.message}`); }); } @@ -637,11 +655,29 @@ export const useCourrier = () => { // Toggle starred status for an email const toggleStarred = useCallback(async (emailId: string) => { + // Find the email in the emails array const email = emails.find(e => e.id === emailId); if (!email) return; const newStarredStatus = !email.flags.flagged; + // CRITICAL FIX: Extract the account ID from the email object + const emailAccountId = email.accountId; + if (!emailAccountId) { + console.error('Cannot toggle star without account ID'); + return; + } + + // Extract normalized folder from folder with potential prefix + let normalizedFolder: string; + if (email.folder && email.folder.includes(':')) { + normalizedFolder = email.folder.split(':')[1]; + } else if (currentFolder.includes(':')) { + normalizedFolder = currentFolder.split(':')[1]; + } else { + normalizedFolder = email.folder || currentFolder; + } + try { const response = await fetch(`/api/courrier/${emailId}/star`, { method: 'POST', @@ -650,7 +686,8 @@ export const useCourrier = () => { }, body: JSON.stringify({ starred: newStarredStatus, - folder: currentFolder + folder: normalizedFolder, + accountId: emailAccountId // CRITICAL FIX: Always include account ID in requests }) }); @@ -658,13 +695,15 @@ export const useCourrier = () => { throw new Error('Failed to toggle star status'); } - // Update the email in the list + // Update the email in the list - match both ID and account ID setEmails(emails.map(email => - email.id === emailId ? { ...email, flags: { ...email.flags, flagged: newStarredStatus } } : email + (email.id === emailId && email.accountId === emailAccountId) + ? { ...email, flags: { ...email.flags, flagged: newStarredStatus } } + : email )); // If the selected email is the one being starred, update it too - if (selectedEmail && selectedEmail.id === emailId) { + if (selectedEmail && selectedEmail.id === emailId && selectedEmail.accountId === emailAccountId) { setSelectedEmail({ ...selectedEmail, flags: { ...selectedEmail.flags, flagged: newStarredStatus } }); } } catch (error) { @@ -736,14 +775,41 @@ export const useCourrier = () => { setIsDeleting(true); try { + // CRITICAL FIX: Extract normalized folder and account ID from currentFolder + let normalizedFolder = currentFolder; + let accountId = 'default'; + + if (currentFolder.includes(':')) { + const parts = currentFolder.split(':'); + accountId = parts[0]; + normalizedFolder = parts[1]; + } + + // Filter email IDs based on the current account context + // Only delete emails that belong to the current account + const emailsInCurrentAccount = emails.filter(email => + emailIds.includes(email.id) && + (!email.accountId || email.accountId === accountId) + ); + + const filteredEmailIds = emailsInCurrentAccount.map(email => email.id); + + if (filteredEmailIds.length === 0) { + console.log('No emails to delete in the current account context'); + return; + } + + console.log(`Deleting ${filteredEmailIds.length} emails from account ${accountId} in folder ${normalizedFolder}`); + const response = await fetch('/api/courrier/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - emailIds, - folder: currentFolder + emailIds: filteredEmailIds, + folder: normalizedFolder, + accountId }) }); @@ -752,19 +818,23 @@ export const useCourrier = () => { } // Remove the deleted emails from the list - setEmails(emails.filter(email => !emailIds.includes(email.id))); + setEmails(emails.filter(email => + !filteredEmailIds.includes(email.id) || + (email.accountId && email.accountId !== accountId) + )); // Clear selection if the selected email was deleted - if (selectedEmail && emailIds.includes(selectedEmail.id)) { + if (selectedEmail && filteredEmailIds.includes(selectedEmail.id) && + (!selectedEmail.accountId || selectedEmail.accountId === accountId)) { setSelectedEmail(null); } // Clear selected IDs - setSelectedEmailIds([]); + setSelectedEmailIds(prevIds => prevIds.filter(id => !filteredEmailIds.includes(id))); toast({ title: "Success", - description: `${emailIds.length} email(s) deleted` + description: `${filteredEmailIds.length} email(s) deleted` }); } catch (error) { console.error('Error deleting emails:', error); @@ -803,8 +873,17 @@ export const useCourrier = () => { const searchEmails = useCallback((query: string) => { setSearchQuery(query); setPage(1); - loadEmails(); - }, [loadEmails]); + + // CRITICAL FIX: Extract account ID from currentFolder when searching + let accountId = 'default'; + if (currentFolder.includes(':')) { + const parts = currentFolder.split(':'); + accountId = parts[0]; + } + + // Call loadEmails with the correct account context + loadEmails(false, accountId); + }, [loadEmails, currentFolder]); // Format an email for reply or forward const formatEmailForAction = useCallback((email: Email) => {