diff --git a/lib/utils/email-adapters.ts b/lib/utils/email-adapters.ts new file mode 100644 index 00000000..3987c7ee --- /dev/null +++ b/lib/utils/email-adapters.ts @@ -0,0 +1,278 @@ +import { EmailMessage, EmailContent, EmailAddress, LegacyEmailMessage } from '@/types/email'; +import { sanitizeHtml } from './email-formatter'; + +/** + * Adapts a legacy email format to the standardized EmailMessage format + */ +export function adaptLegacyEmail(email: LegacyEmailMessage): EmailMessage { + if (!email) { + throw new Error('Cannot adapt null or undefined email'); + } + + // Process content + const content: EmailContent = normalizeContent(email); + + // Convert email addresses to string format as required by EmailMessage interface + const from = formatAddressesToString(normalizeAddresses(email.from)); + const to = formatAddressesToString(normalizeAddresses(email.to)); + const cc = email.cc ? formatAddressesToString(normalizeAddresses(email.cc)) : undefined; + const bcc = email.bcc ? formatAddressesToString(normalizeAddresses(email.bcc)) : undefined; + + // Convert flags if needed + const flags: string[] = normalizeFlags(email.flags); + + // Create standardized email message + return { + id: email.id || '', + from, + to, + cc, + bcc, + subject: email.subject || '', + content, + date: email.date || new Date().toISOString(), + flags, + attachments: normalizeAttachments(email.attachments), + _originalFormat: email // Store original for debugging + }; +} + +/** + * Detects if an email is in MIME format + */ +export function isMimeFormat(email: any): boolean { + // Simple check for MIME format indicators + if (!email) return false; + + // Check for typical MIME format properties + return !!( + email.mimeContent || + (email.headers && (email.bodyParts || email.body)) || + (typeof email.content === 'string' && email.content.includes('MIME-Version')) + ); +} + +/** + * Adapts a MIME format email to the standardized EmailMessage format + * This is a placeholder - actual implementation would depend on the MIME library + */ +export function adaptMimeEmail(mimeEmail: any): EmailMessage { + // Placeholder implementation + const content: EmailContent = { + text: mimeEmail.text || mimeEmail.plainText || '', + html: mimeEmail.html || undefined, + isHtml: !!mimeEmail.html, + direction: 'ltr' + }; + + return { + id: mimeEmail.id || '', + from: mimeEmail.from || '', + to: mimeEmail.to || '', + cc: mimeEmail.cc, + bcc: mimeEmail.bcc, + subject: mimeEmail.subject || '', + content, + date: mimeEmail.date || new Date().toISOString(), + flags: [], + _originalFormat: mimeEmail + }; +} + +/** + * Formats an array of EmailAddress objects to string format + */ +function formatAddressesToString(addresses: EmailAddress[]): string { + return addresses.map(addr => { + if (addr.name && addr.name !== addr.address) { + return `${addr.name} <${addr.address}>`; + } + return addr.address; + }).join(', '); +} + +/** + * Normalizes content from various formats into the standard EmailContent format + */ +function normalizeContent(email: LegacyEmailMessage): EmailContent { + // Default content structure + const normalizedContent: EmailContent = { + html: undefined, + text: '', + isHtml: false, + direction: 'ltr' + }; + + try { + // Extract content based on standardized property hierarchy + let htmlContent = ''; + let textContent = ''; + let isHtml = false; + + // Step 1: Extract content from the various possible formats + if (email.content && typeof email.content === 'object') { + isHtml = !!email.content.html; + htmlContent = email.content.html || ''; + textContent = email.content.text || ''; + } else if (typeof email.content === 'string') { + // Check if the string content is HTML + isHtml = email.content.trim().startsWith('<') && + (email.content.includes('')); + htmlContent = isHtml ? email.content : ''; + textContent = isHtml ? '' : email.content; + } else if (email.html) { + isHtml = true; + htmlContent = email.html; + textContent = email.text || email.plainText || ''; + } else if (email.text || email.plainText) { + isHtml = false; + htmlContent = ''; + textContent = email.text || email.plainText || ''; + } else if (email.formattedContent) { + // Assume formattedContent is already HTML + isHtml = true; + htmlContent = email.formattedContent; + textContent = ''; + } + + // Step 2: Set the normalized content properties + normalizedContent.isHtml = isHtml; + + // Always ensure we have text content + if (textContent) { + normalizedContent.text = textContent; + } else if (htmlContent) { + // Extract text from HTML if we don't have plain text + if (typeof document !== 'undefined') { + // Browser environment + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = htmlContent; + normalizedContent.text = tempDiv.textContent || tempDiv.innerText || ''; + } else { + // Server environment - do simple strip + normalizedContent.text = htmlContent + .replace(/<[^>]*>/g, '') + .replace(/ /g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + } + + // If we have HTML content, sanitize it + if (isHtml && htmlContent) { + normalizedContent.html = sanitizeHtml(htmlContent); + } + + // Determine text direction + normalizedContent.direction = detectTextDirection(normalizedContent.text); + + return normalizedContent; + } catch (error) { + console.error('Error normalizing email content:', error); + + // Return minimal valid content in case of error + return { + text: 'Error loading email content', + isHtml: false, + direction: 'ltr' + }; + } +} + +/** + * Detects the text direction (LTR or RTL) based on the content + */ +function detectTextDirection(text: string): 'ltr' | 'rtl' { + // Simple RTL detection for common RTL languages + // This is a basic implementation and can be enhanced + const rtlChars = /[\u0591-\u07FF\u200F\u202B\u202E\uFB1D-\uFDFD\uFE70-\uFEFC]/; + return rtlChars.test(text) ? 'rtl' : 'ltr'; +} + +/** + * Normalizes email addresses to the EmailAddress format + */ +function normalizeAddresses(addresses: string | EmailAddress[] | undefined): EmailAddress[] { + if (!addresses) { + return []; + } + + if (Array.isArray(addresses)) { + // If already in EmailAddress format, return as is + if (addresses.length > 0 && typeof addresses[0] === 'object') { + return addresses as EmailAddress[]; + } + + // Otherwise convert string elements to EmailAddress objects + return addresses.map((addr: any) => { + if (typeof addr === 'string') { + return { + name: addr.split('@')[0] || '', + address: addr + }; + } + return addr; + }); + } + + // Handle single address as string + if (typeof addresses === 'string') { + // Check if format is "Name " + const match = addresses.match(/^([^<]+)<([^>]+)>$/); + if (match) { + return [{ + name: match[1].trim(), + address: match[2].trim() + }]; + } + + return [{ + name: addresses.split('@')[0] || '', + address: addresses + }]; + } + + return []; +} + +/** + * Normalizes email flags to string array format + */ +function normalizeFlags(flags: string[] | Record | undefined): string[] { + if (!flags) { + return []; + } + + if (Array.isArray(flags)) { + return flags; + } + + // Convert object format to array + return Object.entries(flags) + .filter(([_, value]) => value === true) + .map(([key]) => key); +} + +/** + * Normalizes attachments to the expected format + */ +function normalizeAttachments(attachments: any[] | undefined): Array<{ + filename: string; + contentType: string; + encoding?: string; + content?: string; +}> { + if (!attachments || !Array.isArray(attachments)) { + return []; + } + + return attachments.map(att => ({ + filename: att.filename || att.name || 'unknown', + contentType: att.contentType || 'application/octet-stream', + encoding: att.encoding, + content: att.content + })); +} \ No newline at end of file diff --git a/lib/utils/email-mime-decoder.ts b/lib/utils/email-mime-decoder.ts index c70900c1..d684c807 100644 --- a/lib/utils/email-mime-decoder.ts +++ b/lib/utils/email-mime-decoder.ts @@ -1,275 +1,412 @@ /** - * Email MIME Decoder + * Infomaniak Email MIME Decoder * - * This module provides functions to decode MIME-encoded email content - * for proper display in a frontend application. + * This module provides specialized functions to decode MIME-encoded email content + * from Infomaniak servers for proper display in a frontend application. + * It handles multipart messages, different encodings, and character set conversions. */ +import { LegacyEmailMessage } from '@/types/email'; + +export interface DecodedEmail { + subject: string; + from: string; + to: string; + cc?: string; + bcc?: string; + date: string; + text?: string; + html?: string; + attachments?: Array<{ + filename: string; + contentType: string; + encoding?: string; + content?: string; + }>; + headers?: Record; +} + +export interface EmailHeaderInfo { + contentType: string; + encoding: string; + charset: string; + boundary?: string; +} + /** - * Decode a MIME encoded string (quoted-printable or base64) - * @param {string} text - The encoded text - * @param {string} encoding - The encoding type ('quoted-printable', 'base64', etc) - * @param {string} charset - The character set (utf-8, iso-8859-1, etc) - * @returns {string} - The decoded text + * Main function to decode Infomaniak Email in MIME format */ -export function decodeMIME(text: string, encoding?: string, charset = 'utf-8'): string { - if (!text) return ''; +export function decodeInfomaniakEmail(rawEmailContent: string): DecodedEmail { + // Check if it's a multipart email + const headers = extractHeaders(rawEmailContent); + const headerInfo = parseEmailHeaders(headers); - // Normalize encoding to lowercase - encoding = (encoding || '').toLowerCase(); - charset = (charset || 'utf-8').toLowerCase(); + if (headerInfo.contentType.includes('multipart')) { + return processMultipartEmail(rawEmailContent, headerInfo); + } else { + return processSinglePartEmail(rawEmailContent, headerInfo); + } +} + +/** + * Process a single part email + */ +function processSinglePartEmail(rawEmail: string, headerInfo: EmailHeaderInfo): DecodedEmail { + const splitEmail = rawEmail.split('\r\n\r\n'); + const headers = splitEmail[0]; + const body = splitEmail.slice(1).join('\r\n\r\n'); - try { - // Handle different encoding types - if (encoding === 'quoted-printable') { - return decodeQuotedPrintable(text, charset); - } else if (encoding === 'base64') { - return decodeBase64(text, charset); + const parsedHeaders = parseHeadersToObject(headers); + const decodedBody = decodeMimeContent(body, headerInfo.encoding); + const content = convertCharset(decodedBody, headerInfo.charset); + + const result: DecodedEmail = { + subject: decodeHeaderValue(parsedHeaders['subject'] || ''), + from: decodeHeaderValue(parsedHeaders['from'] || ''), + to: decodeHeaderValue(parsedHeaders['to'] || ''), + cc: parsedHeaders['cc'] ? decodeHeaderValue(parsedHeaders['cc']) : undefined, + bcc: parsedHeaders['bcc'] ? decodeHeaderValue(parsedHeaders['bcc']) : undefined, + date: parsedHeaders['date'] || '', + headers: parsedHeaders + }; + + if (headerInfo.contentType.includes('text/plain')) { + result.text = content; + } else if (headerInfo.contentType.includes('text/html')) { + result.html = content; + } + + return result; +} + +/** + * Process a multipart email + */ +function processMultipartEmail(rawEmail: string, headerInfo: EmailHeaderInfo): DecodedEmail { + if (!headerInfo.boundary) { + throw new Error('Multipart email missing boundary'); + } + + const boundary = headerInfo.boundary; + const splitEmail = rawEmail.split('\r\n\r\n'); + const headers = splitEmail[0]; + const parsedHeaders = parseHeadersToObject(headers); + + const result: DecodedEmail = { + subject: decodeHeaderValue(parsedHeaders['subject'] || ''), + from: decodeHeaderValue(parsedHeaders['from'] || ''), + to: decodeHeaderValue(parsedHeaders['to'] || ''), + cc: parsedHeaders['cc'] ? decodeHeaderValue(parsedHeaders['cc']) : undefined, + bcc: parsedHeaders['bcc'] ? decodeHeaderValue(parsedHeaders['bcc']) : undefined, + date: parsedHeaders['date'] || '', + attachments: [], + headers: parsedHeaders + }; + + // Split by boundary + const bodyContent = rawEmail.split('--' + boundary); + + // Process each part (skip first as it's headers and last as it's boundary end) + for (let i = 1; i < bodyContent.length - 1; i++) { + const part = bodyContent[i]; + const partHeaders = extractHeaders(part); + const partHeaderInfo = parseEmailHeaders(partHeaders); + + // Handle sub-multipart (nested multipart) + if (partHeaderInfo.contentType.includes('multipart') && partHeaderInfo.boundary) { + const subMultipart = processMultipartEmail(part, partHeaderInfo); + if (subMultipart.html) result.html = subMultipart.html; + if (subMultipart.text) result.text = subMultipart.text; + if (subMultipart.attachments) { + result.attachments = [...(result.attachments || []), ...subMultipart.attachments]; + } + continue; + } + + // Get content after headers + const partContent = part.split('\r\n\r\n').slice(1).join('\r\n\r\n'); + const decodedContent = decodeMimeContent(partContent, partHeaderInfo.encoding); + const content = convertCharset(decodedContent, partHeaderInfo.charset); + + // Check content disposition + const contentDisposition = getHeaderValue(partHeaders, 'Content-Disposition') || ''; + + if (contentDisposition.includes('attachment')) { + // This is an attachment + const filename = extractFilename(contentDisposition); + if (result.attachments && filename) { + result.attachments.push({ + filename, + contentType: partHeaderInfo.contentType, + encoding: partHeaderInfo.encoding, + content: decodedContent + }); + } } else { - // Plain text or other encoding - return text; + // This is a content part + if (partHeaderInfo.contentType.includes('text/plain')) { + result.text = content; + } else if (partHeaderInfo.contentType.includes('text/html')) { + result.html = content; + } } - } catch (error) { - console.error('Error decoding MIME:', error); - return text; // Return original text if decoding fails } + + return result; } /** - * Decode a quoted-printable encoded string - * @param {string} text - The quoted-printable encoded text - * @param {string} charset - The character set - * @returns {string} - The decoded text + * Extract headers from an email or part */ -export function decodeQuotedPrintable(text: string, charset: string): string { - // Replace soft line breaks (=\r\n or =\n) - let decoded = text.replace(/=(?:\r\n|\n)/g, ''); - - // Replace quoted-printable encoded characters - decoded = decoded.replace(/=([0-9A-F]{2})/gi, (match, p1) => { - return String.fromCharCode(parseInt(p1, 16)); - }); - - // Handle character encoding - if (charset !== 'utf-8' && typeof TextDecoder !== 'undefined') { - try { - const bytes = new Uint8Array(decoded.length); - for (let i = 0; i < decoded.length; i++) { - bytes[i] = decoded.charCodeAt(i); - } - return new TextDecoder(charset).decode(bytes); - } catch (e) { - console.warn('TextDecoder error:', e); - } - } - - return decoded; +function extractHeaders(content: string): string { + const headerEnd = content.indexOf('\r\n\r\n'); + if (headerEnd === -1) return content; + return content.substring(0, headerEnd); } /** - * Decode a base64 encoded string - * @param {string} text - The base64 encoded text - * @param {string} charset - The character set - * @returns {string} - The decoded text + * Parse email headers into an object */ -export function decodeBase64(text: string, charset: string): string { - // Remove whitespace that might be present in the base64 string - const cleanText = text.replace(/\s/g, ''); +function parseHeadersToObject(headers: string): Record { + const result: Record = {}; + const lines = headers.split('\r\n'); - try { - // Use built-in atob function and TextDecoder for charset handling - const binary = atob(cleanText); - if (charset !== 'utf-8' && typeof TextDecoder !== 'undefined') { - // If TextDecoder is available and the charset is not utf-8 - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); + let currentHeader = ''; + let currentValue = ''; + + for (const line of lines) { + // If line starts with a space or tab, it's a continuation + if (line.startsWith(' ') || line.startsWith('\t')) { + currentValue += ' ' + line.trim(); + } else { + // Save previous header if exists + if (currentHeader) { + result[currentHeader.toLowerCase()] = currentValue; + } + + const colonIndex = line.indexOf(':'); + if (colonIndex !== -1) { + currentHeader = line.substring(0, colonIndex).trim(); + currentValue = line.substring(colonIndex + 1).trim(); } - return new TextDecoder(charset).decode(bytes); } - return binary; - } catch (e) { - console.error('Base64 decoding error:', e); - return text; } + + // Save the last header + if (currentHeader) { + result[currentHeader.toLowerCase()] = currentValue; + } + + return result; } /** * Parse email headers to extract content type, encoding and charset - * @param {string} headers - The raw email headers - * @returns {Object} - Object containing content type, encoding and charset */ -export function parseEmailHeaders(headers: string): { - contentType: string; - encoding: string; - charset: string; -} { - const result = { - contentType: 'text/plain', - encoding: 'quoted-printable', - charset: 'utf-8' - }; +function parseEmailHeaders(headers: string): EmailHeaderInfo { + const contentType = getHeaderValue(headers, 'Content-Type') || 'text/plain'; + const encoding = getHeaderValue(headers, 'Content-Transfer-Encoding') || '7bit'; - // Extract content type - const contentTypeMatch = headers.match(/Content-Type:\s*([^;]+)(?:;\s*charset=([^;]+))?/i); - if (contentTypeMatch) { - result.contentType = contentTypeMatch[1].trim().toLowerCase(); - if (contentTypeMatch[2]) { - result.charset = contentTypeMatch[2].trim().replace(/"/g, '').toLowerCase(); + // Extract charset + let charset = 'utf-8'; + const charsetMatch = contentType.match(/charset\s*=\s*["']?([^"';\s]+)/i); + if (charsetMatch) { + charset = charsetMatch[1]; + } + + // Extract boundary for multipart emails + let boundary; + const boundaryMatch = contentType.match(/boundary\s*=\s*["']?([^"';\s]+)/i); + if (boundaryMatch) { + boundary = boundaryMatch[1]; + } + + return { contentType, encoding, charset, boundary }; +} + +/** + * Get a specific header value + */ +function getHeaderValue(headers: string, name: string): string | null { + const regex = new RegExp(`${name}:\\s*([^\\r\\n]+)`, 'i'); + const match = headers.match(regex); + return match ? match[1].trim() : null; +} + +/** + * Extract filename from Content-Disposition header + */ +function extractFilename(contentDisposition: string): string { + const filenameMatch = contentDisposition.match(/filename\s*=\s*["']?([^"';\s]+)/i); + if (filenameMatch) return filenameMatch[1]; + + // For encoded filenames + const encodedFilenameMatch = contentDisposition.match(/filename\*=([^']*)'[^']*'([^;]+)/i); + if (encodedFilenameMatch) { + try { + return decodeURIComponent(encodedFilenameMatch[2].replace(/%([\dA-F]{2})/g, '%$1')); + } catch (e) { + return encodedFilenameMatch[2]; } } - // Extract content transfer encoding - const encodingMatch = headers.match(/Content-Transfer-Encoding:\s*([^\s]+)/i); - if (encodingMatch) { - result.encoding = encodingMatch[1].trim().toLowerCase(); + return 'attachment'; +} + +/** + * Decode MIME content based on encoding + */ +function decodeMimeContent(content: string, encoding: string): string { + switch (encoding.toLowerCase()) { + case 'quoted-printable': + return decodeQuotedPrintable(content); + case 'base64': + return decodeBase64(content); + case '7bit': + case '8bit': + case 'binary': + default: + return content; } - - return result; } /** - * Decode an email body based on its headers - * @param {string} emailRaw - The raw email content (headers + body) - * @returns {Object} - Object containing decoded text and html parts + * Decode quoted-printable content */ -export function decodeEmail(emailRaw: string): { - contentType: string; - charset: string; - encoding: string; - decodedBody: string; - headers: string; -} { - // Separate headers and body - const parts = emailRaw.split(/\r?\n\r?\n/); - const headers = parts[0]; - const body = parts.slice(1).join('\n\n'); - - // Parse headers - const { contentType, encoding, charset } = parseEmailHeaders(headers); - - // Decode the body - const decodedBody = decodeMIME(body, encoding, charset); - - return { - contentType, - charset, - encoding, - decodedBody, - headers - }; -} - -interface EmailContent { - text: string; - html: string; - attachments: Array<{ - contentType: string; - content: string; - filename?: string; - }>; +function decodeQuotedPrintable(content: string): string { + return content + .replace(/=\r\n/g, '') // Remove soft line breaks + .replace(/=([a-fA-F0-9]{2})/g, (match, p1) => { // Replace hex codes with chars + return String.fromCharCode(parseInt(p1, 16)); + }); } /** - * Process a multipart email to extract text and HTML parts - * @param {string} emailRaw - The raw email content - * @param {string} boundary - The multipart boundary - * @returns {Object} - Object containing text and html parts + * Decode base64 content */ -export function processMultipartEmail(emailRaw: string, boundary: string): EmailContent { - const result: EmailContent = { - text: '', - html: '', - attachments: [] - }; - - // Split by boundary - const boundaryRegex = new RegExp(`--${boundary}\\r?\\n|--${boundary}--\\r?\\n?`, 'g'); - const parts = emailRaw.split(boundaryRegex).filter(part => part.trim()); - - // Process each part - parts.forEach(part => { - const decoded = decodeEmail(part); - - if (decoded.contentType === 'text/plain') { - result.text = decoded.decodedBody; - } else if (decoded.contentType === 'text/html') { - result.html = decoded.decodedBody; - } else if (decoded.contentType.startsWith('image/') || - decoded.contentType.startsWith('application/')) { - // Extract filename if available - const filenameMatch = decoded.headers.match(/filename=["']?([^"';\r\n]+)/i); - const filename = filenameMatch ? filenameMatch[1] : 'attachment'; - - // Handle attachments - result.attachments.push({ - contentType: decoded.contentType, - content: decoded.decodedBody, - filename - }); - } - }); - - return result; +function decodeBase64(content: string): string { + // Remove any whitespace + const cleanContent = content.replace(/\s+/g, ''); + try { + return atob(cleanContent); + } catch (e) { + console.error('Error decoding base64', e); + return content; + } } /** - * Extract boundary from Content-Type header - * @param {string} contentType - The Content-Type header value - * @returns {string|null} - The boundary string or null if not found + * Convert content from specified charset to UTF-8 */ -export function extractBoundary(contentType: string): string | null { - const boundaryMatch = contentType.match(/boundary=["']?([^"';]+)/i); - return boundaryMatch ? boundaryMatch[1] : null; -} - -/** - * Parse an email from its raw content - * @param {string} rawEmail - The raw email content - * @returns {Object} - The parsed email with text and html parts - */ -export function parseRawEmail(rawEmail: string): EmailContent { - // Default result structure - const result: EmailContent = { - text: '', - html: '', - attachments: [] - }; +function convertCharset(content: string, charset: string): string { + // Basic charset conversion - for more complex cases, consider TextDecoder + if (charset.toLowerCase() === 'utf-8' || charset.toLowerCase() === 'utf8') { + return content; + } try { - // Split headers and body - const headerBodySplit = rawEmail.split(/\r?\n\r?\n/); - const headers = headerBodySplit[0]; - const body = headerBodySplit.slice(1).join('\n\n'); - - // Check if multipart - const contentTypeHeader = headers.match(/Content-Type:\s*([^\r\n]+)/i); - - if (contentTypeHeader && contentTypeHeader[1].includes('multipart/')) { - // Get boundary - const boundary = extractBoundary(contentTypeHeader[1]); + // For browsers that support TextDecoder + if (typeof TextDecoder !== 'undefined') { + // Convert string to ArrayBuffer + const buffer = new Uint8Array(content.length); + for (let i = 0; i < content.length; i++) { + buffer[i] = content.charCodeAt(i); + } - if (boundary) { - // Process multipart email - return processMultipartEmail(rawEmail, boundary); + const decoder = new TextDecoder(charset); + return decoder.decode(buffer); + } + } catch (e) { + console.warn('TextDecoder not supported or failed for charset:', charset); + } + + // Fallback for simpler encodings + if (charset.toLowerCase() === 'iso-8859-1' || charset.toLowerCase() === 'latin1') { + return content; // Browser will handle this reasonably + } + + console.warn('Unsupported charset:', charset); + return content; // Return as-is if we can't convert +} + +/** + * Decode encoded header values (RFC 2047) + */ +function decodeHeaderValue(value: string): string { + // Decode headers like =?UTF-8?Q?Subject?= + return value.replace(/=\?([^?]+)\?([BQ])\?([^?]*)\?=/gi, (match, charset, encoding, text) => { + if (encoding.toUpperCase() === 'B') { + // Base64 encoding + try { + const decoded = atob(text); + return convertCharset(decoded, charset); + } catch (e) { + return text; + } + } else if (encoding.toUpperCase() === 'Q') { + // Quoted-printable + try { + const decoded = text + .replace(/_/g, ' ') + .replace(/=([\da-fA-F]{2})/g, (m: string, hex: string) => + String.fromCharCode(parseInt(hex, 16)) + ); + return convertCharset(decoded, charset); + } catch (e) { + return text; } } + return text; + }); +} + +/** + * Clean HTML content for safe rendering + */ +export function cleanHtml(html: string): string { + // Basic sanitization - consider using DOMPurify in a real app + return html + .replace(/)<[^<]*)*<\/script>/gi, '') + .replace(/on\w+="[^"]*"/g, '') + .replace(/on\w+='[^']*'/g, '') + .replace(/on\w+=\w+/g, ''); +} + +/** + * Check if email content is likely in MIME format + */ +export function isMimeFormat(content: string | undefined): boolean { + if (!content) return false; + + // Check for common MIME headers + return ( + content.includes('Content-Type:') && + content.includes('MIME-Version:') && + /\r\n\r\n/.test(content) + ); +} + +/** + * Adapt legacy email to use the decoded MIME content + */ +export function adaptMimeEmail(legacyEmail: LegacyEmailMessage): LegacyEmailMessage { + if (!legacyEmail.content || typeof legacyEmail.content !== 'string' || !isMimeFormat(legacyEmail.content)) { + return legacyEmail; + } + + try { + const decoded = decodeInfomaniakEmail(legacyEmail.content); - // Not multipart, decode as a single part - const { contentType, encoding, charset, decodedBody } = decodeEmail(rawEmail); - - // Set content based on type - if (contentType.includes('text/html')) { - result.html = decodedBody; - } else { - result.text = decodedBody; - } - - return result; - } catch (error) { - console.error('Error parsing raw email:', error); - // Return raw content as text on error - result.text = rawEmail; - return result; + return { + ...legacyEmail, + html: decoded.html, + text: decoded.text || '', + subject: decoded.subject || legacyEmail.subject, + // Keep original content for reference + content: decoded.html || decoded.text || '' + }; + } catch (e) { + console.error('Failed to decode MIME email:', e); + return legacyEmail; } } \ No newline at end of file diff --git a/lib/utils/email-utils.ts b/lib/utils/email-utils.ts index 2711a845..81e6847d 100644 --- a/lib/utils/email-utils.ts +++ b/lib/utils/email-utils.ts @@ -14,6 +14,8 @@ import { EmailContent, EmailAddress } from '@/types/email'; +import { adaptLegacyEmail } from './email-adapters'; +import { decodeInfomaniakEmail, adaptMimeEmail, isMimeFormat } from './email-mime-decoder'; // Reset any existing hooks to start clean DOMPurify.removeAllHooks(); @@ -154,92 +156,31 @@ export function formatPlainTextToHtml(text: string): string { * Normalize email content to our standard format regardless of input format * This is the key function that handles all the different email content formats */ -export function normalizeEmailContent(email: any): EmailContent { - // Default content structure - const normalizedContent: EmailContent = { - html: undefined, - text: '', - isHtml: false, - direction: 'ltr' - }; - - try { - // Extract content based on standardized property hierarchy - let htmlContent = ''; - let textContent = ''; - let isHtml = false; - - // Step 1: Extract content from the various possible formats - if (email.content && typeof email.content === 'object') { - isHtml = !!email.content.html; - htmlContent = email.content.html || ''; - textContent = email.content.text || ''; - } else if (typeof email.content === 'string') { - // Check if the string content is HTML - isHtml = email.content.trim().startsWith('<') && - (email.content.includes('')); - htmlContent = isHtml ? email.content : ''; - textContent = isHtml ? '' : email.content; - } else if (email.html) { - isHtml = true; - htmlContent = email.html; - textContent = email.text || ''; - } else if (email.text) { - isHtml = false; - htmlContent = ''; - textContent = email.text; - } else if (email.formattedContent) { - // Assume formattedContent is already HTML - isHtml = true; - htmlContent = email.formattedContent; - textContent = ''; - } - - // Step 2: Set the normalized content properties - normalizedContent.isHtml = isHtml; - - // Always ensure we have text content - if (textContent) { - normalizedContent.text = textContent; - } else if (htmlContent) { - // Extract text from HTML if we don't have plain text - if (typeof document !== 'undefined') { - // Browser environment - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = htmlContent; - normalizedContent.text = tempDiv.textContent || tempDiv.innerText || ''; - } else { - // Server environment - do simple strip - normalizedContent.text = htmlContent - .replace(/<[^>]*>/g, '') - .replace(/ /g, ' ') - .replace(/\s+/g, ' ') - .trim(); - } - } - - // If we have HTML content, sanitize it - if (isHtml && htmlContent) { - normalizedContent.html = sanitizeHtml(htmlContent); - } - - // Determine text direction - normalizedContent.direction = detectTextDirection(normalizedContent.text); - - return normalizedContent; - } catch (error) { - console.error('Error normalizing email content:', error); - - // Return minimal valid content in case of error - return { - text: 'Error loading email content', - isHtml: false, - direction: 'ltr' - }; +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...'); + return adaptMimeEmail(email); + } 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 + return adaptLegacyEmail(email); } /** diff --git a/types/email.ts b/types/email.ts index 8b22689a..f4ea9273 100644 --- a/types/email.ts +++ b/types/email.ts @@ -20,10 +20,10 @@ export interface EmailAttachment { } export interface EmailContent { - html?: string; // HTML content if available - text: string; // Plain text (always present) - isHtml: boolean; // Flag to indicate primary content type - direction?: 'ltr'|'rtl' // Text direction + text: string; + html?: string; + isHtml: boolean; + direction: 'ltr' | 'rtl'; } export interface EmailFlags { @@ -36,18 +36,43 @@ export interface EmailFlags { export interface EmailMessage { id: string; - messageId?: string; - uid?: number; subject: string; - from: EmailAddress[]; - to: EmailAddress[]; - cc?: EmailAddress[]; - bcc?: EmailAddress[]; - date: Date | string; - flags: EmailFlags; - preview?: string; - content: EmailContent; // Standardized content structure - attachments: EmailAttachment[]; - folder?: string; - size?: number; + from: string; + to: string; + cc?: string; + bcc?: string; + date: string; + flags: string[]; + content: EmailContent; + attachments?: Array<{ + filename: string; + contentType: string; + encoding?: string; + content?: string; + }>; + // For debugging and transition + _originalFormat?: any; +} + +// This represents the legacy email format that might come from API +export interface LegacyEmailMessage { + id: string; + subject: string; + from: string; + to: string; + cc?: string; + bcc?: string; + date: string; + flags?: string[] | Record; + content?: string | EmailContent; + html?: string; // Some APIs provide HTML directly + text?: string; // Some APIs provide text directly + plainText?: string; // Alternative to text + formattedContent?: string; // Legacy property + attachments?: Array<{ + filename?: string; + name?: string; + contentType?: string; + content?: string; + }>; } \ No newline at end of file