/** * Unified Email Utilities * * This file contains all email-related utility functions: * - Content normalization * - Email formatting (replies, forwards) * - Text direction handling */ // Import from centralized configuration import { sanitizeHtml } from './dom-purify-config'; import { EmailMessage, EmailContent, EmailAddress, LegacyEmailMessage } from '@/types/email'; import { adaptLegacyEmail } from '@/lib/utils/email-adapters'; import { decodeInfomaniakEmail, adaptMimeEmail, isMimeFormat } from './email-mime-decoder'; import { detectTextDirection, applyTextDirection, extractEmailContent, processContentWithDirection } from '@/lib/utils/text-direction'; import { format } from 'date-fns'; // Export the sanitizeHtml function from the centralized config export { sanitizeHtml }; /** * Standard interface for formatted email responses */ export interface FormattedEmail { to: string; cc?: string; subject: string; content: EmailContent; attachments?: Array<{ filename: string; contentType: string; content?: string; }>; } /** * Format email addresses for display * Can handle both array of EmailAddress objects or a string */ export function formatEmailAddresses(addresses: EmailAddress[] | string | undefined): string { if (!addresses) return ''; // If already a string, return as is if (typeof addresses === 'string') { return addresses; } // If array, format each address if (Array.isArray(addresses) && addresses.length > 0) { return addresses.map(addr => addr.name && addr.name !== addr.address ? `${addr.name} <${addr.address}>` : addr.address ).join(', '); } return ''; } /** * Format date for display */ export function formatEmailDate(date: Date | string | undefined): string { if (!date) return ''; try { const dateObj = typeof date === 'string' ? new Date(date) : date; return dateObj.toLocaleString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch (e) { return typeof date === 'string' ? date : date.toString(); } } /** * Format plain text for HTML display with proper line breaks */ export function formatPlainTextToHtml(text: string | null | undefined): string { if (!text) return ''; // Escape HTML characters to prevent XSS const escapedText = text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Format plain text with proper line breaks and paragraphs return escapedText .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 } /** * Normalize email content to our standard format regardless of input format */ export function normalizeEmailContent(email: any): EmailMessage { if (!email) { throw new Error('Cannot normalize null or undefined email'); } // First check if this is a MIME format email that needs decoding if (email.content && isMimeFormat(email.content)) { try { console.log('Detected MIME format email, decoding...'); // We need to force cast here due to type incompatibility between EmailMessage and the mime result const adaptedEmail = adaptMimeEmail(email); return { ...adaptedEmail, flags: adaptedEmail.flags || [] // Ensure flags is always an array } as EmailMessage; } catch (error) { console.error('Error decoding MIME email:', error); // Continue with regular normalization if MIME decoding fails } } // Check if it's already in the standardized format if (email.content && typeof email.content === 'object' && (email.content.html !== undefined || email.content.text !== undefined)) { // Already in the correct format return email as EmailMessage; } // Otherwise, adapt from legacy format // We need to force cast here due to type incompatibility const adaptedEmail = adaptLegacyEmail(email); return { ...adaptedEmail, flags: adaptedEmail.flags || [] // Ensure flags is always an array } as EmailMessage; } /** * Render normalized email content into HTML for display */ export function renderEmailContent(content: EmailContent | null): string { if (!content) { return '

