Neah/lib/utils/email-utils.ts
2025-04-30 20:55:17 +02:00

448 lines
15 KiB
TypeScript

/**
* Unified Email Utilities
*
* This file contains all email-related utility functions:
* - Content normalization
* - Content sanitization
* - Email formatting (replies, forwards)
* - Text direction detection
*/
import DOMPurify from 'isomorphic-dompurify';
import {
EmailMessage,
EmailContent,
EmailAddress
} from '@/types/email';
// Reset any existing hooks to start clean
DOMPurify.removeAllHooks();
// Configure DOMPurify for auto text direction
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
if (node instanceof HTMLElement) {
// Only set direction if not already specified
if (!node.hasAttribute('dir')) {
// Add dir attribute only if not present
node.setAttribute('dir', 'auto');
}
}
});
// Configure DOMPurify to preserve direction attributes
DOMPurify.setConfig({
ADD_ATTR: ['dir'],
ALLOWED_ATTR: ['style', 'class', 'id', 'dir']
});
/**
* Detect if text contains RTL characters
*/
export function detectTextDirection(text: string): 'ltr' | 'rtl' {
// Pattern for RTL characters (Arabic, Hebrew, etc.)
const rtlLangPattern = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
return rtlLangPattern.test(text) ? 'rtl' : 'ltr';
}
/**
* Format email addresses for display
*/
export function formatEmailAddresses(addresses: EmailAddress[]): string {
if (!addresses || addresses.length === 0) return '';
return addresses.map(addr =>
addr.name && addr.name !== addr.address
? `${addr.name} <${addr.address}>`
: addr.address
).join(', ');
}
/**
* 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();
}
}
/**
* Sanitize HTML content before processing or displaying
* Uses email industry standards for proper, consistent, and secure rendering
*/
export function sanitizeHtml(html: string): string {
if (!html) return '';
try {
// Use DOMPurify with comprehensive email HTML standards
const clean = DOMPurify.sanitize(html, {
ADD_TAGS: [
'html', 'head', 'body', 'style', 'link', 'meta', 'title',
'table', 'caption', 'col', 'colgroup', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
'div', 'span', 'img', 'br', 'hr', 'section', 'article', 'header', 'footer',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'blockquote', 'pre', 'code',
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a', 'b', 'i', 'u', 'em',
'strong', 'del', 'ins', 'mark', 'small', 'sub', 'sup', 'q', 'abbr'
],
ADD_ATTR: [
'style', 'class', 'id', 'name', 'href', 'src', 'alt', 'title', 'width', 'height',
'border', 'cellspacing', 'cellpadding', 'bgcolor', 'background', 'color',
'align', 'valign', 'dir', 'lang', 'target', 'rel', 'charset', 'media',
'colspan', 'rowspan', 'scope', 'span', 'size', 'face', 'hspace', 'vspace',
'data-*'
],
KEEP_CONTENT: true,
WHOLE_DOCUMENT: false,
ALLOW_DATA_ATTR: true,
ALLOW_UNKNOWN_PROTOCOLS: true, // Needed for some email clients
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'select', 'textarea'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout'],
FORCE_BODY: false
});
// Fix common email rendering issues
return clean
// Fix for Outlook WebVML content
.replace(/<!--\[if\s+gte\s+mso/g, '<!--[if gte mso')
// Fix for broken image paths that might be relative
.replace(/(src|background)="(?!http|data|https|cid)/gi, '$1="https://');
} catch (e) {
console.error('Error sanitizing HTML:', e);
// Fall back to a basic sanitization approach
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/on\w+="[^"]*"/g, '')
.replace(/(javascript|jscript|vbscript|mocha):/gi, 'removed:');
}
}
/**
* Format plain text for HTML display with proper line breaks
*/
export function formatPlainTextToHtml(text: string): string {
if (!text) return '';
// Escape HTML characters to prevent XSS
const escapedText = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// 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
* 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('<html') ||
email.content.includes('<body') ||
email.content.includes('<div') ||
email.content.includes('<p>'));
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(/&nbsp;/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'
};
}
}
/**
* Render normalized email content into HTML for display
*/
export function renderEmailContent(content: EmailContent): string {
if (!content) {
return '<div class="email-content-empty">No content available</div>';
}
try {
// Determine if we're rendering HTML or plain text
if (content.isHtml && content.html) {
// For HTML content, wrap it with proper styling
return `<div class="email-content" dir="${content.direction}" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 100%; overflow-x: auto; overflow-wrap: break-word; word-wrap: break-word;">${content.html}</div>`;
} else {
// For plain text, format it as HTML and wrap with monospace styling
const formattedText = formatPlainTextToHtml(content.text);
return `<div class="email-content plain-text" dir="${content.direction}" style="font-family: -apple-system, BlinkMacSystemFont, Menlo, Monaco, Consolas, 'Courier New', monospace; white-space: pre-wrap; line-height: 1.5; color: #333; padding: 15px; max-width: 100%; overflow-wrap: break-word;"><p>${formattedText}</p></div>`;
}
} catch (error) {
console.error('Error rendering email content:', error);
return `<div class="email-content-error" style="padding: 15px; color: #721c24; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px;"><p>Error displaying email content</p><p style="font-size: 12px; margin-top: 10px;">${error instanceof Error ? error.message : 'Unknown error'}</p></div>`;
}
}
/**
* Format an email for forwarding
*/
export function formatForwardedEmail(email: EmailMessage): {
subject: string;
content: EmailContent;
} {
// Format subject with Fwd: prefix if needed
const subjectBase = email.subject || '(No subject)';
const subject = subjectBase.match(/^(Fwd|FW|Forward):/i)
? subjectBase
: `Fwd: ${subjectBase}`;
// Get sender and recipient information
const fromString = formatEmailAddresses(email.from || []);
const toString = formatEmailAddresses(email.to || []);
const dateString = formatEmailDate(email.date);
// Get original content as HTML
const originalContent = email.content.isHtml && email.content.html
? email.content.html
: formatPlainTextToHtml(email.content.text);
// Check if the content already has a forwarded message header
const hasExistingHeader = originalContent.includes('---------- Forwarded message ---------');
// If there's already a forwarded message header, don't add another one
let htmlContent = '';
if (hasExistingHeader) {
// Just wrap the content without additional formatting
htmlContent = `
<div style="min-height: 20px;"></div>
<div class="email-original-content">
${originalContent}
</div>
`;
} else {
// Create formatted content for forwarded email
htmlContent = `
<div style="min-height: 20px;">
<div style="border-top: 1px solid #ccc; margin-top: 10px; padding-top: 10px;">
<div style="font-family: Arial, sans-serif; color: #333;">
<div style="margin-bottom: 15px;">
<div>---------- Forwarded message ---------</div>
<div><b>From:</b> ${fromString}</div>
<div><b>Date:</b> ${dateString}</div>
<div><b>Subject:</b> ${email.subject || ''}</div>
<div><b>To:</b> ${toString}</div>
</div>
<div class="email-original-content">
${originalContent}
</div>
</div>
</div>
</div>
`;
}
// Create normalized content with HTML and extracted text
const content: EmailContent = {
html: sanitizeHtml(htmlContent),
text: '', // Will be extracted when composing
isHtml: true,
direction: email.content.direction || 'ltr'
};
// Extract text from HTML if in browser environment
if (typeof document !== 'undefined') {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
content.text = tempDiv.textContent || tempDiv.innerText || '';
} else {
// Simple text extraction in server environment
content.text = htmlContent
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.trim();
}
return { subject, content };
}
/**
* Format an email for reply or reply-all
*/
export function formatReplyEmail(email: EmailMessage, type: 'reply' | 'reply-all'): {
to: string;
cc?: string;
subject: string;
content: EmailContent;
} {
// Format email addresses
const to = formatEmailAddresses(email.from || []);
// For reply-all, include all recipients in CC except our own address
let cc = undefined;
if (type === 'reply-all' && (email.to || email.cc)) {
const allRecipients = [
...(email.to || []),
...(email.cc || [])
];
// Remove duplicates, then convert to string
const uniqueRecipients = [...new Map(allRecipients.map(addr =>
[addr.address, addr]
)).values()];
cc = formatEmailAddresses(uniqueRecipients);
}
// Format subject with Re: prefix if needed
const subjectBase = email.subject || '(No subject)';
const subject = subjectBase.match(/^Re:/i) ? subjectBase : `Re: ${subjectBase}`;
// Get original content as HTML
const originalContent = email.content.isHtml && email.content.html
? email.content.html
: formatPlainTextToHtml(email.content.text);
// Format sender info
const sender = email.from && email.from.length > 0 ? email.from[0] : undefined;
const senderName = sender ? (sender.name || sender.address) : 'Unknown Sender';
const formattedDate = formatEmailDate(email.date);
// Create the reply content with attribution line
const htmlContent = `
<div style="min-height: 20px;"></div>
<div style="border-top: 1px solid #ccc; margin-top: 10px; padding-top: 10px;">
<div style="font-family: Arial, sans-serif; color: #666; margin-bottom: 10px;">
On ${formattedDate}, ${senderName} wrote:
</div>
<blockquote style="margin: 0 0 0 10px; padding: 0 0 0 10px; border-left: 2px solid #ccc;">
${originalContent}
</blockquote>
</div>
`;
// Create normalized content with HTML and extracted text
const content: EmailContent = {
html: sanitizeHtml(htmlContent),
text: '', // Will be extracted when composing
isHtml: true,
direction: email.content.direction || 'ltr'
};
// Extract text from HTML if in browser environment
if (typeof document !== 'undefined') {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
content.text = tempDiv.textContent || tempDiv.innerText || '';
} else {
// Simple text extraction in server environment
content.text = htmlContent
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.trim();
}
return { to, cc, subject, content };
}
/**
* Format an email for reply or forward - Unified API
*/
export function formatEmailForReplyOrForward(
email: EmailMessage,
type: 'reply' | 'reply-all' | 'forward'
): {
to?: string;
cc?: string;
subject: string;
content: EmailContent;
} {
if (type === 'forward') {
const { subject, content } = formatForwardedEmail(email);
return { subject, content };
} else {
return formatReplyEmail(email, type);
}
}