448 lines
15 KiB
TypeScript
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, '&')
|
|
.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
|
|
* 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(/ /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(/ /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(/ /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);
|
|
}
|
|
}
|