No content available
'; } // Use the centralized content processing function const processed = processContentWithDirection(content); // Return the processed HTML with proper direction return processed.html || '
No content available
'; } /** * Get recipient addresses from an email for reply or forward */ function getRecipientAddresses(email: any, type: 'reply' | 'reply-all'): { to: string; cc: string } { // Format the recipients const to = Array.isArray(email.from) ? email.from.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.address ? addr.address : ''; }).filter(Boolean).join(', ') : typeof email.from === 'string' ? email.from : ''; // For reply-all, include other recipients in CC let cc = ''; if (type === 'reply-all') { const toRecipients = Array.isArray(email.to) ? email.to.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.address ? addr.address : ''; }).filter(Boolean) : typeof email.to === 'string' ? [email.to] : []; const ccRecipients = Array.isArray(email.cc) ? email.cc.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.address ? addr.address : ''; }).filter(Boolean) : typeof email.cc === 'string' ? [email.cc] : []; cc = [...toRecipients, ...ccRecipients].join(', '); } return { to, cc }; } /** * Get formatted header information for reply or forward */ function getFormattedHeaderInfo(email: any): { fromStr: string; toStr: string; ccStr: string; dateStr: string; subject: string; } { // Format the subject const subject = email.subject && !email.subject.startsWith('Re:') && !email.subject.startsWith('Fwd:') ? email.subject : email.subject || ''; // Format the date const dateStr = email.date ? new Date(email.date).toLocaleString() : 'Unknown Date'; // Format sender const fromStr = Array.isArray(email.from) ? email.from.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.name ? `${addr.name} <${addr.address}>` : addr.address; }).join(', ') : typeof email.from === 'string' ? email.from : 'Unknown Sender'; // Format recipients const toStr = Array.isArray(email.to) ? email.to.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.name ? `${addr.name} <${addr.address}>` : addr.address; }).join(', ') : typeof email.to === 'string' ? email.to : ''; // Format CC const ccStr = Array.isArray(email.cc) ? email.cc.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.name ? `${addr.name} <${addr.address}>` : addr.address; }).join(', ') : typeof email.cc === 'string' ? email.cc : ''; return { fromStr, toStr, ccStr, dateStr, subject }; } /** * Extract image attachments from HTML content */ function extractInlineImages(htmlContent: string): Array<{ filename: string; contentType: string; content?: string; }> { const images: Array<{ filename: string; contentType: string; content?: string; }> = []; try { if (!htmlContent || typeof window === 'undefined') return images; // Create a temporary DOM element to parse the HTML const tempDiv = document.createElement('div'); tempDiv.innerHTML = htmlContent; // Find all image elements const imgElements = tempDiv.querySelectorAll('img'); // Process each image imgElements.forEach((img, index) => { const src = img.getAttribute('src'); if (!src) return; // Only process data URLs and non-tracking pixels if (src.startsWith('data:image')) { const contentType = src.split(',')[0].split(':')[1].split(';')[0]; const imageData = src.split(',')[1]; // Skip tiny images (likely tracking pixels) if (imageData.length < 100) return; images.push({ filename: `inline-image-${index + 1}.${contentType.split('/')[1] || 'png'}`, contentType, content: imageData }); // Replace the image source with a placeholder img.setAttribute('src', `cid:inline-image-${index + 1}`); } else if (src.startsWith('cid:')) { // Already a CID reference, just add a placeholder const cid = src.substring(4); images.push({ filename: `${cid}.png`, contentType: 'image/png' }); } }); // Update the HTML content to use the placeholders htmlContent = tempDiv.innerHTML; } catch (error) { console.error('Error extracting inline images:', error); } return images; } /** * Format email for reply */ export function formatReplyEmail(originalEmail: EmailMessage | LegacyEmailMessage | null, type: 'reply' | 'reply-all' = 'reply'): FormattedEmail { if (!originalEmail) { return { to: '', cc: '', subject: '', content: { text: '', html: '', isHtml: false, direction: 'ltr' as const } }; } // Extract recipient addresses const { to, cc } = getRecipientAddresses(originalEmail, type); // Get header information const { fromStr, dateStr, subject } = getFormattedHeaderInfo(originalEmail); // Extract just the text content for a clean reply let emailText = ''; // Try to get text directly from content.text first if (originalEmail.content && typeof originalEmail.content === 'object' && originalEmail.content.text) { emailText = originalEmail.content.text; } // Otherwise, fall back to extractEmailContent which tries various formats else { const { text } = extractEmailContent(originalEmail); emailText = text; } // Create simple reply with header const cleanReplyHeader = `
On ${dateStr}, ${fromStr} wrote:
`; // Limit text to reasonable size and format as simple HTML const maxChars = 1000; const truncatedText = emailText.length > maxChars ? emailText.slice(0, maxChars) + '... [message truncated]' : emailText; const cleanHtml = ` ${cleanReplyHeader}

${truncatedText.replace(/\n/g, '

')}

`; // Plain text version const plainText = ` On ${dateStr}, ${fromStr} wrote: > ${truncatedText.split('\n').join('\n> ')} `; return { to, cc, subject: subject.startsWith('Re:') ? subject : `Re: ${subject}`, content: { text: plainText.trim(), html: cleanHtml, isHtml: true, direction: 'ltr' } }; } /** * Format email for forwarding */ export function formatForwardedEmail(originalEmail: EmailMessage | LegacyEmailMessage | null): FormattedEmail { if (!originalEmail) { return { to: '', subject: '', content: { text: '', html: '', isHtml: false, direction: 'ltr' as const } }; } // Get header information const { fromStr, toStr, ccStr, dateStr, subject } = getFormattedHeaderInfo(originalEmail); // Extract just the text content for a clean forward let emailText = ''; // Try to get text directly from content.text first if (originalEmail.content && typeof originalEmail.content === 'object' && originalEmail.content.text) { emailText = originalEmail.content.text; } // Otherwise, fall back to extractEmailContent which tries various formats else { const { text } = extractEmailContent(originalEmail); emailText = text; } // Create simple forward with metadata header const cleanForwardHeader = `
---------- Forwarded message ---------
From: ${fromStr}
Date: ${dateStr}
Subject: ${subject || ''}
To: ${toStr}
${ccStr ? `
Cc: ${ccStr}
` : ''}
`; // Limit text to reasonable size and format as simple HTML const maxChars = 1500; const truncatedText = emailText.length > maxChars ? emailText.slice(0, maxChars) + '... [message truncated]' : emailText; const cleanHtml = ` ${cleanForwardHeader}

${truncatedText.replace(/\n/g, '

')}

`; // Plain text version const plainText = ` ---------- Forwarded message --------- From: ${fromStr} Date: ${dateStr} Subject: ${subject || ''} To: ${toStr} ${ccStr ? `Cc: ${ccStr}\n` : ''} ${truncatedText} `; // Check if original has attachments const attachments = originalEmail.attachments || []; return { to: '', subject: subject.startsWith('Fwd:') ? subject : `Fwd: ${subject}`, content: { text: plainText.trim(), html: cleanHtml, isHtml: true, direction: 'ltr' }, // Only include attachments if they exist attachments: attachments.length > 0 ? attachments.map(att => ({ filename: att.filename || 'attachment', contentType: att.contentType || 'application/octet-stream', content: att.content })) : undefined }; } /** * Format an email for reply or reply-all - canonical implementation */ export function formatEmailForReplyOrForward( email: EmailMessage | LegacyEmailMessage | null, type: 'reply' | 'reply-all' | 'forward' ): FormattedEmail { // Use our dedicated formatters if (type === 'forward') { return formatForwardedEmail(email); } else { return formatReplyEmail(email, type as 'reply' | 'reply-all'); } }