diff --git a/components/email/EmailPanel.tsx b/components/email/EmailPanel.tsx index 04a8b80a..fa37f1ea 100644 --- a/components/email/EmailPanel.tsx +++ b/components/email/EmailPanel.tsx @@ -1,10 +1,12 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import EmailPreview from './EmailPreview'; import ComposeEmail from './ComposeEmail'; import { Loader2 } from 'lucide-react'; import { formatReplyEmail, EmailMessage as FormatterEmailMessage } from '@/lib/utils/email-formatter'; +import { useEmailFetch } from '@/hooks/use-email-fetch'; +import { debounce } from '@/lib/utils/debounce'; // Add local EmailMessage interface interface EmailAddress { @@ -40,28 +42,28 @@ interface EmailMessage { } interface EmailPanelProps { - selectedEmail: { emailId: string; accountId: string; folder: string } | null; - onSendEmail: (emailData: { - to: string; - cc?: string; - bcc?: string; - subject: string; - body: string; - attachments?: Array<{ - name: string; - content: string; - type: string; - }>; - }) => Promise; + selectedEmail: { + emailId: string; + accountId: string; + folder: string; + } | null; + onSendEmail: (email: any) => void; } export default function EmailPanel({ selectedEmail, onSendEmail }: EmailPanelProps) { - const [email, setEmail] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + // Use the new email fetch hook + const { email, loading, error, fetchEmail } = useEmailFetch({ + onEmailLoaded: (email) => { + // Handle any post-load actions + console.log('Email loaded:', email.id); + }, + onError: (error) => { + console.error('Error loading email:', error); + } + }); // Compose mode state const [isComposing, setIsComposing] = useState(false); @@ -111,57 +113,21 @@ export default function EmailPanel({ } }, [email]); + // Debounced email fetch + const debouncedFetchEmail = useCallback( + debounce((emailId: string, accountId: string, folder: string) => { + fetchEmail(emailId, accountId, folder); + }, 300), + [fetchEmail] + ); + // Load email content when selectedEmail changes useEffect(() => { if (selectedEmail) { - fetchEmail(selectedEmail.emailId, selectedEmail.accountId, selectedEmail.folder); + debouncedFetchEmail(selectedEmail.emailId, selectedEmail.accountId, selectedEmail.folder); setIsComposing(false); - } else { - setEmail(null); } - }, [selectedEmail]); - - // Fetch the email content - const fetchEmail = async (emailId: string, accountId: string, folder: string) => { - setLoading(true); - setError(null); - try { - const response = await fetch(`/api/courrier/${emailId}?accountId=${encodeURIComponent(accountId)}&folder=${encodeURIComponent(folder)}`); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to fetch email'); - } - const data = await response.json(); - if (!data) { - throw new Error('Email not found'); - } - // Mark as read if not already - if (!data.flags?.seen) { - markAsRead(emailId); - } - setEmail(data); - } catch (err) { - console.error('Error fetching email:', err); - setError(err instanceof Error ? err.message : 'Failed to load email'); - } finally { - setLoading(false); - } - }; - - // Mark email as read - const markAsRead = async (id: string) => { - try { - await fetch(`/api/courrier/${id}/mark-read`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ action: 'mark-read' }), - }); - } catch (err) { - console.error('Error marking email as read:', err); - } - }; + }, [selectedEmail, debouncedFetchEmail]); // Handle reply/forward actions const handleReply = (type: 'reply' | 'reply-all' | 'forward') => { @@ -174,7 +140,7 @@ export default function EmailPanel({ setIsComposing(false); setComposeType('new'); }; - + // If no email is selected and not composing if (!selectedEmail && !isComposing) { return ( @@ -227,7 +193,6 @@ export default function EmailPanel({ // Show compose mode or email preview return (
- {/* Remove ComposeEmail overlay/modal logic. Only show preview or a button to trigger compose in parent. */} {isComposing ? (
diff --git a/hooks/use-email-fetch.ts b/hooks/use-email-fetch.ts new file mode 100644 index 00000000..5b2f6a4b --- /dev/null +++ b/hooks/use-email-fetch.ts @@ -0,0 +1,126 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useToast } from './use-toast'; + +interface EmailFetchState { + email: any | null; + loading: boolean; + error: string | null; +} + +interface UseEmailFetchProps { + onEmailLoaded?: (email: any) => void; + onError?: (error: string) => void; +} + +export function useEmailFetch({ onEmailLoaded, onError }: UseEmailFetchProps = {}) { + const [state, setState] = useState({ + email: null, + loading: false, + error: null + }); + + const abortControllerRef = useRef(null); + const { toast } = useToast(); + + // Validate email fetch parameters + const validateFetchParams = (emailId: string, accountId: string, folder: string) => { + if (!emailId || typeof emailId !== 'string') { + throw new Error('Invalid email ID'); + } + + if (!accountId || typeof accountId !== 'string') { + throw new Error('Invalid account ID'); + } + + if (!folder || typeof folder !== 'string') { + throw new Error('Invalid folder'); + } + + // Validate UID format + if (!/^\d+$/.test(emailId)) { + throw new Error('Email ID must be a numeric UID'); + } + }; + + // Fetch email with proper error handling and cancellation + const fetchEmail = useCallback(async (emailId: string, accountId: string, folder: string) => { + try { + // Cancel any in-flight request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Create new abort controller + abortControllerRef.current = new AbortController(); + + // Validate parameters + validateFetchParams(emailId, accountId, folder); + + setState(prev => ({ ...prev, loading: true, error: null })); + + const response = await fetch( + `/api/courrier/${emailId}?accountId=${encodeURIComponent(accountId)}&folder=${encodeURIComponent(folder)}`, + { + signal: abortControllerRef.current.signal + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to fetch email'); + } + + const data = await response.json(); + + if (!data) { + throw new Error('Email not found'); + } + + setState({ email: data, loading: false, error: null }); + onEmailLoaded?.(data); + + // Mark as read if not already + if (!data.flags?.seen) { + try { + await fetch(`/api/courrier/${emailId}/mark-read`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'mark-read' }) + }); + } catch (err) { + console.error('Error marking email as read:', err); + } + } + } catch (err) { + // Don't set error if request was aborted + if (err.name === 'AbortError') { + return; + } + + const errorMessage = err instanceof Error ? err.message : 'Failed to load email'; + setState(prev => ({ ...prev, loading: false, error: errorMessage })); + onError?.(errorMessage); + + // Show toast for user feedback + toast({ + title: 'Error', + description: errorMessage, + variant: 'destructive' + }); + } + }, [onEmailLoaded, onError, toast]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + return { + ...state, + fetchEmail + }; +} \ No newline at end of file diff --git a/lib/services/email-service.ts b/lib/services/email-service.ts index 41ba2179..1635f5b8 100644 --- a/lib/services/email-service.ts +++ b/lib/services/email-service.ts @@ -400,6 +400,22 @@ export async function getEmailContent( folder: string = 'INBOX', accountId?: string ): Promise { + // Validate parameters + if (!userId || !emailId || !folder) { + throw new Error('Missing required parameters'); + } + + // Validate UID format + if (!/^\d+$/.test(emailId)) { + throw new Error('Invalid email ID format: must be a numeric UID'); + } + + // Convert to number for IMAP + const numericId = parseInt(emailId, 10); + if (isNaN(numericId)) { + throw new Error('Email ID must be a number'); + } + // Try to get from cache first const cachedEmail = await getCachedEmailContent(userId, accountId || folder, emailId); if (cachedEmail) { @@ -414,9 +430,25 @@ export async function getEmailContent( try { // Remove accountId prefix if present const actualFolder = folder.includes(':') ? folder.split(':')[1] : folder; - await client.mailboxOpen(actualFolder); - const message = await client.fetchOne(emailId, { + // Log connection details + console.log(`[DEBUG] Fetching email ${emailId} from folder ${actualFolder} for account ${accountId}`); + + // Open mailbox with error handling + const mailbox = await client.mailboxOpen(actualFolder); + if (!mailbox || typeof mailbox === 'boolean') { + throw new Error(`Failed to open mailbox: ${actualFolder}`); + } + + // Log mailbox status + console.log(`[DEBUG] Mailbox ${actualFolder} opened, total messages: ${mailbox.exists}`); + + // Validate UID exists in mailbox + if (numericId > mailbox.uidNext) { + throw new Error(`Email ID ${numericId} is greater than the highest UID in mailbox (${mailbox.uidNext})`); + } + + const message = await client.fetchOne(numericId.toString(), { source: true, envelope: true, flags: true, @@ -424,7 +456,7 @@ export async function getEmailContent( }); if (!message) { - throw new Error('Email not found'); + throw new Error(`Email not found with ID ${numericId} in folder ${actualFolder}`); } const { source, envelope, flags, size } = message; @@ -488,6 +520,15 @@ export async function getEmailContent( await cacheEmailContent(userId, accountId || folder, emailId, email); return email; + } catch (error) { + console.error('[ERROR] Email fetch failed:', { + userId, + emailId, + folder, + accountId, + error: error instanceof Error ? error.message : 'Unknown error' + }); + throw error; } finally { try { await client.mailboxClose(); diff --git a/lib/utils/debounce.ts b/lib/utils/debounce.ts new file mode 100644 index 00000000..555ca54e --- /dev/null +++ b/lib/utils/debounce.ts @@ -0,0 +1,11 @@ +export function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout; + + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +} \ No newline at end of file