diff --git a/components/email/EmailContentDisplay.tsx b/components/email/EmailContentDisplay.tsx index 50986449..c1e4c54e 100644 --- a/components/email/EmailContentDisplay.tsx +++ b/components/email/EmailContentDisplay.tsx @@ -1,51 +1,97 @@ 'use client'; -import React, { useEffect, useState, useRef } from 'react'; -import DOMPurify from 'isomorphic-dompurify'; -import { parseRawEmail } from '@/lib/utils/email-mime-decoder'; +import React, { useEffect, useState } from 'react'; +import DOMPurify from 'dompurify'; +import { formatEmailContent } from '@/lib/utils/email-content'; +import { sanitizeHtml } from '@/lib/utils/email-formatter'; -interface EmailContentDisplayProps { - content: string; - type?: 'html' | 'text' | 'auto'; - className?: string; - showQuotedText?: boolean; +/** + * Interface for email content types + */ +interface ProcessedContent { + html: string; + text: string; + isHtml: boolean; } /** - * Component for displaying properly formatted email content - * Handles MIME decoding, sanitization, and proper rendering + * Interface for component props + */ +interface EmailContentDisplayProps { + content: string; + type?: 'html' | 'text' | 'auto'; + isRawEmail?: boolean; + className?: string; +} + +/** + * Parse raw email content into HTML and text parts + * This is a helper function used when processing raw email formats + */ +function parseRawEmail(rawContent: string): { html: string; text: string } { + // Simple parser for demonstration - in production, use a proper MIME parser + const hasHtmlPart = rawContent.includes('/i) || + rawContent.match(//i); + + if (htmlMatch) { + htmlPart = htmlMatch[0]; + } else { + // Fallback extraction + const parts = rawContent.split(/(?:--boundary|\r\n\r\n)/); + htmlPart = parts.find(part => + part.includes('Content-Type: text/html') || + part.includes(']+>/g, ' ').replace(/\s+/g, ' ').trim() + }; + } + + return { + html: '', + text: rawContent + }; +} + +/** + * EmailContentDisplay component - displays formatted email content + * with proper security, styling and support for different email formats */ const EmailContentDisplay: React.FC = ({ content, type = 'auto', - className = '', - showQuotedText = true + isRawEmail = false, + className = '' }) => { - const [processedContent, setProcessedContent] = useState<{ - html: string; - text: string; - isHtml: boolean; - }>({ + const [processedContent, setProcessedContent] = useState({ html: '', text: '', isHtml: false }); - const containerRef = useRef(null); - - // Process and sanitize email content + // Process the email content when it changes useEffect(() => { if (!content) { - setProcessedContent({ html: '', text: '', isHtml: false }); + setProcessedContent({ + html: '', + text: '', + isHtml: false + }); return; } try { - // Check if this is raw email content - const isRawEmail = content.includes('Content-Type:') || - content.includes('MIME-Version:') || - content.includes('From:') && content.includes('To:'); - if (isRawEmail) { // Parse raw email content const parsed = parseRawEmail(content); @@ -54,12 +100,8 @@ const EmailContentDisplay: React.FC = ({ const useHtml = (type === 'html' || (type === 'auto' && parsed.html)) && !!parsed.html; if (useHtml) { - // Sanitize HTML content - const sanitizedHtml = DOMPurify.sanitize(parsed.html, { - ADD_TAGS: ['table', 'thead', 'tbody', 'tr', 'td', 'th'], - ADD_ATTR: ['target', 'rel', 'colspan', 'rowspan'], - ALLOW_DATA_ATTR: false - }); + // Use the enhanced sanitizeHtml function from email-formatter + const sanitizedHtml = sanitizeHtml(parsed.html); setProcessedContent({ html: sanitizedHtml, @@ -67,8 +109,9 @@ const EmailContentDisplay: React.FC = ({ isHtml: true }); } else { - // Format plain text with line breaks - const formattedText = parsed.text.replace(/\n/g, '
'); + // Format plain text properly + const formattedText = formatEmailContent({ text: parsed.text }); + setProcessedContent({ html: formattedText, text: parsed.text, @@ -77,28 +120,29 @@ const EmailContentDisplay: React.FC = ({ } } else { // Treat as direct content (not raw email) - const isHtmlContent = content.includes('') || - content.includes('') || + content.includes(']+>/g, ' ').replace(/\s+/g, ' ').trim(), isHtml: true }); } else { - // Format plain text with line breaks - const formattedText = content.replace(/\n/g, '
'); + // Format plain text properly using formatEmailContent + const formattedText = formatEmailContent({ text: content }); + setProcessedContent({ html: formattedText, text: content, @@ -108,89 +152,29 @@ const EmailContentDisplay: React.FC = ({ } } catch (err) { console.error('Error processing email content:', err); - // Fallback to plain text + // Fallback to plain text with basic formatting setProcessedContent({ - html: content.replace(/\n/g, '
'), + html: `
${content.replace(//g, '>').replace(/\n/g, '
')}
`, text: content, isHtml: false }); } - }, [content, type]); - - // Process quoted content visibility and fix table styling - useEffect(() => { - if (!containerRef.current || !processedContent.html) return; - - const container = containerRef.current; - - // Handle quoted text visibility - if (!showQuotedText) { - // Add toggle buttons for quoted text sections - const quotedSections = container.querySelectorAll('blockquote'); - - quotedSections.forEach((quote, index) => { - // Check if this quoted section already has a toggle - if (quote.previousElementSibling?.classList.contains('quoted-toggle-btn')) { - return; - } - - // Create toggle button - const toggleBtn = document.createElement('button'); - toggleBtn.innerText = '▼ Show quoted text'; - toggleBtn.className = 'quoted-toggle-btn'; - toggleBtn.style.cssText = 'background: none; border: none; color: #666; font-size: 12px; cursor: pointer; padding: 4px 0; display: block;'; - - // Hide quoted section initially - quote.style.display = 'none'; - - // Add click handler - toggleBtn.addEventListener('click', () => { - const isHidden = quote.style.display === 'none'; - quote.style.display = isHidden ? 'block' : 'none'; - toggleBtn.innerText = isHidden ? '▲ Hide quoted text' : '▼ Show quoted text'; - }); - - // Insert before the blockquote - quote.parentNode?.insertBefore(toggleBtn, quote); - }); - } - - // Process tables and ensure they're properly formatted - const tables = container.querySelectorAll('table'); - tables.forEach(table => { - // Cast to HTMLTableElement to access style property - const tableElement = table as HTMLTableElement; - - // Only apply styling if the table doesn't already have border styles - if (!tableElement.hasAttribute('border') && - (!tableElement.style.border || tableElement.style.border === '')) { - // Apply proper table styling - tableElement.style.width = '100%'; - tableElement.style.borderCollapse = 'collapse'; - tableElement.style.margin = '10px 0'; - tableElement.style.border = '1px solid #ddd'; - } - - const cells = table.querySelectorAll('td, th'); - cells.forEach(cell => { - // Cast to HTMLTableCellElement to access style property - const cellElement = cell as HTMLTableCellElement; - - // Only apply styling if the cell doesn't already have border styles - if (!cellElement.style.border || cellElement.style.border === '') { - cellElement.style.border = '1px solid #ddd'; - cellElement.style.padding = '6px'; - } - }); - }); - }, [processedContent.html, showQuotedText]); + }, [content, type, isRawEmail]); return ( -
+
+
+
); }; diff --git a/components/email/EmailPreview.tsx b/components/email/EmailPreview.tsx index 2b08292f..fc4bbc9b 100644 --- a/components/email/EmailPreview.tsx +++ b/components/email/EmailPreview.tsx @@ -12,6 +12,7 @@ import { EmailMessage as FormatterEmailMessage, sanitizeHtml } from '@/lib/utils/email-formatter'; +import { formatEmailContent } from '@/lib/utils/email-content'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { AvatarImage } from '@/components/ui/avatar'; import { Card } from '@/components/ui/card'; @@ -103,43 +104,11 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP // Format the email content const formattedContent = useMemo(() => { if (!email) { - console.log('EmailPreview: No email provided'); return ''; } - try { - console.log('EmailPreview: Full email object:', JSON.stringify(email, null, 2)); - - // Get the content in order of preference - let content = ''; - - // If content is an object with html/text - if (email.content && typeof email.content === 'object') { - console.log('EmailPreview: Using object content:', JSON.stringify(email.content, null, 2)); - content = email.content.html || email.content.text || ''; - } - // If content is a string - else if (typeof email.content === 'string') { - console.log('EmailPreview: Using direct string content:', email.content); - content = email.content; - } - - console.log('EmailPreview: Final content before sanitization:', content); - - // Sanitize the content for display - const sanitizedContent = DOMPurify.sanitize(content, { - ADD_TAGS: ['style', 'table', 'thead', 'tbody', 'tr', 'td', 'th'], - ADD_ATTR: ['class', 'style', 'dir', 'colspan', 'rowspan'], - ALLOW_DATA_ATTR: false - }); - - console.log('EmailPreview: Final sanitized content:', sanitizedContent); - - return sanitizedContent; - } catch (error) { - console.error('EmailPreview: Error formatting email content:', error); - return ''; - } + // Use the improved, standardized email content formatter + return formatEmailContent(email); }, [email]); // Display loading state @@ -249,12 +218,28 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP {/* Email content */} -
-
-
+
+
diff --git a/lib/utils/email-content.ts b/lib/utils/email-content.ts index 3667bcad..0c30aa81 100644 --- a/lib/utils/email-content.ts +++ b/lib/utils/email-content.ts @@ -1,5 +1,10 @@ import DOMPurify from 'dompurify'; +/** + * Format and standardize email content for display following email industry standards. + * This function handles various email content formats and ensures proper display + * including support for HTML emails, plain text emails, RTL languages, and email client quirks. + */ export function formatEmailContent(email: any): string { if (!email) { console.log('formatEmailContent: No email provided'); @@ -7,47 +12,91 @@ export function formatEmailContent(email: any): string { } try { - console.log('formatEmailContent: Raw email content:', { - content: email.content, - html: email.html, - text: email.text, - formattedContent: email.formattedContent - }); - - // Get the content in order of preference + // Get the content in order of preference with proper fallbacks let content = ''; + let isHtml = false; + let textContent = ''; + // Extract content based on standardized property hierarchy if (email.content && typeof email.content === 'object') { - console.log('formatEmailContent: Using object content:', email.content); - content = email.content.html || email.content.text || ''; + isHtml = !!email.content.html; + content = email.content.html || ''; + textContent = email.content.text || ''; } else if (typeof email.content === 'string') { - console.log('formatEmailContent: Using direct string content'); + // Check if the string content is HTML + isHtml = email.content.trim().startsWith('<') && + (email.content.includes('')); content = email.content; + textContent = email.content; } else if (email.html) { - console.log('formatEmailContent: Using html content'); + isHtml = true; content = email.html; + textContent = email.text || ''; } else if (email.text) { - console.log('formatEmailContent: Using text content'); - content = email.text; + isHtml = false; + content = ''; + textContent = email.text; } else if (email.formattedContent) { - console.log('formatEmailContent: Using formattedContent'); + // Assume formattedContent is already HTML + isHtml = true; content = email.formattedContent; + textContent = ''; } - console.log('formatEmailContent: Content before sanitization:', content); + // If we have HTML content, sanitize and standardize it + if (isHtml && content) { + // Sanitize with industry-standard email tags and attributes + const sanitizedContent = DOMPurify.sanitize(content, { + ADD_TAGS: [ + 'style', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', + 'caption', 'col', 'colgroup', 'div', 'span', 'img', 'br', 'hr', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'blockquote', 'pre', + 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a', 'b', 'i', 'u', 'em', + 'strong', 'del', 'ins', 'sub', 'sup', 'small', 'mark', 'q' + ], + ADD_ATTR: [ + 'class', 'style', 'id', 'href', 'src', 'alt', 'title', 'width', 'height', + 'border', 'cellspacing', 'cellpadding', 'bgcolor', 'color', 'dir', 'lang', + 'align', 'valign', 'span', 'colspan', 'rowspan', 'target', 'rel', + 'background', 'data-*' + ], + ALLOW_DATA_ATTR: true, + WHOLE_DOCUMENT: false, + RETURN_DOM: false, + FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button'], + FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout'] + }); + + // Wrap the content in standard email container with responsive styling + return ` + + `; + } + // If we only have text content, format it properly + else if (textContent) { + // Format plain text with proper line breaks and paragraphs + const formattedText = textContent + .replace(/\r\n|\r|\n/g, '
') // Convert all newlines to
+ .replace(/((?:
){2,})/g, '

') // Convert multiple newlines to paragraphs + .replace(/
<\/p>/g, '

') // Fix any

combinations + .replace(/


/g, '

'); // Fix any


combinations + + return ` +

+ `; + } - // Sanitize the content for display while preserving formatting - const sanitizedContent = DOMPurify.sanitize(content, { - ADD_TAGS: ['style', 'table', 'thead', 'tbody', 'tr', 'td', 'th'], - ADD_ATTR: ['class', 'style', 'dir', 'colspan', 'rowspan'], - ALLOW_DATA_ATTR: false - }); - - console.log('formatEmailContent: Final sanitized content:', sanitizedContent); - - return sanitizedContent; + // Default case: empty or unrecognized content + return ''; } catch (error) { console.error('formatEmailContent: Error formatting email content:', error); - return ''; + return ''; } } \ No newline at end of file diff --git a/lib/utils/email-formatter.ts b/lib/utils/email-formatter.ts index 29180535..58e716e1 100644 --- a/lib/utils/email-formatter.ts +++ b/lib/utils/email-formatter.ts @@ -135,34 +135,54 @@ export function formatEmailDate(date: Date | string | undefined): string { /** * Sanitize HTML content before processing or displaying - * This ensures the content is properly sanitized while preserving text direction + * Implements email industry standards for proper, consistent, and secure rendering + * * @param html HTML content to sanitize - * @returns Sanitized HTML with preserved text direction + * @returns Sanitized HTML with preserved styling and structure */ export function sanitizeHtml(html: string): string { if (!html) return ''; try { - // Use DOMPurify but ensure we keep all elements and attributes that might be in emails + // Use DOMPurify with comprehensive email HTML standards const clean = DOMPurify.sanitize(html, { - ADD_TAGS: ['button', 'style', 'img', 'iframe', 'meta', 'table', 'thead', 'tbody', 'tr', 'td', 'th'], - ADD_ATTR: ['target', 'rel', 'style', 'class', 'id', 'href', 'src', 'alt', 'title', 'width', 'height', 'onclick', 'colspan', 'rowspan'], + 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' + ], + 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-*' + ], KEEP_CONTENT: true, WHOLE_DOCUMENT: false, ALLOW_DATA_ATTR: true, - ALLOW_UNKNOWN_PROTOCOLS: true, - FORCE_BODY: false, - RETURN_DOM: false, - RETURN_DOM_FRAGMENT: false, + 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 }); - return clean; + // Fix common email rendering issues + return clean + // Fix for Outlook WebVML content + .replace(/