Neah/lib/utils/email-utils.ts
2025-05-01 09:59:00 +02:00

499 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,
LegacyEmailMessage
} from '@/types/email';
import { adaptLegacyEmail } from '@/lib/utils/email-adapters';
import { decodeInfomaniakEmail, adaptMimeEmail, isMimeFormat } from './email-mime-decoder';
import { detectTextDirection, applyTextDirection } from '@/lib/utils/text-direction';
// Reset any existing hooks to start clean
DOMPurify.removeAllHooks();
// Remove the hook that adds dir="auto" - we'll handle direction explicitly instead
// Configure DOMPurify to preserve direction attributes
DOMPurify.setConfig({
ADD_ATTR: ['dir'],
ALLOWED_ATTR: ['style', 'class', 'id', 'dir']
});
/**
* 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();
}
}
/**
* 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
const fixedHtml = 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://');
// We don't manually add direction here anymore - applyTextDirection will handle it
return fixedHtml;
} 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 | null | undefined): 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): 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);
}
/**
* 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>';
}
const safeContent = {
text: content.text || '',
html: content.html,
isHtml: content.isHtml,
direction: content.direction || 'ltr'
};
// If we have HTML content and isHtml flag is true, use it
if (safeContent.isHtml && safeContent.html) {
// Apply text direction consistently using the utility
return applyTextDirection(safeContent.html, safeContent.text);
}
// Otherwise, format the text content with basic HTML
const text = safeContent.text;
const formattedText = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
// Apply text direction consistently
return applyTextDirection(formattedText, text);
}
// Add interface for email formatting functions
interface FormattedEmail {
to: string;
cc?: string;
subject: string;
content: EmailContent;
}
/**
* 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
}
};
}
// Format the recipients
const to = Array.isArray(originalEmail.from)
? originalEmail.from.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.address ? addr.address : '';
}).filter(Boolean).join(', ')
: typeof originalEmail.from === 'string'
? originalEmail.from
: '';
// For reply-all, include other recipients in CC
let cc = '';
if (type === 'reply-all') {
const toRecipients = Array.isArray(originalEmail.to)
? originalEmail.to.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.address ? addr.address : '';
}).filter(Boolean)
: typeof originalEmail.to === 'string'
? [originalEmail.to]
: [];
const ccRecipients = Array.isArray(originalEmail.cc)
? originalEmail.cc.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.address ? addr.address : '';
}).filter(Boolean)
: typeof originalEmail.cc === 'string'
? [originalEmail.cc]
: [];
cc = [...toRecipients, ...ccRecipients].join(', ');
}
// Format the subject
const subject = originalEmail.subject && !originalEmail.subject.startsWith('Re:')
? `Re: ${originalEmail.subject}`
: originalEmail.subject || '';
// Format the content
const originalDate = originalEmail.date ? new Date(originalEmail.date) : new Date();
const dateStr = originalDate.toLocaleString();
const fromStr = Array.isArray(originalEmail.from)
? originalEmail.from.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.name ? `${addr.name} <${addr.address}>` : addr.address;
}).join(', ')
: typeof originalEmail.from === 'string'
? originalEmail.from
: 'Unknown Sender';
const toStr = Array.isArray(originalEmail.to)
? originalEmail.to.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.name ? `${addr.name} <${addr.address}>` : addr.address;
}).join(', ')
: typeof originalEmail.to === 'string'
? originalEmail.to
: '';
// Extract original content
const originalTextContent =
typeof originalEmail?.content === 'object' ? originalEmail.content.text :
typeof originalEmail?.content === 'string' ? originalEmail.content :
originalEmail?.text || '';
const originalHtmlContent =
typeof originalEmail?.content === 'object' ? originalEmail.content.html :
originalEmail?.html ||
(typeof originalEmail?.content === 'string' && originalEmail?.content.includes('<')
? originalEmail.content
: '');
// Get the direction from the original email
const originalDirection =
typeof originalEmail?.content === 'object' ? originalEmail.content.direction :
detectTextDirection(originalTextContent);
// Create content with appropriate quote formatting
const replyBody = `
<br/>
<br/>
<blockquote style="border-left: 2px solid #ddd; padding-left: 10px; margin: 10px 0; color: #505050;">
<p>On ${dateStr}, ${fromStr} wrote:</p>
${originalHtmlContent || originalTextContent.replace(/\n/g, '<br>')}
</blockquote>
`;
// Apply consistent text direction
const htmlContent = applyTextDirection(replyBody);
// Create plain text content
const textContent = `
On ${dateStr}, ${fromStr} wrote:
> ${originalTextContent.split('\n').join('\n> ')}
`;
return {
to,
cc,
subject,
content: {
text: textContent,
html: htmlContent,
isHtml: true,
direction: 'ltr' as const // Reply is LTR, but original content keeps its direction in the blockquote
}
};
}
/**
* 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
}
};
}
// Format the subject
const subject = originalEmail.subject && !originalEmail.subject.startsWith('Fwd:')
? `Fwd: ${originalEmail.subject}`
: originalEmail.subject || '';
// Format from, to, cc for the header
const fromStr = Array.isArray(originalEmail.from)
? originalEmail.from.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.name ? `${addr.name} <${addr.address}>` : addr.address;
}).join(', ')
: typeof originalEmail.from === 'string'
? originalEmail.from
: 'Unknown Sender';
const toStr = Array.isArray(originalEmail.to)
? originalEmail.to.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.name ? `${addr.name} <${addr.address}>` : addr.address;
}).join(', ')
: typeof originalEmail.to === 'string'
? originalEmail.to
: '';
const ccStr = Array.isArray(originalEmail.cc)
? originalEmail.cc.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.name ? `${addr.name} <${addr.address}>` : addr.address;
}).join(', ')
: typeof originalEmail.cc === 'string'
? originalEmail.cc
: '';
const dateStr = originalEmail.date ? new Date(originalEmail.date).toLocaleString() : 'Unknown Date';
// Extract original content
const originalTextContent =
typeof originalEmail?.content === 'object' ? originalEmail.content.text :
typeof originalEmail?.content === 'string' ? originalEmail.content :
originalEmail?.text || '';
const originalHtmlContent =
typeof originalEmail?.content === 'object' ? originalEmail.content.html :
originalEmail?.html ||
(typeof originalEmail?.content === 'string' && originalEmail?.content.includes('<')
? originalEmail.content
: '');
// Get the direction from the original email
const originalDirection =
typeof originalEmail?.content === 'object' ? originalEmail.content.direction :
detectTextDirection(originalTextContent);
// Create forwarded content with header information
const forwardBody = `
<br/>
<br/>
<div class="email-forwarded-content">
<p>---------- Forwarded message ---------</p>
<p><strong>From:</strong> ${fromStr}</p>
<p><strong>Date:</strong> ${dateStr}</p>
<p><strong>Subject:</strong> ${originalEmail?.subject || ''}</p>
<p><strong>To:</strong> ${toStr}</p>
${ccStr ? `<p><strong>Cc:</strong> ${ccStr}</p>` : ''}
<div style="margin-top: 15px; border-top: 1px solid #eee; padding-top: 15px;">
${originalHtmlContent || originalTextContent.replace(/\n/g, '<br>')}
</div>
</div>
`;
// Apply consistent text direction
const htmlContent = applyTextDirection(forwardBody);
// Create plain text content
const textContent = `
---------- Forwarded message ---------
From: ${fromStr}
Date: ${dateStr}
Subject: ${originalEmail?.subject || ''}
To: ${toStr}
${ccStr ? `Cc: ${ccStr}\n` : ''}
${originalTextContent}
`;
return {
to: '',
subject,
content: {
text: textContent,
html: htmlContent,
isHtml: true,
direction: 'ltr' as const // Forward is LTR, but original content keeps its direction
}
};
}
/**
* Format an email for reply or reply-all
*/
export function formatEmailForReplyOrForward(
email: EmailMessage,
type: 'reply' | 'reply-all' | 'forward'
): {
to?: string;
cc?: string;
subject: string;
content: EmailContent;
} {
// Use our dedicated formatters but ensure the return is properly typed
if (type === 'forward') {
const formatted = formatForwardedEmail(email);
return {
to: formatted.to,
subject: formatted.subject,
content: formatted.content
};
} else {
const formatted = formatReplyEmail(email, type);
return {
to: formatted.to,
cc: formatted.cc,
subject: formatted.subject,
content: formatted.content
};
}
}