/** * Unified Email Utilities * * This file provides backward compatibility for email utilities. * New code should import directly from the specialized modules: * - email-content.ts (content processing) * - text-direction.ts (direction handling) * - dom-purify-config.ts (sanitization) */ // Import from specialized modules import { sanitizeHtml } from './dom-purify-config'; import { detectTextDirection, applyTextDirection } from './text-direction'; import { extractEmailContent, formatEmailContent, processHtmlContent, formatPlainTextToHtml, isHtmlContent, extractTextFromHtml } from './email-content'; import { EmailMessage, EmailContent, EmailAddress, LegacyEmailMessage } from '@/types/email'; import { adaptLegacyEmail } from '@/lib/utils/email-adapters'; import { decodeInfomaniakEmail, adaptMimeEmail, isMimeFormat } from './email-mime-decoder'; import { format } from 'date-fns'; // Re-export important functions for backward compatibility export { sanitizeHtml, extractEmailContent, formatEmailContent, processHtmlContent, formatPlainTextToHtml, detectTextDirection, applyTextDirection }; /** * 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(); } } /** * 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
'; } // Create a simple object that can be processed by formatEmailContent const emailObj = { content }; // Use the centralized formatting function return formatEmailContent(emailObj); } /** * 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 }; } /** * Format email for reply */ export function formatReplyEmail(originalEmail: EmailMessage | LegacyEmailMessage | null, type: 'reply' | 'reply-all' = 'reply'): FormattedEmail { console.log('formatReplyEmail called:', { type, emailId: originalEmail?.id }); if (!originalEmail) { console.warn('formatReplyEmail: No original email provided'); return { to: '', subject: '', content: { text: '', html: '', isHtml: false, direction: 'ltr' } }; } // Adapt legacy format if needed const email = 'content' in originalEmail ? originalEmail : adaptLegacyEmail(originalEmail); // Format subject with Re: prefix const subject = email.subject ? (email.subject.toLowerCase().startsWith('re:') ? email.subject : `Re: ${email.subject}`) : 'Re: '; // Get recipient addresses const { to, cc } = getRecipientAddresses(email, type); // Get email content and sanitize it const originalContent = email.content; // Extract text and html content let htmlContent = ''; let textContent = ''; let direction: 'ltr' | 'rtl' = 'ltr'; // Handle different content formats if (typeof originalContent === 'string') { console.log('formatReplyEmail: content is string, length:', originalContent.length); // Simple string content textContent = originalContent; const isHtml = isHtmlContent(originalContent); if (isHtml) { htmlContent = originalContent; } else { // If it's plain text, convert to HTML htmlContent = formatPlainTextToHtml(originalContent); } } else if (originalContent) { console.log('formatReplyEmail: content is object:', { hasHtml: !!originalContent.html, htmlLength: originalContent.html?.length || 0, hasText: !!originalContent.text, textLength: originalContent.text?.length || 0, direction: originalContent.direction }); // Standard EmailContent object htmlContent = originalContent.html || ''; textContent = originalContent.text || ''; direction = originalContent.direction || 'ltr' as const; // If no HTML but has text, convert text to HTML if (!htmlContent && textContent) { htmlContent = formatPlainTextToHtml(textContent); } } // Get quote header const { fromStr, dateStr } = getFormattedHeaderInfo(email); // Use the from name if available, otherwise use email address const sender = fromStr; const date = dateStr; // Create the quoted reply content if (htmlContent) { // Format HTML reply with better styling for quoted content 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(' On ${date}, ${sender} wrote:
${sanitizedOriginal}
`; } if (textContent) { // Format plain text reply const lines = textContent.split(/\r\n|\r|\n/); textContent = `On ${date}, ${sender} wrote:\n\n${lines.map(line => `> ${line}`).join('\n')}`; } const result = { to, cc: cc || undefined, subject, content: { html: htmlContent, text: textContent, isHtml: true, direction, }, attachments: email.attachments?.map(att => { // Create properly typed attachment if ('name' in att) { return { filename: att.filename || att.name || 'attachment', contentType: att.contentType || 'application/octet-stream', content: att.content }; } return { filename: att.filename || 'attachment', contentType: att.contentType || 'application/octet-stream', content: att.content }; }) }; console.log('formatReplyEmail result:', { to: result.to, subject: result.subject, hasHtml: !!result.content.html, htmlLength: result.content.html?.length || 0, hasText: !!result.content.text, textLength: result.content.text?.length || 0 }); 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); if (!originalEmail) { console.warn('formatForwardedEmail: No original email provided'); return { to: '', subject: '', content: { text: '', html: '', isHtml: false, direction: 'ltr' } }; } // Adapt legacy format if needed const email = 'content' in originalEmail ? originalEmail : adaptLegacyEmail(originalEmail); // Format subject with Fwd: prefix const subject = email.subject ? (email.subject.toLowerCase().startsWith('fwd:') ? email.subject : `Fwd: ${email.subject}`) : 'Fwd: '; // Get email content const originalContent = email.content; // Extract text and html content let htmlContent = ''; let textContent = ''; let direction: 'ltr' | 'rtl' = 'ltr'; // Handle different content formats if (typeof originalContent === 'string') { console.log('formatForwardedEmail: content is string, length:', originalContent.length); // Simple string content textContent = originalContent; const isHtml = isHtmlContent(originalContent); if (isHtml) { htmlContent = originalContent; } else { // If it's plain text, convert to HTML htmlContent = formatPlainTextToHtml(originalContent); } } else if (originalContent) { console.log('formatForwardedEmail: content is object:', { hasHtml: !!originalContent.html, htmlLength: originalContent.html?.length || 0, hasText: !!originalContent.text, textLength: originalContent.text?.length || 0, direction: originalContent.direction }); // Standard EmailContent object htmlContent = originalContent.html || ''; textContent = originalContent.text || ''; direction = originalContent.direction || 'ltr' as const; // If no HTML but has text, convert text to HTML if (!htmlContent && textContent) { htmlContent = formatPlainTextToHtml(textContent); } } // Get header info for the forwarded message const headerInfo = getFormattedHeaderInfo(email); // Create the forwarded content if (htmlContent) { console.log('Formatting HTML forward, 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 }); // Create forwarded message with header info htmlContent = `
---------------------------- Forwarded Message ----------------------------
${headerInfo.ccStr ? ` ` : ''}
From: ${headerInfo.fromStr}
Date: ${headerInfo.dateStr}
Subject: ${headerInfo.subject}
To: ${headerInfo.toStr}
Cc: ${headerInfo.ccStr}
----------------------------------------------------------------------
${sanitizedOriginal}
`; } 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}` : ''} ${textContent} `.trim(); } const result = { to: '', subject, content: { html: htmlContent, text: textContent, isHtml: true, direction, }, attachments: email.attachments?.map(att => { // Create properly typed attachment if ('name' in att) { return { filename: att.filename || att.name || 'attachment', contentType: att.contentType || 'application/octet-stream', content: att.content }; } return { filename: att.filename || 'attachment', contentType: att.contentType || 'application/octet-stream', content: att.content }; }) }; console.log('formatForwardedEmail result:', { subject: result.subject, hasHtml: !!result.content.html, htmlLength: result.content.html?.length || 0, hasText: !!result.content.text, textLength: result.content.text?.length || 0 }); return result; } /** * 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'); } }