Neah/lib/utils/email-utils.ts
2025-05-01 17:01:26 +02:00

667 lines
21 KiB
TypeScript

/**
* 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 '<div class="email-content-empty">No content available</div>';
}
// 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('<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}
</blockquote>
`;
}
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 = `
<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>` : ''}
</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>
`;
}
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');
}
}