courrier preview

This commit is contained in:
alma 2025-05-01 17:57:35 +02:00
parent fbfababb22
commit 3420c5be5c
8 changed files with 632 additions and 1566 deletions

View File

@ -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 = `
<div style="margin: 20px 0 10px 0; color: #666; font-family: Arial, sans-serif;">
<div style="border-bottom: 1px solid #ccc; margin-bottom: 10px; padding-bottom: 5px;">
<div>---------------------------- Forwarded Message ----------------------------</div>
</div>
<table style="margin-bottom: 10px; font-size: 14px;">
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">From:</td>
<td style="padding: 3px 0;">${fromStr}</td>
</tr>
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">Date:</td>
<td style="padding: 3px 0;">${dateStr}</td>
</tr>
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">Subject:</td>
<td style="padding: 3px 0;">${initialEmail.subject || ''}</td>
</tr>
</table>
<div style="border-bottom: 1px solid #ccc; margin-top: 5px; margin-bottom: 15px; padding-bottom: 5px;">
<div>----------------------------------------------------------------------</div>
</div>
<div>[Could not load original content. Please use plain text or start a new message.]</div>
</div>
`;
} else {
// For replies
fallbackContent = `
<div style="margin: 20px 0 10px 0; color: #666; border-bottom: 1px solid #ddd; padding-bottom: 5px;">
On ${dateStr}, ${fromStr} wrote:
</div>
<blockquote style="margin: 0; padding-left: 10px; border-left: 3px solid #ddd; color: #505050; background-color: #f9f9f9; padding: 10px;">
[Could not load original content. Please use plain text or start a new message.]
</blockquote>
`;
}
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

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
console.log('Using email.content.text (converted)', contentToUse.substring(0, 50));
}
contentToUse = email.content.html || email.content.text || '';
}
}
// Fall back to html or text properties if content is not available
else if (email.html) {
contentToUse = email.html;
console.log('Using fallback email.html', contentToUse.substring(0, 50));
}
else if (email.text) {
// Convert plain text to HTML with line breaks
@ -114,15 +76,6 @@ export default function EmailDetailView({
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
console.log('Using fallback email.text (converted)', contentToUse.substring(0, 50));
}
// Log if no content was found
if (!contentToUse) {
console.error('No renderable content found in email!', {
id: email.id,
subject: email.subject
});
}
// Return content or fallback message

View File

@ -5,8 +5,6 @@ import 'quill/dist/quill.snow.css';
import { sanitizeHtml } from '@/lib/utils/dom-purify-config';
import { detectTextDirection } from '@/lib/utils/text-direction';
import { processHtmlContent } from '@/lib/utils/email-content';
import Quill from 'quill';
import QuillBetterTable from 'quill-better-table';
interface RichEmailEditorProps {
initialContent: string;
@ -15,175 +13,8 @@ interface RichEmailEditorProps {
minHeight?: string;
maxHeight?: string;
preserveFormatting?: boolean;
autofocus?: boolean;
mode?: string;
allowedFormats?: any;
customKeyBindings?: any;
}
// Register better table module
function registerTableModule() {
try {
// Only attempt to register if the module exists
if (typeof QuillBetterTable !== 'undefined') {
Quill.register({
'modules/better-table': QuillBetterTable
}, true);
return true;
} else {
console.warn('QuillBetterTable module is not available, skipping registration');
return false;
}
} catch (error) {
console.error('Error registering table module:', error);
return false;
}
}
/**
* Clean up problematic table structures that cause issues with quill-better-table
*/
function cleanupTableStructures(htmlContent: string, isReplyOrForward: boolean = false): string {
if (!htmlContent) return htmlContent;
try {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
// Find tables with problematic nested structures
const tables = tempDiv.querySelectorAll('table');
// Check if content looks like a forwarded email or reply content
const isForwardedEmail =
htmlContent.includes('---------- Forwarded message ----------') ||
htmlContent.includes('Forwarded message') ||
htmlContent.includes('forwarded message') ||
(htmlContent.includes('From:') && htmlContent.includes('Date:') && htmlContent.includes('Subject:'));
const isReplyEmail =
htmlContent.includes('wrote:') ||
htmlContent.includes('<blockquote') ||
htmlContent.includes('gmail_quote');
// For reply/forward content, force convert ALL tables to divs to avoid Quill errors
const shouldConvertAllTables = isReplyOrForward || isForwardedEmail || isReplyEmail;
if (tables.length > 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<RichEmailEditorProps> = ({
initialContent,
onChange,
@ -191,329 +22,201 @@ const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
minHeight = '200px',
maxHeight = 'calc(100vh - 400px)',
preserveFormatting = false,
autofocus = false,
mode = 'compose',
allowedFormats,
customKeyBindings,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const quillRef = useRef<any>(null);
const quillInitTimeoutRef = useRef<NodeJS.Timeout | null>(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('<blockquote')
});
// Get the container of the editor
// Detect text direction
const direction = detectTextDirection(initialContent);
// Process HTML content using centralized utility
const sanitizedContent = processHtmlContent(initialContent);
// Log sanitized content details for debugging
console.log('Sanitized content details:', {
length: sanitizedContent.length,
isEmpty: sanitizedContent.trim().length === 0,
startsWithDiv: sanitizedContent.trim().startsWith('<div'),
containsForwardedMessage: sanitizedContent.includes('---------- Forwarded message ----------'),
containsQuoteHeader: sanitizedContent.includes('wrote:'),
hasTable: sanitizedContent.includes('<table'),
hasBlockquote: sanitizedContent.includes('<blockquote'),
firstNChars: sanitizedContent.substring(0, 100).replace(/\n/g, '\\n')
});
// Check if sanitized content is valid
if (sanitizedContent.trim().length === 0) {
console.warn('Sanitized content is empty after processing, using fallback approach');
// Try to extract text content if HTML processing failed
try {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = initialContent;
const textContent = tempDiv.textContent || tempDiv.innerText || 'Empty content';
// Set text directly to ensure something displays
quillRef.current.setText(textContent);
} catch (e) {
console.error('Text extraction fallback failed:', e);
quillRef.current.setText('Error loading content');
}
} else {
// Use direct innerHTML setting for the initial content
quillRef.current.root.innerHTML = sanitizedContent;
// Set the direction for the content
if (quillRef.current && quillRef.current.format) {
quillRef.current.format('direction', direction);
if (direction === 'rtl') {
quillRef.current.format('align', 'right');
}
} else {
console.warn('Cannot format content: editor not fully initialized');
}
}
// Set cursor at the beginning
quillRef.current.setSelection(0, 0);
// Ensure the cursor and scroll position is at the top of the editor
if (editorRef.current) {
// Clear the content
editorRef.current.innerHTML = '';
editorRef.current.scrollTop = 0;
// Find and scroll parent containers that might have scroll
const scrollable = [
editorRef.current.closest('.ql-container'),
editorRef.current.closest('.rich-email-editor-container'),
editorRef.current.closest('.overflow-y-auto'),
document.querySelector('.overflow-y-auto')
];
scrollable.forEach(el => {
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 = '<p>Error loading content. Please start typing or paste content manually.</p>';
// 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 || '<p>Editor ready. Start typing...</p>';
// 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<RichEmailEditorProps> = ({
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('<blockquote') ||
initialContent.includes('Forwarded message') ||
initialContent.includes('---------- Forwarded message ----------');
// If content type changed (from reply to regular or vice versa), we need to reload
if (contentIsReplyOrForward !== isReplyOrForward) {
console.log('Content type changed from', isReplyOrForward ? 'reply/forward' : 'regular',
'to', contentIsReplyOrForward ? 'reply/forward' : 'regular',
'- reloading editor');
setIsReplyOrForward(contentIsReplyOrForward);
// Force a complete re-initialization of the editor by unmounting
if (quillRef.current) {
quillRef.current.off('text-change');
// Clear content to prevent flashing
try {
quillRef.current.root.innerHTML = '<div>Loading...</div>';
} catch (e) {
console.warn('Error clearing editor content:', e);
}
quillRef.current = null;
}
setIsReady(false);
// Force a small delay before reinitializing to ensure cleanup completes
setTimeout(() => {
// 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<RichEmailEditorProps> = ({
containsQuoteHeader: sanitizedContent.includes('wrote:'),
hasTable: sanitizedContent.includes('<table'),
hasBlockquote: sanitizedContent.includes('<blockquote'),
isReplyOrForward: contentIsReplyOrForward,
firstNChars: sanitizedContent.substring(0, 100).replace(/\n/g, '\\n')
});
@ -603,7 +259,7 @@ const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
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<RichEmailEditorProps> = ({
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<RichEmailEditorProps> = ({
}
}
}
}, [initialContent, isReady, isReplyOrForward]);
}, [initialContent, isReady]);
return (
<div className="rich-email-editor-wrapper">

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
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) => {

View File

@ -718,32 +718,9 @@ export async function getEmailContent(
// Convert flags from Set to boolean checks
const flagsArray = Array.from(flags as Set<string>);
// 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,

View File

@ -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 <body> 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(/<!--\[if\s+gte\s+mso/g, '<!--[if gte mso')
// Fix for broken image paths that might be relative
.replace(/(src|background)="(?!http|data|https|cid)/gi, '$1="https://');
return fixedHtml;
} catch (e) {
console.error('Error sanitizing HTML:', e);
// Fall back to a basic sanitization approach
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/javascript:/gi, 'blocked:');
.replace(/on\w+="[^"]*"/g, '')
.replace(/(javascript|jscript|vbscript|mocha):/gi, 'removed:');
}
}

View File

@ -13,23 +13,20 @@
import { sanitizeHtml } from './dom-purify-config';
import { detectTextDirection } from './text-direction';
import { EmailContent } from '@/types/email';
import { processCidReferences } from './email-utils';
/**
* Extract content from various possible email formats
* Centralized implementation to reduce duplication across the codebase
*/
export function extractEmailContent(email: any): { text: string; html: string; isHtml: boolean; direction: 'ltr' | 'rtl'; } {
export function extractEmailContent(email: any): { text: string; html: string } {
// Default empty values
let textContent = '';
let htmlContent = '';
let isHtml = false;
let direction: 'ltr' | 'rtl' = 'ltr';
// Early exit if no email
if (!email) {
console.log('extractEmailContent: No email provided');
return { text: '', html: '', isHtml: false, direction: 'ltr' };
return { text: '', html: '' };
}
try {
@ -38,8 +35,6 @@ export function extractEmailContent(email: any): { text: string; html: string; i
// Standard format with content object
textContent = email.content.text || '';
htmlContent = email.content.html || '';
isHtml = email.content.isHtml || !!htmlContent;
direction = email.content.direction || 'ltr';
// Handle complex email formats where content might be nested
if (!textContent && !htmlContent) {
@ -49,17 +44,13 @@ export function extractEmailContent(email: any): { text: string; html: string; i
// Determine if body is HTML or text
if (isHtmlContent(email.content.body)) {
htmlContent = email.content.body;
isHtml = true;
} else {
textContent = email.content.body;
isHtml = false;
}
} else if (typeof email.content.body === 'object' && email.content.body) {
// Some email formats nest content inside body
htmlContent = email.content.body.html || '';
textContent = email.content.body.text || '';
isHtml = email.content.body.isHtml || !!htmlContent;
direction = email.content.body.direction || 'ltr';
}
}
@ -69,10 +60,8 @@ export function extractEmailContent(email: any): { text: string; html: string; i
// Check if data looks like HTML
if (isHtmlContent(email.content.data)) {
htmlContent = email.content.data;
isHtml = true;
} else {
textContent = email.content.data;
isHtml = false;
}
}
}
@ -81,25 +70,19 @@ export function extractEmailContent(email: any): { text: string; html: string; i
// Check if content is likely HTML
if (isHtmlContent(email.content)) {
htmlContent = email.content;
isHtml = true;
} else {
textContent = email.content;
isHtml = false;
}
} else {
// Check other common properties
htmlContent = email.html || '';
textContent = email.text || '';
isHtml = email.isHtml || !!htmlContent;
direction = email.direction || 'ltr';
// If still no content, check for less common properties
if (!htmlContent && !textContent) {
// Try additional properties that some email clients use
htmlContent = email.body?.html || email.bodyHtml || email.htmlBody || '';
textContent = email.body?.text || email.bodyText || email.plainText || '';
isHtml = email.body?.isHtml || !!htmlContent;
direction = email.body?.direction || 'ltr';
}
}
} catch (error) {
@ -116,12 +99,10 @@ export function extractEmailContent(email: any): { text: string; html: string; i
hasHtml: !!htmlContent,
htmlLength: htmlContent?.length || 0,
hasText: !!textContent,
textLength: textContent?.length || 0,
isHtml,
direction
textLength: textContent?.length || 0
});
return { text: textContent, html: htmlContent, isHtml, direction };
return { text: textContent, html: htmlContent };
}
/**
@ -179,30 +160,28 @@ export function formatEmailContent(email: any): string {
try {
// Extract content from email
const { text, html, isHtml, direction } = extractEmailContent(email);
const { text, html } = extractEmailContent(email);
console.log('formatEmailContent processing:', {
hasHtml: !!html,
htmlLength: html?.length || 0,
hasText: !!text,
textLength: text?.length || 0,
emailType: typeof email === 'string' ? 'string' : 'object',
isHtml,
direction
emailType: typeof email === 'string' ? 'string' : 'object'
});
// If we have HTML content, sanitize and standardize it
if (html) {
// Process HTML content
const processed = processHtmlContent(html, { sanitize: true });
let processedHtml = processHtmlContent(html, text);
console.log('HTML content processed:', {
processedLength: processed.sanitizedContent?.length || 0,
isEmpty: !processed.sanitizedContent || processed.sanitizedContent.trim().length === 0
processedLength: processedHtml?.length || 0,
isEmpty: !processedHtml || processedHtml.trim().length === 0
});
// Apply styling
return `<div class="email-content" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 100%; overflow-x: auto; overflow-wrap: break-word; word-wrap: break-word;" dir="${processed.direction}">${processed.sanitizedContent}</div>`;
return `<div class="email-content" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 100%; overflow-x: auto; overflow-wrap: break-word; word-wrap: break-word;" dir="${detectTextDirection(text)}">${processedHtml}</div>`;
}
// If we only have text content, format it properly
else if (text) {
@ -219,142 +198,164 @@ export function formatEmailContent(email: any): string {
}
/**
* Process HTML content to ensure safe rendering and proper formatting
* Process HTML content to fix common email rendering issues
*/
export function processHtmlContent(
htmlContent: string,
options?: {
sanitize?: boolean;
blockExternalContent?: boolean;
preserveReplyFormat?: boolean;
attachments?: Array<{
filename?: string;
name?: string;
contentType?: string;
content?: string;
contentId?: string;
}>;
} | string // Support for legacy textContent parameter
): {
sanitizedContent: string;
hasImages: boolean;
hasExternalContent: boolean;
direction: 'ltr' | 'rtl';
} {
// Handle legacy string parameter (textContent)
if (typeof options === 'string') {
options = { sanitize: true };
}
console.log('Processing HTML content:', {
contentLength: htmlContent?.length || 0,
startsWithHtml: htmlContent?.startsWith('<html'),
startsWithDiv: htmlContent?.startsWith('<div'),
containsForwardedMessage: htmlContent?.includes('---------- Forwarded message ----------'),
containsQuoteHeader: htmlContent?.includes('<div class="gmail_quote"'),
sanitize: options?.sanitize,
preserveReplyFormat: options?.preserveReplyFormat,
blockExternalContent: options?.blockExternalContent,
hasAttachments: options?.attachments?.length || 0
});
if (!htmlContent) {
return {
sanitizedContent: '',
hasImages: false,
hasExternalContent: false,
direction: 'ltr',
};
}
// Store the original content for comparison
const originalContent = htmlContent;
// Process CID references before sanitization
if (options?.attachments?.length) {
console.log('Processing CID references in processHtmlContent');
htmlContent = processCidReferences(htmlContent, options.attachments);
}
export function processHtmlContent(htmlContent: string, textContent?: string): string {
if (!htmlContent) return '';
try {
// Special handling for reply/forwarded content with less aggressive sanitization
const isReplyOrForward = options?.preserveReplyFormat === true;
// Apply sanitization by default unless explicitly turned off
let sanitizedContent = (options?.sanitize !== false)
? sanitizeHtml(htmlContent, { preserveReplyFormat: isReplyOrForward })
: htmlContent;
// Log content changes from sanitization
console.log('HTML sanitization results:', {
originalLength: originalContent.length,
sanitizedLength: sanitizedContent.length,
difference: originalContent.length - sanitizedContent.length,
percentRemoved: ((originalContent.length - sanitizedContent.length) / originalContent.length * 100).toFixed(2) + '%',
isEmpty: !sanitizedContent || sanitizedContent.trim().length === 0,
isReplyOrForward: isReplyOrForward
console.log('processHtmlContent input:', {
length: htmlContent.length,
startsWithHtml: htmlContent.trim().startsWith('<html'),
startsWithDiv: htmlContent.trim().startsWith('<div'),
hasBody: htmlContent.includes('<body'),
containsForwardedMessage: htmlContent.includes('---------- Forwarded message ----------'),
containsQuoteHeader: htmlContent.includes('wrote:'),
hasBlockquote: htmlContent.includes('<blockquote'),
hasTable: htmlContent.includes('<table')
});
// Detect if content is a forwarded message to ensure special handling for tables
const isForwardedEmail =
sanitizedContent.includes('---------- Forwarded message ----------') ||
sanitizedContent.includes('Forwarded message') ||
(sanitizedContent.includes('From:') && sanitizedContent.includes('Date:') &&
sanitizedContent.includes('Subject:') && sanitizedContent.includes('To:'));
// Check for browser environment (DOMParser is browser-only)
const hasHtmlTag = htmlContent.includes('<html');
const hasBodyTag = htmlContent.includes('<body');
// Special processing for forwarded email styling
if (isForwardedEmail || isReplyOrForward) {
console.log('Detected forwarded email or reply content, enhancing structure');
// Make sure we're not removing important table structures
sanitizedContent = sanitizedContent
// Preserve table styling for email headers
.replace(/<table([^>]*)>/g, '<table$1 style="margin: 10px 0; border-collapse: collapse; font-size: 13px; color: #333;">')
.replace(/<td([^>]*)>/g, '<td$1 style="padding: 3px 5px; vertical-align: top;">')
// Ensure blockquote styling is preserved
.replace(/<blockquote([^>]*)>/g, '<blockquote$1 style="margin: 0; padding-left: 10px; border-left: 3px solid #ddd; color: #505050; background-color: #f9f9f9; padding: 10px;">');
// Preserve original HTML for debugging
let originalHtml = htmlContent;
// Extract body content if we have a complete HTML document and in browser environment
if (hasHtmlTag && hasBodyTag && typeof window !== 'undefined' && typeof DOMParser !== 'undefined') {
try {
// Create a DOM parser to extract just the body content
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const bodyContent = doc.body.innerHTML;
if (bodyContent) {
console.log('Extracted body content from HTML document, length:', bodyContent.length);
htmlContent = bodyContent;
}
} catch (error) {
console.error('Error extracting body content:', error);
}
}
// Use the centralized sanitizeHtml function
let sanitizedContent = sanitizeHtml(htmlContent);
console.log('After sanitizeHtml:', {
originalLength: originalHtml.length,
sanitizedLength: sanitizedContent.length,
difference: originalHtml.length - sanitizedContent.length,
percentRemoved: ((originalHtml.length - sanitizedContent.length) / originalHtml.length * 100).toFixed(2) + '%',
containsForwardedMessage: sanitizedContent.includes('---------- Forwarded message ----------'),
hasTable: sanitizedContent.includes('<table'),
hasBlockquote: sanitizedContent.includes('<blockquote')
});
// Fix URL encoding issues and clean up content
try {
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
// Temporary element to manipulate the HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = sanitizedContent;
// Fix all links that might have been double-encoded
const links = tempDiv.querySelectorAll('a');
links.forEach(link => {
const href = link.getAttribute('href');
if (href && href.includes('%')) {
try {
// Try to decode URLs that might have been double-encoded
const decodedHref = decodeURIComponent(href);
link.setAttribute('href', decodedHref);
} catch (e) {
// If decoding fails, keep the original
console.warn('Failed to decode href:', href);
}
}
});
// Fix image URLs - preserve cid: URLs for email attachments
const images = tempDiv.querySelectorAll('img');
images.forEach(img => {
const src = img.getAttribute('src');
if (src) {
// Don't modify cid: URLs as they are handled specially in email clients
if (src.startsWith('cid:')) {
// Keep cid: URLs as they are
console.log('Preserving CID reference:', src);
}
// Fix http:// URLs to https:// for security
else if (src.startsWith('http://')) {
img.setAttribute('src', src.replace('http://', 'https://'));
}
// Handle relative URLs that might be broken
else if (!src.startsWith('https://') && !src.startsWith('data:')) {
if (src.startsWith('/')) {
img.setAttribute('src', `https://example.com${src}`);
} else {
img.setAttribute('src', `https://example.com/${src}`);
}
}
}
});
// Clean up excessive whitespace and empty elements
// Find all text nodes and normalize whitespace
const walker = document.createTreeWalker(
tempDiv,
NodeFilter.SHOW_TEXT,
null
);
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
// Process text nodes to normalize whitespace
textNodes.forEach(node => {
if (node.nodeValue) {
// Replace sequences of whitespace with a single space
node.nodeValue = node.nodeValue.replace(/\s+/g, ' ').trim();
}
});
// Remove empty paragraphs and divs that contain only whitespace
const emptyElements = tempDiv.querySelectorAll('p, div, span');
emptyElements.forEach(el => {
if (el.innerHTML.trim() === '' || el.innerHTML === '&nbsp;') {
el.parentNode?.removeChild(el);
}
});
// Remove excessive consecutive <br> tags (more than 2)
let html = tempDiv.innerHTML;
html = html.replace(/(<br\s*\/?>\s*){3,}/gi, '<br><br>');
tempDiv.innerHTML = html;
// Get the fixed HTML
sanitizedContent = tempDiv.innerHTML;
}
} catch (e) {
console.error('Error fixing content:', e);
}
// Fix common email client quirks without breaking cid: URLs
sanitizedContent = sanitizedContent
return sanitizedContent
// Fix for Outlook WebVML content
.replace(/<!--\[if\s+gte\s+mso/g, '<!--[if gte mso')
// Fix for broken image paths starting with // (add https:)
.replace(/src="\/\//g, 'src="https://')
// Handle mixed content issues by converting http:// to https://
.replace(/src="http:\/\//g, 'src="https://')
// Fix email signature line breaks
.replace(/--<br>/g, '<hr style="border-top: 1px solid #ccc; margin: 15px 0;">')
.replace(/-- <br>/g, '<hr style="border-top: 1px solid #ccc; margin: 15px 0;">')
// Fix for broken image paths WITHOUT replacing cid: URLs
.replace(/(src|background)="(?!(?:https?:|data:|cid:))/gi, '$1="https://')
// Fix for base64 images that might be broken across lines
.replace(/src="data:image\/[^;]+;base64,\s*([^"]+)\s*"/gi, (match, p1) => {
return `src="data:image/png;base64,${p1.replace(/\s+/g, '')}"`;
})
// Remove excessive whitespace from the HTML string itself
.replace(/>\s+</g, '> <');
// Additional processing for quoted content in replies/forwards
if (sanitizedContent.includes('blockquote')) {
console.log('Enhancing blockquote styling');
sanitizedContent = sanitizedContent
// Ensure blockquotes have proper styling
.replace(/<blockquote([^>]*)>/g, (match, attrs) => {
if (match.includes('style=')) {
return match; // Already has style
}
return `<blockquote${attrs} style="margin: 0; padding-left: 10px; border-left: 3px solid #ddd; color: #505050; background-color: #f9f9f9; padding: 10px;">`;
});
}
return {
sanitizedContent,
hasImages: sanitizedContent.includes('<img'),
hasExternalContent: sanitizedContent.includes('https://'),
direction: detectTextDirection(sanitizedContent)
};
} catch (error) {
console.error('Error processing HTML content:', error);
return {
sanitizedContent: htmlContent,
hasImages: false,
hasExternalContent: false,
direction: 'ltr',
};
return htmlContent;
}
}

View File

@ -324,26 +324,14 @@ export function formatReplyEmail(originalEmail: EmailMessage | LegacyEmailMessag
// Create the quoted reply content
if (htmlContent) {
// Format HTML reply with better styling for quoted content
// Format HTML reply
console.log('Formatting HTML reply, quoted content length:', htmlContent.length);
// Apply minimal sanitization to the original content - preserve more structure
// We'll do a more comprehensive sanitization later in the flow
const sanitizedOriginal = sanitizeHtml(htmlContent, { preserveReplyFormat: true });
// Check if original content already contains blockquotes or is reply/forward
const containsExistingQuote =
sanitizedOriginal.includes('<blockquote') ||
sanitizedOriginal.includes('wrote:') ||
sanitizedOriginal.includes('---------- Forwarded message ----------');
// Preserve existing quotes and add outer quote
htmlContent = `
<div style="margin: 20px 0 10px 0; color: #666; border-bottom: 1px solid #ddd; padding-bottom: 5px;">
On ${date}, ${sender} wrote:
</div>
<blockquote style="margin: 0; padding-left: 10px; border-left: 3px solid #ddd; color: #505050; background-color: #f9f9f9; padding: 10px;">
${sanitizedOriginal}
${sanitizeHtml(htmlContent)}
</blockquote>
`;
}
@ -393,103 +381,11 @@ export function formatReplyEmail(originalEmail: EmailMessage | LegacyEmailMessag
return result;
}
/**
* Process and replace CID references with base64 data URLs using the email's attachments.
* This function should be called before sanitizing the content.
*/
export function processCidReferences(htmlContent: string, attachments?: Array<{
filename?: string;
name?: string;
contentType?: string;
content?: string;
contentId?: string;
}>): string {
if (!htmlContent || !attachments || !attachments.length) {
return htmlContent;
}
console.log(`Processing CID references with ${attachments.length} attachments available`);
try {
// Create a map of content IDs to their attachment data
const cidMap = new Map();
attachments.forEach(att => {
if (att.contentId) {
// Content ID sometimes has <> brackets which need to be removed
const cleanCid = att.contentId.replace(/[<>]/g, '');
cidMap.set(cleanCid, {
contentType: att.contentType || 'application/octet-stream',
content: att.content
});
console.log(`Mapped CID: ${cleanCid} to attachment of type ${att.contentType || 'unknown'}`);
}
});
// If we have no content IDs mapped, return original content
if (cidMap.size === 0) {
console.log('No CID references found in attachments');
return htmlContent;
}
// Check if we're in a browser environment
if (typeof document === 'undefined') {
console.log('Not in browser environment, skipping CID processing');
return htmlContent;
}
// Parse the HTML content and replace CID references
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
// Find all images with CID sources
const imgElements = tempDiv.querySelectorAll('img[src^="cid:"]');
console.log(`Found ${imgElements.length} img elements with CID references`);
if (imgElements.length === 0) {
return htmlContent;
}
// Process each image with a CID reference
let replacedCount = 0;
imgElements.forEach(img => {
const src = img.getAttribute('src');
if (!src || !src.startsWith('cid:')) return;
// Extract the content ID from the src
const cid = src.substring(4); // Remove 'cid:' prefix
// Find the matching attachment
const attachment = cidMap.get(cid);
if (attachment && attachment.content) {
// Convert the attachment content to a data URL
const dataUrl = `data:${attachment.contentType};base64,${attachment.content}`;
// Replace the CID reference with the data URL
img.setAttribute('src', dataUrl);
replacedCount++;
console.log(`Replaced CID ${cid} with data URL`);
} else {
console.log(`No matching attachment found for CID: ${cid}`);
}
});
console.log(`Replaced ${replacedCount} CID references with data URLs`);
// Return the updated HTML content
return tempDiv.innerHTML;
} catch (error) {
console.error('Error processing CID references:', error);
return htmlContent;
}
}
/**
* Format email for forwarding
*/
export function formatForwardedEmail(originalEmail: EmailMessage | LegacyEmailMessage | null): FormattedEmail {
console.log('formatForwardedEmail called, emailId:', originalEmail?.id);
console.log('formatForwardedEmail called:', { emailId: originalEmail?.id });
if (!originalEmail) {
console.warn('formatForwardedEmail: No original email provided');
@ -508,6 +404,14 @@ export function formatForwardedEmail(originalEmail: EmailMessage | LegacyEmailMe
(email.subject.toLowerCase().startsWith('fwd:') ? email.subject : `Fwd: ${email.subject}`) :
'Fwd: ';
// Get original email info for headers
const { fromStr, toStr, ccStr, dateStr } = getFormattedHeaderInfo(email);
console.log('Forward header info:', { fromStr, toStr, dateStr, subject });
// Original sent date
const date = dateStr;
// Get email content
const originalContent = email.content;
@ -548,66 +452,68 @@ export function formatForwardedEmail(originalEmail: EmailMessage | LegacyEmailMe
htmlContent = formatPlainTextToHtml(textContent);
}
}
// Get header info for the forwarded message
const headerInfo = getFormattedHeaderInfo(email);
// Create the forwarded content
// Create the forwarded email HTML content
if (htmlContent) {
console.log('Formatting HTML forward, content length:', htmlContent.length);
console.log('Formatting HTML forward, original content length:', htmlContent.length);
// Apply minimal sanitization to the original content - preserve more structure
// We'll do a more comprehensive sanitization later in the flow
const sanitizedOriginal = sanitizeHtml(htmlContent, { preserveReplyFormat: true });
// Important: First sanitize the content portion only
const sanitizedOriginalContent = sanitizeHtml(htmlContent);
console.log('Sanitized original content length:', sanitizedOriginalContent.length);
// Create forwarded message with header info
htmlContent = `
// Create the complete forwarded email with header info
const fullForwardedEmail = `
<div style="margin: 20px 0 10px 0; color: #666; font-family: Arial, sans-serif;">
<div style="border-bottom: 1px solid #ccc; margin-bottom: 10px; padding-bottom: 5px;">
<div>---------------------------- Forwarded Message ----------------------------</div>
</div>
<table style="margin-bottom: 10px; font-size: 14px; border-collapse: collapse;">
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">From:</td>
<td style="padding: 3px 0;">${headerInfo.fromStr}</td>
</tr>
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">Date:</td>
<td style="padding: 3px 0;">${headerInfo.dateStr}</td>
</tr>
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">Subject:</td>
<td style="padding: 3px 0;">${headerInfo.subject}</td>
</tr>
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">To:</td>
<td style="padding: 3px 0;">${headerInfo.toStr}</td>
</tr>
${headerInfo.ccStr ? `
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">Cc:</td>
<td style="padding: 3px 0;">${headerInfo.ccStr}</td>
</tr>` : ''}
---------- Forwarded message ----------<br>
<table style="margin: 10px 0 15px 0; border-collapse: collapse; font-size: 13px; color: #333;">
<tbody>
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">From:</td>
<td style="padding: 3px 0;">${fromStr}</td>
</tr>
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">Date:</td>
<td style="padding: 3px 0;">${date}</td>
</tr>
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">Subject:</td>
<td style="padding: 3px 0;">${email.subject || ''}</td>
</tr>
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">To:</td>
<td style="padding: 3px 0;">${toStr}</td>
</tr>
${ccStr ? `
<tr>
<td style="padding: 3px 10px 3px 0; font-weight: bold; text-align: right; vertical-align: top;">Cc:</td>
<td style="padding: 3px 0;">${ccStr}</td>
</tr>
` : ''}
</tbody>
</table>
<div style="border-bottom: 1px solid #ccc; margin-top: 5px; margin-bottom: 15px; padding-bottom: 5px;">
<div>----------------------------------------------------------------------</div>
</div>
</div>
<div class="forwarded-content" style="margin: 0; color: #333;">
${sanitizedOriginal}
<div style="padding: 10px 0; border-top: 1px solid #ddd;">
${sanitizedOriginalContent}
</div>
`;
// Now we have the full forwarded email structure without sanitizing it again
htmlContent = fullForwardedEmail;
console.log('Final forward HTML content length:', htmlContent.length,
'contains table:', htmlContent.includes('<table'),
'contains forwarded message:', htmlContent.includes('---------- Forwarded message ----------'));
}
// Format the plain text version
if (textContent) {
// Format plain text forward
textContent = `
---------- Forwarded message ----------
From: ${headerInfo.fromStr}
Date: ${headerInfo.dateStr}
Subject: ${headerInfo.subject}
To: ${headerInfo.toStr}
${headerInfo.ccStr ? `Cc: ${headerInfo.ccStr}` : ''}
From: ${fromStr}
Date: ${date}
Subject: ${email.subject || ''}
To: ${toStr}
${ccStr ? `Cc: ${ccStr}\n` : ''}
${textContent}
`.trim();