646 lines
19 KiB
TypeScript
646 lines
19 KiB
TypeScript
/**
|
|
* 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, '"')
|
|
.replace(/'/g, ''');
|
|
|
|
// Format plain text with proper line breaks and paragraphs
|
|
return escapedText
|
|
.replace(/\r\n|\r|\n/g, '<br>') // Convert all newlines to <br>
|
|
.replace(/((?:<br>){2,})/g, '</p><p>') // Convert multiple newlines to paragraphs
|
|
.replace(/<br><\/p>/g, '</p>') // Fix any <br></p> combinations
|
|
.replace(/<p><br>/g, '<p>'); // Fix any <p><br> 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 '<div class="email-content-empty">No content available</div>';
|
|
}
|
|
|
|
// Use the centralized content processing function
|
|
const processed = processContentWithDirection(content);
|
|
|
|
// Return the processed HTML with proper direction
|
|
return processed.html || '<div class="email-content-empty">No content available</div>';
|
|
}
|
|
|
|
/**
|
|
* 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 content using centralized utility - get simpler text version when possible
|
|
const { text: originalTextContent, html: originalHtmlContent } = extractEmailContent(originalEmail);
|
|
|
|
// Simpler approach - prefer text content when available for clean replies
|
|
let replyBody = '';
|
|
let textReply = '';
|
|
|
|
// Create a header that works in both HTML and plain text
|
|
const headerHtml = `<div style="margin-top: 20px; margin-bottom: 10px; color: #666;">On ${dateStr}, ${fromStr} wrote:</div>`;
|
|
|
|
// Use extracted text content when available for cleaner replies
|
|
if (originalTextContent) {
|
|
// Use text content with proper line breaks - limit to a reasonable size
|
|
const maxChars = 1500;
|
|
const truncatedText = originalTextContent.length > maxChars
|
|
? originalTextContent.slice(0, maxChars) + '... [message truncated]'
|
|
: originalTextContent;
|
|
|
|
replyBody = `
|
|
${headerHtml}
|
|
<blockquote style="margin: 10px 0; padding-left: 10px; border-left: 2px solid #ddd; color: #505050;">
|
|
${truncatedText.replace(/\n/g, '<br>')}
|
|
</blockquote>
|
|
`;
|
|
|
|
textReply = `
|
|
On ${dateStr}, ${fromStr} wrote:
|
|
> ${truncatedText.split('\n').join('\n> ')}
|
|
`;
|
|
}
|
|
// If no text, try to sanitize and simplify HTML
|
|
else if (originalHtmlContent) {
|
|
try {
|
|
// Sanitize the original HTML to remove problematic elements and simplify
|
|
const sanitizedHtml = sanitizeHtml(originalHtmlContent);
|
|
|
|
// Extract the text content from the sanitized HTML for the plaintext version
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = sanitizedHtml;
|
|
const extractedText = tempDiv.textContent || tempDiv.innerText || '';
|
|
|
|
// Limit to a reasonable size
|
|
const maxChars = 1500;
|
|
const truncatedText = extractedText.length > maxChars
|
|
? extractedText.slice(0, maxChars) + '... [message truncated]'
|
|
: extractedText;
|
|
|
|
replyBody = `
|
|
${headerHtml}
|
|
<blockquote style="margin: 10px 0; padding-left: 10px; border-left: 2px solid #ddd; color: #505050;">
|
|
${truncatedText.replace(/\n/g, '<br>')}
|
|
</blockquote>
|
|
`;
|
|
|
|
textReply = `
|
|
On ${dateStr}, ${fromStr} wrote:
|
|
> ${truncatedText.split('\n').join('\n> ')}
|
|
`;
|
|
} catch (error) {
|
|
console.error('Error processing HTML for reply:', error);
|
|
// Fallback to a basic template if everything fails
|
|
replyBody = `
|
|
${headerHtml}
|
|
<blockquote style="margin: 10px 0; padding-left: 10px; border-left: 2px solid #ddd; color: #505050;">
|
|
[Original message content could not be processed]
|
|
</blockquote>
|
|
`;
|
|
|
|
textReply = `
|
|
On ${dateStr}, ${fromStr} wrote:
|
|
> [Original message content could not be processed]
|
|
`;
|
|
}
|
|
} else {
|
|
// Empty or unrecognized content
|
|
replyBody = `
|
|
${headerHtml}
|
|
<blockquote style="margin: 10px 0; padding-left: 10px; border-left: 2px solid #ddd; color: #505050;">
|
|
[Original message content not available]
|
|
</blockquote>
|
|
`;
|
|
|
|
textReply = `
|
|
On ${dateStr}, ${fromStr} wrote:
|
|
> [Original message content not available]
|
|
`;
|
|
}
|
|
|
|
// Process the content with proper direction
|
|
const processed = processContentWithDirection(replyBody);
|
|
|
|
// Extract any inline images as attachments
|
|
const inlineImages = extractInlineImages(originalHtmlContent);
|
|
|
|
return {
|
|
to,
|
|
cc,
|
|
subject: subject.startsWith('Re:') ? subject : `Re: ${subject}`,
|
|
content: {
|
|
text: textReply.trim(),
|
|
html: processed.html,
|
|
isHtml: true,
|
|
direction: processed.direction
|
|
},
|
|
// Include inline images as attachments if any were found
|
|
attachments: inlineImages.length > 0 ? inlineImages : undefined
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 content using centralized utility - get simpler text version when possible
|
|
const { text: originalTextContent, html: originalHtmlContent } = extractEmailContent(originalEmail);
|
|
|
|
// Simpler approach - prefer text content when available for clean forwards
|
|
let forwardBody = '';
|
|
let textForward = '';
|
|
|
|
// Create metadata header that works in both HTML and plain text
|
|
const headerHtml = `
|
|
<div style="margin-top: 20px; color: #666;">
|
|
<div>---------- Forwarded message ---------</div>
|
|
<div><strong>From:</strong> ${fromStr}</div>
|
|
<div><strong>Date:</strong> ${dateStr}</div>
|
|
<div><strong>Subject:</strong> ${subject || ''}</div>
|
|
<div><strong>To:</strong> ${toStr}</div>
|
|
${ccStr ? `<div><strong>Cc:</strong> ${ccStr}</div>` : ''}
|
|
</div>
|
|
`;
|
|
|
|
// Use extracted text content when available for cleaner forwards
|
|
if (originalTextContent) {
|
|
// Use text content with proper line breaks - limit to a reasonable size
|
|
const maxChars = 2000;
|
|
const truncatedText = originalTextContent.length > maxChars
|
|
? originalTextContent.slice(0, maxChars) + '... [message truncated]'
|
|
: originalTextContent;
|
|
|
|
forwardBody = `
|
|
${headerHtml}
|
|
<blockquote style="margin-top: 10px; padding-left: 10px; border-left: 2px solid #ddd; color: #505050;">
|
|
${truncatedText.replace(/\n/g, '<br>')}
|
|
</blockquote>
|
|
`;
|
|
|
|
textForward = `
|
|
---------- Forwarded message ---------
|
|
From: ${fromStr}
|
|
Date: ${dateStr}
|
|
Subject: ${subject || ''}
|
|
To: ${toStr}
|
|
${ccStr ? `Cc: ${ccStr}\n` : ''}
|
|
|
|
${truncatedText}
|
|
`;
|
|
}
|
|
// If no text, try to sanitize and simplify HTML
|
|
else if (originalHtmlContent) {
|
|
try {
|
|
// Sanitize the original HTML to remove problematic elements and simplify
|
|
const sanitizedHtml = sanitizeHtml(originalHtmlContent);
|
|
|
|
// Extract the text content from the sanitized HTML for the plaintext version
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = sanitizedHtml;
|
|
const extractedText = tempDiv.textContent || tempDiv.innerText || '';
|
|
|
|
// Limit to a reasonable size
|
|
const maxChars = 2000;
|
|
const truncatedText = extractedText.length > maxChars
|
|
? extractedText.slice(0, maxChars) + '... [message truncated]'
|
|
: extractedText;
|
|
|
|
forwardBody = `
|
|
${headerHtml}
|
|
<blockquote style="margin-top: 10px; padding-left: 10px; border-left: 2px solid #ddd; color: #505050;">
|
|
${truncatedText.replace(/\n/g, '<br>')}
|
|
</blockquote>
|
|
`;
|
|
|
|
textForward = `
|
|
---------- Forwarded message ---------
|
|
From: ${fromStr}
|
|
Date: ${dateStr}
|
|
Subject: ${subject || ''}
|
|
To: ${toStr}
|
|
${ccStr ? `Cc: ${ccStr}\n` : ''}
|
|
|
|
${truncatedText}
|
|
`;
|
|
} catch (error) {
|
|
console.error('Error processing HTML for forward:', error);
|
|
// Fallback to a basic template if everything fails
|
|
forwardBody = `
|
|
${headerHtml}
|
|
<blockquote style="margin-top: 10px; padding-left: 10px; border-left: 2px solid #ddd; color: #505050;">
|
|
[Original message content could not be processed]
|
|
</blockquote>
|
|
`;
|
|
|
|
textForward = `
|
|
---------- Forwarded message ---------
|
|
From: ${fromStr}
|
|
Date: ${dateStr}
|
|
Subject: ${subject || ''}
|
|
To: ${toStr}
|
|
${ccStr ? `Cc: ${ccStr}\n` : ''}
|
|
|
|
[Original message content could not be processed]
|
|
`;
|
|
}
|
|
} else {
|
|
// Empty or unrecognized content
|
|
forwardBody = `
|
|
${headerHtml}
|
|
<blockquote style="margin-top: 10px; padding-left: 10px; border-left: 2px solid #ddd; color: #505050;">
|
|
[Original message content not available]
|
|
</blockquote>
|
|
`;
|
|
|
|
textForward = `
|
|
---------- Forwarded message ---------
|
|
From: ${fromStr}
|
|
Date: ${dateStr}
|
|
Subject: ${subject || ''}
|
|
To: ${toStr}
|
|
${ccStr ? `Cc: ${ccStr}\n` : ''}
|
|
|
|
[Original message content not available]
|
|
`;
|
|
}
|
|
|
|
// Process the content with proper direction
|
|
const processed = processContentWithDirection(forwardBody);
|
|
|
|
// Check if the original email has attachments
|
|
const originalAttachments = originalEmail.attachments || [];
|
|
|
|
// Extract any inline images and add to attachments
|
|
const inlineImages = extractInlineImages(originalHtmlContent);
|
|
|
|
// Combine original attachments and inline images
|
|
const combinedAttachments = [
|
|
...originalAttachments.map(att => ({
|
|
filename: att.filename || 'attachment',
|
|
contentType: att.contentType || 'application/octet-stream',
|
|
content: att.content
|
|
})),
|
|
...inlineImages
|
|
];
|
|
|
|
return {
|
|
to: '',
|
|
subject: subject.startsWith('Fwd:') ? subject : `Fwd: ${subject}`,
|
|
content: {
|
|
text: textForward.trim(),
|
|
html: processed.html,
|
|
isHtml: true,
|
|
direction: 'ltr'
|
|
},
|
|
// Include attachments if any were found
|
|
attachments: combinedAttachments.length > 0 ? combinedAttachments : 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');
|
|
}
|
|
}
|