diff --git a/components/email/ComposeEmail.tsx b/components/email/ComposeEmail.tsx index 109de02b..b5b367b1 100644 --- a/components/email/ComposeEmail.tsx +++ b/components/email/ComposeEmail.tsx @@ -136,64 +136,6 @@ export default function ComposeEmail(props: ComposeEmailProps) { } } - // Safety timeout to prevent endless loading - const safetyTimeoutId = setTimeout(() => { - const contentState = emailContent; - if (!contentState || contentState === "") { - console.warn('Email content initialization timed out after 5 seconds, using fallback template'); - // Create a basic fallback template - const { fromStr, dateStr } = getFormattedInfoForEmail(initialEmail); - - // Use a different template based on email type - let fallbackContent = ''; - if (type === 'forward') { - fallbackContent = ` -
| From: | -${fromStr} | -
| Date: | -${dateStr} | -
| Subject: | -${initialEmail.subject || ''} | -
- [Could not load original content. Please use plain text or start a new message.] -- `; - } - - setEmailContent(fallbackContent); - - // Also update the Quill editor directly if possible - const editorElement = document.querySelector('.ql-editor'); - if (editorElement instanceof HTMLElement) { - editorElement.innerHTML = fallbackContent; - } - } - }, 5000); - // Get recipients based on type if (type === 'reply' || type === 'reply-all') { // Get formatted data for reply @@ -316,9 +258,6 @@ export default function ComposeEmail(props: ComposeEmailProps) { setAttachments(formattedAttachments); } } - - // Clear the safety timeout if we complete successfully - return () => clearTimeout(safetyTimeoutId); } catch (error) { console.error('Error initializing compose form:', error); // Provide a fallback in case of error diff --git a/components/email/EmailDetailView.tsx b/components/email/EmailDetailView.tsx index 4877c79a..afb1ef28 100644 --- a/components/email/EmailDetailView.tsx +++ b/components/email/EmailDetailView.tsx @@ -45,35 +45,11 @@ export default function EmailDetailView({ // Render email content based on the email body const renderEmailContent = () => { try { - // Enhanced debugging to trace exactly what's in the content - console.log('EmailDetailView renderEmailContent - DETAILED DEBUG', { - emailId: email.id, - subject: email.subject, + console.log('EmailDetailView renderEmailContent', { hasContent: !!email.content, contentType: typeof email.content, - contentKeys: email.content && typeof email.content === 'object' ? Object.keys(email.content) : [], - contentStringLength: typeof email.content === 'string' ? email.content.length : 'N/A', - contentHtmlLength: email.content && typeof email.content === 'object' && 'html' in email.content && typeof (email.content as any).html === 'string' - ? ((email.content as any).html as string).length - : 0, - contentTextLength: email.content && typeof email.content === 'object' && 'text' in email.content && typeof (email.content as any).text === 'string' - ? ((email.content as any).text as string).length - : 0, - contentSample: typeof email.content === 'string' - ? email.content.substring(0, 100) - : (email.content && typeof email.content === 'object' && 'html' in email.content && typeof (email.content as any).html === 'string' - ? ((email.content as any).html as string).substring(0, 100) - : (email.content && typeof email.content === 'object' && 'text' in email.content && typeof (email.content as any).text === 'string' - ? ((email.content as any).text as string).substring(0, 100) - : 'N/A')), hasHtml: !!email.html, - htmlLength: email.html?.length || 0, - htmlSample: email.html?.substring(0, 100) || 'N/A', - hasText: !!email.text, - textLength: email.text?.length || 0, - textSample: email.text?.substring(0, 100) || 'N/A', - contentIsNull: email.content === null, - contentIsUndefined: email.content === undefined, + hasText: !!email.text }); // Determine what content to use and how to handle it @@ -83,29 +59,15 @@ export default function EmailDetailView({ // If content is a string, use it directly if (typeof email.content === 'string') { contentToUse = email.content; - console.log('Using email.content as string', contentToUse.substring(0, 50)); } // If content is an object with html/text properties else if (typeof email.content === 'object') { - const contentObj = email.content as {html?: string; text?: string}; - if (contentObj.html) { - contentToUse = contentObj.html; - console.log('Using email.content.html', contentToUse.substring(0, 50)); - } else if (contentObj.text) { - // Convert plain text to HTML - contentToUse = contentObj.text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\n/g, '
0) { - console.log(`Found ${tables.length} tables in ${shouldConvertAllTables ? 'reply/forward' : 'regular'} content`); - - let convertedCount = 0; - tables.forEach(table => { - // In reply/forward mode, convert ALL tables to divs to avoid quill-better-table issues - if (shouldConvertAllTables) { - const replacementDiv = document.createElement('div'); - replacementDiv.className = 'converted-table'; - replacementDiv.style.border = '1px solid #ddd'; - replacementDiv.style.margin = '10px 0'; - replacementDiv.style.padding = '10px'; - - // Preserve the original table structure visually - // Create a simplified HTML representation of the table - let tableHtml = ''; - - // Process each row - const rows = table.querySelectorAll('tr'); - rows.forEach(row => { - const rowDiv = document.createElement('div'); - rowDiv.style.display = 'flex'; - rowDiv.style.flexWrap = 'wrap'; - rowDiv.style.marginBottom = '5px'; - - // Process each cell in the row - const cells = row.querySelectorAll('td, th'); - cells.forEach(cell => { - const cellDiv = document.createElement('div'); - cellDiv.style.flex = '1'; - cellDiv.style.padding = '5px'; - cellDiv.style.borderBottom = '1px solid #eee'; - cellDiv.innerHTML = cell.innerHTML; - rowDiv.appendChild(cellDiv); - }); - - replacementDiv.appendChild(rowDiv); - }); - - // If no rows were processed, just use the table's inner HTML - if (rows.length === 0) { - replacementDiv.innerHTML = table.innerHTML; - } - - // Replace the table with the div - if (table.parentNode) { - table.parentNode.replaceChild(replacementDiv, table); - convertedCount++; - } - } - // For regular content, just add width attributes to make quill-better-table happy - else { - // Skip simple tables that are likely to work fine with Quill - // Check more conditions to identify simple tables - const isSimpleTable = - table.rows.length <= 3 && - table.querySelectorAll('td, th').length <= 6 && - !table.querySelector('table') && // No nested tables - !table.innerHTML.includes('rowspan') && // No rowspan - !table.innerHTML.includes('colspan'); // No colspan - - if (isSimpleTable) { - console.log('Preserving simple table structure'); - // Add width attribute - table.setAttribute('width', '100%'); - - // Add width to cells - const cells = table.querySelectorAll('td, th'); - cells.forEach(cell => { - if (cell instanceof HTMLTableCellElement && !cell.hasAttribute('width')) { - cell.setAttribute('width', '100'); - } - }); - } else { - // Convert complex tables to divs - const replacementDiv = document.createElement('div'); - replacementDiv.className = 'converted-table'; - replacementDiv.style.border = '1px solid #ddd'; - replacementDiv.style.margin = '10px 0'; - replacementDiv.style.padding = '10px'; - - // Copy the table's innerHTML - replacementDiv.innerHTML = table.innerHTML; - - // Replace the table with the div - if (table.parentNode) { - table.parentNode.replaceChild(replacementDiv, table); - convertedCount++; - } - } - } - }); - - console.log(`Converted ${convertedCount} tables to divs to prevent Quill errors`); - return tempDiv.innerHTML; - } - - return htmlContent; - } catch (error) { - console.error('Error cleaning up table structures:', error); - return htmlContent; - } -} - -// Define toolbar options for consistency -const emailToolbarOptions = [ - ['bold', 'italic', 'underline', 'strike'], - [{ 'color': [] }, { 'background': [] }], - [{ 'list': 'ordered'}, { 'list': 'bullet' }], - [{ 'indent': '-1'}, { 'indent': '+1' }], - [{ 'align': [] }], - [{ 'direction': 'rtl' }], - ['link'], - ['clean'], -]; - const RichEmailEditor: React.FC= ({ initialContent, onChange, @@ -191,329 +22,201 @@ const RichEmailEditor: React.FC = ({ minHeight = '200px', maxHeight = 'calc(100vh - 400px)', preserveFormatting = false, - autofocus = false, - mode = 'compose', - allowedFormats, - customKeyBindings, }) => { const editorRef = useRef (null); const toolbarRef = useRef (null); const quillRef = useRef (null); - const quillInitTimeoutRef = useRef (null); const [isReady, setIsReady] = useState(false); - const [isReplyOrForward, setIsReplyOrForward] = useState(false); - // Helper function to clean up existing editor - const cleanupEditor = () => { - try { - // Clear any existing timeouts - if (quillInitTimeoutRef.current) { - clearTimeout(quillInitTimeoutRef.current); - quillInitTimeoutRef.current = null; + // Initialize Quill editor when component mounts + useEffect(() => { + // Import Quill dynamically (client-side only) + const initializeQuill = async () => { + if (!editorRef.current || !toolbarRef.current) return; + + const Quill = (await import('quill')).default; + + // Import quill-better-table + let tableModule = null; + try { + const QuillBetterTable = await import('quill-better-table'); + + // Register the table module if available + if (QuillBetterTable && QuillBetterTable.default) { + Quill.register({ + 'modules/better-table': QuillBetterTable.default + }, true); + tableModule = QuillBetterTable.default; + console.log('Better Table module registered successfully'); + } + } catch (err) { + console.warn('Table module not available:', err); } - // Remove existing Quill instance if it exists - if (quillRef.current) { - console.log('Cleaning up existing Quill editor'); - - // Remove event listeners + // Define custom formats/modules with table support + const emailToolbarOptions = [ + ['bold', 'italic', 'underline', 'strike'], + [{ 'color': [] }, { 'background': [] }], + [{ 'list': 'ordered'}, { 'list': 'bullet' }], + [{ 'indent': '-1'}, { 'indent': '+1' }], + [{ 'align': [] }], + [{ 'direction': 'rtl' }], // Add direction to toolbar + ['link'], + ['clean'], + ]; + + // Create new Quill instance with the DOM element and custom toolbar + const editorElement = editorRef.current; + quillRef.current = new Quill(editorElement, { + modules: { + toolbar: { + container: toolbarRef.current, + handlers: { + // Add any custom toolbar handlers here + } + }, + clipboard: { + matchVisual: false // Disable clipboard matching for better HTML handling + }, + // Don't initialize better-table yet - we'll do it after content is loaded + 'better-table': false, + }, + placeholder: placeholder, + theme: 'snow', + }); + + // Set initial content properly + if (initialContent) { try { - quillRef.current.off('text-change'); - quillRef.current.off('selection-change'); + console.log('Setting initial content in editor', { + length: initialContent.length, + startsWithHtml: initialContent.trim().startsWith('<'), + containsForwardedMessage: initialContent.includes('---------- Forwarded message ----------'), + containsReplyIndicator: initialContent.includes('wrote:'), + hasBlockquote: initialContent.includes(' { + if (el instanceof HTMLElement) { + el.scrollTop = 0; + } + }); } } catch (err) { - console.warn('Error removing Quill event listeners:', err); - } - - // Set to null to ensure garbage collection - quillRef.current = null; - } - - return true; - } catch (error) { - console.error('Error cleaning up editor:', error); - return false; - } - }; - - // Initialize editor effect - useEffect(() => { - let editorInstance: any = null; - let initialContentSet = false; - let initializationTimeout: NodeJS.Timeout | null = null; - - const initEditor = async () => { - try { - // First cleanup any existing editor instances to prevent memory leaks - cleanupEditor(); - - if (!editorRef.current) { - console.error('Editor reference is not available'); - return; - } - - // Log the initialization - console.log('Initializing editor in', mode, 'mode'); - - // Clear any existing content - editorRef.current.innerHTML = ''; - - // Register better table module - registerTableModule(); - console.log('Better Table module registered successfully'); - - // Create a modules configuration that works - const modules = { - toolbar: emailToolbarOptions, - keyboard: { - bindings: customKeyBindings, - } - }; - - // Set up Quill with configurations - const quill = new Quill(editorRef.current, { - modules, - theme: 'snow', - placeholder: placeholder || 'Write your message here...', - formats: allowedFormats, - }); - - // Store the instance for cleanup - quillRef.current = quill; - editorInstance = quill; - - // Process and set initial content if available - if (initialContent) { + console.error('Error setting initial content:', err); + + // Enhanced fallback mechanism for complex content try { - console.log('Setting initial editor content'); + // First try to extract text from HTML + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = initialContent; + const textContent = tempDiv.textContent || tempDiv.innerText || ''; - // Process the content to handle blocked elements like CID images - const processedContent = handleBlockedContent(initialContent); - - // Use dangerouslyPasteHTML which accepts string content directly - quill.clipboard.dangerouslyPasteHTML(processedContent); - - // Emit initial content with the string HTML - if (onChange) { - onChange(quill.root.innerHTML); - } - - initialContentSet = true; - } catch (err) { - console.error('Error setting initial content:', err); - - // Fallback to direct HTML setting if conversion fails - try { - quill.root.innerHTML = handleBlockedContent(initialContent); - - // Still emit the change even with the fallback approach - if (onChange) { - onChange(quill.root.innerHTML); - } - } catch (innerErr) { - console.error('Fallback content setting failed:', innerErr); - quill.root.innerHTML = 'Error loading content. Please start typing or paste content manually.
'; - - // Emit the fallback content - if (onChange) { - onChange(quill.root.innerHTML); - } + if (textContent.trim()) { + console.log('Using extracted text fallback, length:', textContent.length); + quillRef.current.setText(textContent); + } else { + // If text extraction fails or returns empty, provide a message + console.log('Using empty content fallback'); + quillRef.current.setText('Unable to load original content'); } + } catch (e) { + console.error('All fallbacks failed:', e); + quillRef.current.setText('Error loading content'); } } - - // Handle content change events - quill.on('text-change', (delta: any, oldDelta: any, source: string) => { - if (source === 'user') { - const html = quill.root.innerHTML; - onChange(html); - } - }); - - // Set focus if autofocus is true - if (autofocus) { - setTimeout(() => { - quill.focus(); - quill.setSelection(0, 0); - }, 100); - } - - // Mark editor as ready - setIsReady(true); - } catch (error) { - console.error('Error initializing editor:', error); } - }; - - // Set a timeout to prevent endless waiting for editor initialization - initializationTimeout = setTimeout(() => { - if (!initialContentSet && editorRef.current) { - console.warn('Editor initialization timed out after 3 seconds'); - - // Force cleanup and try one more time with minimal settings - cleanupEditor(); - - try { - // Create a simple editor without complex modules - const fallbackQuill = new Quill(editorRef.current, { - theme: 'snow', - placeholder: placeholder || 'Write your message here...', - }); - - // Add a simple placeholder message - fallbackQuill.root.innerHTML = initialContent || 'Editor ready. Start typing...
'; - - // Handle content change events - fallbackQuill.on('text-change', () => { - const html = fallbackQuill.root.innerHTML; - onChange(html); - }); - - quillRef.current = fallbackQuill; - editorInstance = fallbackQuill; - setIsReady(true); - } catch (fallbackError) { - console.error('Fallback editor initialization also failed:', fallbackError); - } - } - }, 3000); - - // Store timeout reference to allow cleanup - quillInitTimeoutRef.current = initializationTimeout; - - // Initialize the editor - initEditor(); - - // Cleanup function - return () => { - if (initializationTimeout) { - clearTimeout(initializationTimeout); - } - - if (quillInitTimeoutRef.current) { - clearTimeout(quillInitTimeoutRef.current); - quillInitTimeoutRef.current = null; - } - - if (editorInstance) { - try { - // Remove event listeners - editorInstance.off('text-change'); - - // Properly destroy Quill instance if possible - if (editorInstance.emitter) { - editorInstance.emitter.removeAllListeners(); - } - } catch (error) { - console.error('Error during editor cleanup:', error); - } - } - - // Final cleanup of any Quill instances - cleanupEditor(); - }; - }, [initialContent, onChange, placeholder, autofocus, mode, allowedFormats, customKeyBindings]); - // Handle blocked content like CID images that cause loading issues - function handleBlockedContent(htmlContent: string): string { - if (!htmlContent) return ''; - - try { - // Create a DOM parser to work with the HTML - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlContent, 'text/html'); - - // Count images processed for logging - let imageCount = 0; - let cidCount = 0; - - // Process all images to prevent loading issues - const images = doc.querySelectorAll('img'); - images.forEach(img => { - const src = img.getAttribute('src'); - imageCount++; - - // Handle CID images which cause loading issues - if (src && src.startsWith('cid:')) { - cidCount++; - // Replace with placeholder or remove src - img.setAttribute('src', 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"%3E%3Cpath fill="%23ccc" d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/%3E%3C/svg%3E'); - img.setAttribute('data-original-cid', src); - img.setAttribute('title', 'Image reference not available'); - img.setAttribute('alt', 'Image placeholder'); - img.style.border = '1px dashed #ccc'; - img.style.padding = '4px'; - img.style.maxWidth = '100%'; - } - - // Handle possible blocked remote images - if (src && (src.startsWith('http://') || src.startsWith('https://'))) { - // Set alt text and a class for better UX - if (!img.getAttribute('alt')) { - img.setAttribute('alt', 'Remote image'); - } - img.classList.add('remote-image'); - } + // Add change listener + quillRef.current.on('text-change', () => { + const html = quillRef.current.root.innerHTML; + onChange(html); }); - - // Process table elements to ensure consistent styling - const tables = doc.querySelectorAll('table'); - tables.forEach(table => { - // Ensure tables have consistent styling to prevent layout issues - const htmlTable = table as HTMLTableElement; - htmlTable.style.borderCollapse = 'collapse'; - htmlTable.style.maxWidth = '100%'; - - // Handle cell padding and borders for better readability - const cells = htmlTable.querySelectorAll('td, th'); - cells.forEach(cell => { - const htmlCell = cell as HTMLTableCellElement; - if (!htmlCell.style.padding) { - htmlCell.style.padding = '4px 8px'; - } - // Only add border if not already styled - if (!htmlCell.style.border) { - htmlCell.style.border = '1px solid #e0e0e0'; - } - }); - }); - - // Ensure all blockquotes have consistent styling - const blockquotes = doc.querySelectorAll('blockquote'); - blockquotes.forEach(blockquote => { - const htmlBlockquote = blockquote as HTMLElement; - if (!htmlBlockquote.style.borderLeft) { - htmlBlockquote.style.borderLeft = '3px solid #ddd'; - } - if (!htmlBlockquote.style.paddingLeft) { - htmlBlockquote.style.paddingLeft = '10px'; - } - if (!htmlBlockquote.style.margin) { - htmlBlockquote.style.margin = '10px 0'; - } - if (!htmlBlockquote.style.color) { - htmlBlockquote.style.color = '#505050'; - } - }); - - // Log the number of images processed for debugging - if (imageCount > 0) { - console.log(`Processed ${imageCount} images in content (${cidCount} CID images replaced)`); + + // Improve editor layout + const editorContainer = editorElement.closest('.ql-container'); + if (editorContainer) { + editorContainer.classList.add('email-editor-container'); } - - // Return the cleaned HTML - return doc.body.innerHTML; - } catch (error) { - console.error('Error processing blocked content:', error); - // Return original content if processing fails - return htmlContent; - } - } + + setIsReady(true); + }; + + initializeQuill().catch(err => { + console.error('Failed to initialize Quill editor:', err); + }); + + // Clean up on unmount + return () => { + if (quillRef.current) { + // Clean up any event listeners or resources + quillRef.current.off('text-change'); + } + }; + }, []); // Update content from props if changed externally - using a simpler approach useEffect(() => { @@ -533,57 +236,11 @@ const RichEmailEditor: React.FC= ({ firstNChars: initialContent.substring(0, 100).replace(/\n/g, '\\n') }); - // Check if content is reply or forward to use special handling - const contentIsReplyOrForward = - initialContent.includes('wrote:') || - initialContent.includes(' { - // Explicitly empty out the editor DOM node to ensure clean start - if (editorRef.current) { - while (editorRef.current.firstChild) { - editorRef.current.removeChild(editorRef.current.firstChild); - } - } - }, 50); - - return; - } - - // Pre-process to handle blocked content - const preProcessedContent = handleBlockedContent(initialContent); + // Detect text direction + const direction = detectTextDirection(initialContent); // Process HTML content using centralized utility - const processed = processHtmlContent(preProcessedContent, { - sanitize: true, - preserveReplyFormat: contentIsReplyOrForward - }); - const sanitizedContent = processed.sanitizedContent; - const direction = processed.direction; // Use direction from processed result + const sanitizedContent = processHtmlContent(initialContent); // Log sanitized content details for debugging console.log('Sanitized content details:', { @@ -594,7 +251,6 @@ const RichEmailEditor: React.FC= ({ containsQuoteHeader: sanitizedContent.includes('wrote:'), hasTable: sanitizedContent.includes(' = ({ console.warn('Sanitized content is empty, using original content'); // If sanitized content is empty, try to extract text from original const tempDiv = document.createElement('div'); - tempDiv.innerHTML = preProcessedContent; + tempDiv.innerHTML = initialContent; const textContent = tempDiv.textContent || tempDiv.innerText || ''; // Create simple HTML with text content @@ -611,61 +267,32 @@ const RichEmailEditor: React.FC
= ({ quillRef.current.setText(textContent || 'No content available'); } } else { - // Process content based on type - if (contentIsReplyOrForward) { - console.log('Using special handling for reply/forward content update'); - - // For reply/forward content, convert ALL tables to divs - const cleanedContent = cleanupTableStructures(sanitizedContent, true); - - // Set content without table handling + // SIMPLIFIED: Set content directly to the root element rather than using clipboard if (quillRef.current && quillRef.current.root) { - quillRef.current.root.innerHTML = cleanedContent; + // First set the content + quillRef.current.root.innerHTML = sanitizedContent; + + // Then safely apply formatting only if quillRef is valid + try { + if (quillRef.current && quillRef.current.format && quillRef.current.root.innerHTML.trim().length > 0) { + // Set the direction for the content + quillRef.current.format('direction', direction); + if (direction === 'rtl') { + quillRef.current.format('align', 'right'); + } - // Delay applying formatting to ensure Quill is fully ready - setTimeout(() => { - if (quillRef.current) { - try { - // Set the direction for the content - quillRef.current.format('direction', direction); - if (direction === 'rtl') { - quillRef.current.format('align', 'right'); - } - - // Force update - quillRef.current.update(); - - // Set selection to beginning - quillRef.current.setSelection(0, 0); - } catch (innerError) { - console.error('Error applying delayed formatting:', innerError); - } - } - }, 100); + // Force update + quillRef.current.update(); + + // Set selection to beginning + quillRef.current.setSelection(0, 0); + } else { + console.warn('Skipping format - either editor not ready or content empty'); } - } else { - // For regular content, use normal processing - const cleanedContent = cleanupTableStructures(sanitizedContent, false); - - if (quillRef.current && quillRef.current.root) { - quillRef.current.root.innerHTML = cleanedContent; - - // Safely apply formatting - try { - quillRef.current.format('direction', direction); - if (direction === 'rtl') { - quillRef.current.format('align', 'right'); - } - - // Force update - quillRef.current.update(); - - // Set selection to beginning - quillRef.current.setSelection(0, 0); } catch (formatError) { console.error('Error applying formatting:', formatError); - } - } + // Continue without formatting if there's an error + } } } } catch (err) { @@ -690,7 +317,7 @@ const RichEmailEditor: React.FC = ({ } } } - }, [initialContent, isReady, isReplyOrForward]); + }, [initialContent, isReady]); return ( diff --git a/hooks/use-email-state.ts b/hooks/use-email-state.ts index 088e01e9..fa08e11c 100644 --- a/hooks/use-email-state.ts +++ b/hooks/use-email-state.ts @@ -61,84 +61,6 @@ export const useEmailState = () => { } }, []); - // Normalize email content structure to ensure consistency - const normalizeEmailContent = useCallback((emailData: any): any => { - if (!emailData) return emailData; - - // Create a clone to avoid modifying the original - const normalizedEmail = { ...emailData }; - - // Log the incoming email structure - console.log(`[NORMALIZE_EMAIL] Processing email ${normalizedEmail.id || 'unknown'}: ${normalizedEmail.subject || 'No subject'}`); - - try { - // Handle content field normalization - if (!normalizedEmail.content) { - // Create content object if it doesn't exist - normalizedEmail.content = { html: '', text: '' }; - - // Try to populate content from html/text fields - if (normalizedEmail.html) { - normalizedEmail.content.html = normalizedEmail.html; - console.log(`[NORMALIZE_EMAIL] Populated content.html from email.html (${normalizedEmail.html.length} chars)`); - } - - if (normalizedEmail.text) { - normalizedEmail.content.text = normalizedEmail.text; - console.log(`[NORMALIZE_EMAIL] Populated content.text from email.text (${normalizedEmail.text.length} chars)`); - } - } - // If content is a string, convert to object format - else if (typeof normalizedEmail.content === 'string') { - const htmlContent = normalizedEmail.content; - normalizedEmail.content = { - html: htmlContent, - text: htmlContent.replace(/<[^>]*>/g, '') // Simple HTML to text conversion - }; - console.log(`[NORMALIZE_EMAIL] Converted string content to object (${htmlContent.length} chars)`); - } - // Ensure content object has both html and text properties - else if (typeof normalizedEmail.content === 'object') { - if (!normalizedEmail.content.html && normalizedEmail.content.text) { - // Convert text to simple HTML if only text exists - normalizedEmail.content.html = normalizedEmail.content.text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\n/g, '
'); - console.log(`[NORMALIZE_EMAIL] Created HTML content from text (${normalizedEmail.content.text.length} chars)`); - } else if (!normalizedEmail.content.text && normalizedEmail.content.html) { - // Create plain text version if only HTML exists - normalizedEmail.content.text = normalizedEmail.content.html.replace(/<[^>]*>/g, ''); - console.log(`[NORMALIZE_EMAIL] Created text content from HTML (${normalizedEmail.content.html.length} chars)`); - } - } - - // Ensure html and text properties are also set for backward compatibility - if (normalizedEmail.content?.html && !normalizedEmail.html) { - normalizedEmail.html = normalizedEmail.content.html; - } - - if (normalizedEmail.content?.text && !normalizedEmail.text) { - normalizedEmail.text = normalizedEmail.content.text; - } - - console.log(`[NORMALIZE_EMAIL] Normalized email content structure successfully`, { - hasContentObj: !!normalizedEmail.content, - contentHtmlLength: normalizedEmail.content?.html?.length || 0, - contentTextLength: normalizedEmail.content?.text?.length || 0, - hasHtml: !!normalizedEmail.html, - hasText: !!normalizedEmail.text - }); - - return normalizedEmail; - } catch (error) { - console.error(`[NORMALIZE_EMAIL] Error normalizing email content:`, error); - // Return the original data if normalization fails - return emailData; - } - }, []); - // 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 @@ -155,407 +77,194 @@ export const useEmailState = () => { dispatch({ type: 'SET_LOADING', payload: true }); try { - // CRITICAL FIX: Add more robust validation to prevent "toString of undefined" error - if (!state.currentFolder) { - logEmailOp('ERROR', 'Current folder is undefined, cannot load emails'); - dispatch({ - type: 'SET_ERROR', - payload: 'Invalid folder configuration' - }); - dispatch({ type: 'SET_LOADING', payload: false }); - return; - } - // 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); - // Additional validation for accountId - if (accountId === undefined && state.currentFolder.includes(':')) { - // Try to extract accountId from folder string as fallback - const extractedAccountId = state.currentFolder.split(':')[0]; - if (extractedAccountId) { - console.log(`[DEBUG-LOAD_EMAILS] Using extracted accountId ${extractedAccountId} from folder path as fallback`); - const { normalizedFolder, effectiveAccountId, prefixedFolder } = - normalizeFolderAndAccount(state.currentFolder, extractedAccountId); - - logEmailOp('LOAD_EMAILS', `Loading emails for ${prefixedFolder} (account: ${effectiveAccountId}, isLoadMore: ${isLoadMore}, page: ${page})`); + 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`); - // Continue with the extracted account ID... - // 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?${queryParams.toString()}`); - const response = await fetch(`/api/courrier?${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 appropriate action based on if we're loading more - DO NOT OVERRIDE isLoadMore! dispatch({ type: isLoadMore ? 'APPEND_EMAILS' : 'SET_EMAILS', - payload: Array.isArray(data.emails) ? data.emails : [] + payload: cachedEmails.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 }); + // Set pagination info from cache if available + if (cachedEmails.totalEmails) { + dispatch({ type: 'SET_TOTAL_EMAILS', payload: cachedEmails.totalEmails }); } - if (data.totalPages) { - dispatch({ type: 'SET_TOTAL_PAGES', payload: data.totalPages }); + if (cachedEmails.totalPages) { + dispatch({ type: 'SET_TOTAL_PAGES', payload: cachedEmails.totalPages }); } // Update available mailboxes if provided - if (data.mailboxes && data.mailboxes.length > 0) { - dispatch({ type: 'SET_MAILBOXES', payload: data.mailboxes }); + if (cachedEmails.mailboxes && cachedEmails.mailboxes.length > 0) { + dispatch({ type: 'SET_MAILBOXES', payload: cachedEmails.mailboxes }); } - } else { - // If we can't extract a valid accountId, throw an error - throw new Error("Cannot determine account ID for loading emails"); } - } else { - // Normal flow with valid accountId - 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 + // CRITICAL FIX: If this was a loadMore operation, check the result after the dispatch if (isLoadMore) { - console.log(`[DEBUG-PAGINATION] Loading more emails. Current page: ${page}, existing emails: ${state.emails.length}`); + setTimeout(() => { + console.log(`[DEBUG-CACHE_HIT_APPEND] After ${isLoadMore ? 'APPEND' : 'SET'}, email count is now: ${state.emails.length}`); + }, 0); } - // 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?${queryParams.toString()}`); + const response = await fetch(`/api/courrier?${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; } - // 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?${queryParams.toString()}`); - const response = await fetch(`/api/courrier?${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 }); - } + 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; } - // 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() - })) - ); + // 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; } - 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(); + // 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 }); - } + } + + // 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)}`); @@ -646,11 +355,9 @@ export const useEmailState = () => { if (existingEmail && existingEmail.contentFetched) { // Use the existing email if it has content already - // ENHANCEMENT: Apply content normalization before selecting the email - const normalizedEmail = normalizeEmailContent(existingEmail); dispatch({ type: 'SELECT_EMAIL', - payload: { emailId, accountId, folder, email: normalizedEmail } + payload: { emailId, accountId, folder, email: existingEmail } }); // Mark as read if not already @@ -679,13 +386,10 @@ export const useEmailState = () => { // Mark the email as read on the server markEmailAsRead(emailId, true, effectiveAccountId); - // ENHANCEMENT: Apply content normalization before selecting the email - const normalizedEmailData = normalizeEmailContent(emailData); - // Select the email dispatch({ type: 'SELECT_EMAIL', - payload: { emailId, accountId: effectiveAccountId, folder, email: normalizedEmailData } + payload: { emailId, accountId: effectiveAccountId, folder, email: emailData } }); } catch (error) { logEmailOp('ERROR', `Failed to select email: ${error instanceof Error ? error.message : String(error)}`); @@ -696,7 +400,7 @@ export const useEmailState = () => { } finally { dispatch({ type: 'SET_LOADING', payload: false }); } - }, [state.emails, logEmailOp, normalizeEmailContent]); + }, [state.emails, logEmailOp]); // Toggle email selection for multi-select const toggleEmailSelection = useCallback((emailId: string) => { diff --git a/lib/services/email-service.ts b/lib/services/email-service.ts index 1ea56362..94fd30ab 100644 --- a/lib/services/email-service.ts +++ b/lib/services/email-service.ts @@ -718,32 +718,9 @@ export async function getEmailContent( // Convert flags from Set to boolean checks const flagsArray = Array.from(flags as Set); - // Process the raw HTML with CID attachments + // Preserve the raw HTML exactly as it was in the original email const rawHtml = parsedEmail.html || ''; - // Import processHtmlContent if needed - const { processHtmlContent } = await import('../utils/email-content'); - - // Process HTML content with attachments for CID image handling - let processedHtml = rawHtml; - let direction = 'ltr'; - - if (rawHtml) { - const processed = processHtmlContent(rawHtml, { - sanitize: true, - blockExternalContent: false, - attachments: parsedEmail.attachments?.map(att => ({ - filename: att.filename || 'attachment', - contentType: att.contentType, - content: att.content?.toString('base64'), // Convert Buffer to base64 string - contentId: att.contentId - })) - }); - - processedHtml = processed.sanitizedContent; - direction = processed.direction; - } - const email: EmailMessage = { id: emailId, messageId: envelope.messageId, @@ -776,15 +753,13 @@ export async function getEmailContent( attachments: parsedEmail.attachments?.map(att => ({ filename: att.filename || 'attachment', contentType: att.contentType, - contentId: att.contentId, - content: att.content?.toString('base64'), size: att.size || 0 })), content: { text: parsedEmail.text || '', - html: processedHtml || '', - isHtml: !!processedHtml, - direction + html: rawHtml || '', + isHtml: !!rawHtml, + direction: 'ltr' // Default to left-to-right }, folder: normalizedFolder, contentFetched: true, diff --git a/lib/utils/dom-purify-config.ts b/lib/utils/dom-purify-config.ts index 47842171..d7409634 100644 --- a/lib/utils/dom-purify-config.ts +++ b/lib/utils/dom-purify-config.ts @@ -11,103 +11,64 @@ import DOMPurify from 'isomorphic-dompurify'; // Reset any existing hooks to start with a clean slate DOMPurify.removeAllHooks(); -/** - * Configure DOMPurify with safe defaults for email content - * This balances security with the need to display rich email content - */ -export function configureDOMPurify() { - // Enhanced configuration for email content - DOMPurify.setConfig({ - ADD_TAGS: [ - // SVG elements for simple charts/logos that might be in emails - 'svg', 'path', 'g', 'circle', 'rect', 'line', 'polygon', 'ellipse', - // Common email-specific elements - 'o:p', 'font', - // Allow comments for conditional HTML in emails - '!--...--' - ], - ADD_ATTR: [ - // SVG attributes - 'viewbox', 'd', 'cx', 'cy', 'r', 'fill', 'stroke', 'stroke-width', 'x', 'y', 'width', 'height', - // Additional HTML attributes commonly used in emails - 'align', 'valign', 'bgcolor', 'color', 'cellpadding', 'cellspacing', 'colspan', 'rowspan', - 'face', 'size', 'direction', 'role', 'aria-label', 'aria-hidden', - // List attributes - 'start', 'type', 'value', - // Table attributes and styles - 'border', 'frame', 'rules', 'summary', 'headers', 'scope', 'abbr', - // Blockquote attributes - 'cite', 'datetime', - // Form elements attributes (read-only) - 'readonly', 'disabled', 'selected', 'checked', 'multiple', 'wrap', - // Additional attributes for forwarded emails - 'style', 'class', 'id', 'dir', 'lang', 'title', - // Table attributes commonly used in email clients - 'background', 'bordercolor', 'width', 'height' - ], - FORBID_TAGS: [ - // Remove dangerous tags - 'script', 'object', 'iframe', 'embed', 'applet', 'meta', 'link', - // Form elements that could be used for phishing - 'form', 'button', 'input', 'textarea', 'select', 'option' - ], - FORBID_ATTR: [ - // Remove JavaScript and dangerous attributes - 'onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', 'onmouseenter', 'onmouseleave', - 'onkeydown', 'onkeypress', 'onkeyup', 'onchange', 'onsubmit', 'onreset', 'onselect', 'onblur', - 'onfocus', 'onscroll', 'onbeforeunload', 'onunload', 'onhashchange', 'onpopstate', 'onpageshow', - 'onpagehide', 'onabort', 'oncanplay', 'oncanplaythrough', 'ondurationchange', 'onemptied', - 'onended', 'onloadeddata', 'onloadedmetadata', 'onloadstart', 'onpause', 'onplay', 'onplaying', - 'onprogress', 'onratechange', 'onseeked', 'onseeking', 'onstalled', 'onsuspend', 'ontimeupdate', - 'onvolumechange', 'onwaiting', 'animationend', 'animationiteration', 'animationstart', - // Dangerous attributes - 'formaction', 'xlink:href' - ], - ALLOW_DATA_ATTR: false, // Disable data-* attributes which can be used for XSS - WHOLE_DOCUMENT: false, // Don't parse the entire document - just fragments - SANITIZE_DOM: true, // Sanitize the DOM to prevent XSS - KEEP_CONTENT: true, // Keep content of elements that are removed - RETURN_DOM: false, // Return a DOM object rather than HTML string - RETURN_DOM_FRAGMENT: false, // Return a DocumentFragment rather than HTML string - FORCE_BODY: false, // Add a tag if one doesn't exist - ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|data|irc):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, - ALLOW_UNKNOWN_PROTOCOLS: true, // Some email clients use custom protocols for images/attachments - USE_PROFILES: { html: true } // Use the HTML profile for more permissive sanitization - }); - - return DOMPurify; -} - -// Singleton instance of configured DOMPurify for the app -export const purify = configureDOMPurify(); +// Configure DOMPurify with settings appropriate for email content +DOMPurify.setConfig({ + ADD_TAGS: [ + 'html', 'head', 'body', 'style', 'link', 'meta', 'title', + 'table', 'caption', 'col', 'colgroup', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', + 'div', 'span', 'img', 'br', 'hr', 'section', 'article', 'header', 'footer', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'blockquote', 'pre', 'code', + 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a', 'b', 'i', 'u', 'em', + 'strong', 'del', 'ins', 'mark', 'small', 'sub', 'sup', 'q', 'abbr', + 'font' // Allow legacy font tag often found in emails + ], + ADD_ATTR: [ + 'style', 'class', 'id', 'name', 'href', 'src', 'alt', 'title', 'width', 'height', + 'border', 'cellspacing', 'cellpadding', 'bgcolor', 'background', 'color', + 'align', 'valign', 'dir', 'lang', 'target', 'rel', 'charset', 'media', + 'colspan', 'rowspan', 'scope', 'span', 'size', 'face', 'hspace', 'vspace', + 'data-*', + 'start', 'type', 'value', 'cite', 'datetime', 'wrap', 'summary' + ], + KEEP_CONTENT: true, + WHOLE_DOCUMENT: false, + ALLOW_DATA_ATTR: true, + ALLOW_UNKNOWN_PROTOCOLS: true, // Needed for some email clients + FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'select', 'textarea'], + FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout'], + FORCE_BODY: false, + USE_PROFILES: { html: true } // Use HTML profile for more permissive sanitization for emails +}); /** - * Sanitize HTML content using our email-specific configuration + * Sanitizes HTML content with the centralized DOMPurify configuration + * @param html HTML content to sanitize + * @returns Sanitized HTML */ -export function sanitizeHtml(content: string, options?: { preserveReplyFormat?: boolean }): string { - if (!content) return ''; +export function sanitizeHtml(html: string): string { + if (!html) return ''; try { - // Special handling for reply/forward emails to be less aggressive with sanitization - const extraTags = options?.preserveReplyFormat - ? ['style', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'td', 'th'] - : ['style']; - - const extraAttrs = options?.preserveReplyFormat - ? ['style', 'class', 'align', 'valign', 'bgcolor', 'colspan', 'rowspan', 'width', 'height', 'border'] - : ['style', 'class']; - - // Sanitize with our configured instance and options - return purify.sanitize(content, { - ADD_TAGS: extraTags, - ADD_ATTR: extraAttrs + // Use DOMPurify with our central configuration + const clean = DOMPurify.sanitize(html, { + ADD_ATTR: ['style', 'class', 'id', 'align', 'valign', 'colspan', 'rowspan', 'cellspacing', 'cellpadding', 'bgcolor'] }); - } catch (error) { - console.error('Failed to sanitize HTML content:', error); - // Fallback to basic sanitization - return content + + // Fix common email rendering issues + const fixedHtml = clean + // Fix for Outlook WebVML content + .replace(/