Neah/lib/utils/email-formatter.ts
2025-04-26 19:40:07 +02:00

281 lines
8.8 KiB
TypeScript

/**
* CENTRAL EMAIL FORMATTER
*
* This is the primary and only email formatting utility to be used.
* All email formatting should go through here to ensure consistent
* handling of text direction and HTML sanitization.
*/
import DOMPurify from 'isomorphic-dompurify';
// Configure DOMPurify to preserve direction attributes
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
// Preserve direction attributes
if (node.hasAttribute('dir')) {
node.setAttribute('dir', node.getAttribute('dir') || 'ltr');
}
// Preserve text-align in styles
if (node.hasAttribute('style')) {
const style = node.getAttribute('style') || '';
if (style.includes('text-align')) {
// Keep existing alignment
} else if (node.hasAttribute('dir') && node.getAttribute('dir') === 'rtl') {
node.setAttribute('style', style + '; text-align: right;');
}
}
});
// Interface definitions
export interface EmailAddress {
name: string;
address: string;
}
export interface EmailMessage {
id: string;
messageId?: string;
subject: string;
from: EmailAddress[];
to: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
date: Date | string;
flags?: {
seen: boolean;
flagged: boolean;
answered: boolean;
deleted: boolean;
draft: boolean;
};
preview?: string;
content?: string;
html?: string;
text?: string;
hasAttachments?: boolean;
attachments?: any[];
folder?: string;
size?: number;
contentFetched?: boolean;
}
/**
* 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();
}
}
/**
* Clean HTML content to prevent RTL/LTR issues
* This is the ONLY function that should be used for cleaning HTML content
*/
export function cleanHtmlContent(content: string): string {
if (!content) return '';
// First sanitize the HTML with our configured DOMPurify
const sanitized = DOMPurify.sanitize(content);
// Process content to ensure consistent direction
let processed = sanitized;
// Replace RTL attributes with LTR if needed
// We're now more careful to only modify direction attributes if needed
processed = processed.replace(/dir\s*=\s*["']rtl["']/gi, 'dir="ltr"');
processed = processed.replace(/style\s*=\s*["']([^"']*)direction\s*:\s*rtl;?([^"']*)["']/gi,
(match, before, after) => `style="${before}direction: ltr;${after}"`);
return processed;
}
/**
* Format an email for forwarding - CENTRAL IMPLEMENTATION
* All other formatting functions should be deprecated in favor of this one
*/
export function formatForwardedEmail(email: EmailMessage): {
subject: string;
content: string;
} {
// 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 and clean original content
const originalContent = cleanHtmlContent(email.content || email.html || email.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
if (hasExistingHeader) {
// Just wrap the content in appropriate styling without adding another header
const content = `
<div style="min-height: 20px;"></div>
<div style="direction: ltr; text-align: left;" dir="ltr" class="email-original-content">
${originalContent}
</div>
`;
return { subject, content };
}
// Create formatted content with explicit LTR formatting
const content = `
<div style="min-height: 20px;"></div>
<div style="border-top: 1px solid #ccc; margin-top: 10px; padding-top: 10px; direction: ltr; text-align: left;" dir="ltr">
<div style="font-family: Arial, sans-serif; color: #333; direction: ltr; text-align: left;" dir="ltr">
<div style="margin-bottom: 15px; direction: ltr; text-align: left;" dir="ltr">
<div style="direction: ltr; text-align: left;" dir="ltr">---------- Forwarded message ---------</div>
<div style="direction: ltr; text-align: left;" dir="ltr"><b>From:</b> ${fromString}</div>
<div style="direction: ltr; text-align: left;" dir="ltr"><b>Date:</b> ${dateString}</div>
<div style="direction: ltr; text-align: left;" dir="ltr"><b>Subject:</b> ${email.subject || ''}</div>
<div style="direction: ltr; text-align: left;" dir="ltr"><b>To:</b> ${toString}</div>
</div>
<div style="direction: ltr; text-align: left;" dir="ltr" class="email-original-content">
${originalContent}
</div>
</div>
</div>
`;
return { subject, content };
}
/**
* Format an email for reply or reply-all - CENTRAL IMPLEMENTATION
* All other formatting functions should be deprecated in favor of this one
*/
export function formatReplyEmail(email: EmailMessage, type: 'reply' | 'reply-all'): {
to: string;
cc?: string;
subject: string;
content: string;
} {
// Format subject with Re: prefix if needed
const subjectBase = email.subject || '(No subject)';
const subject = subjectBase.match(/^Re:/i)
? subjectBase
: `Re: ${subjectBase}`;
// Get sender information for quote header
const sender = email.from[0];
const fromText = sender?.name
? `${sender.name} <${sender.address}>`
: sender?.address || 'Unknown sender';
// Format date for quote header
const date = typeof email.date === 'string' ? new Date(email.date) : email.date;
const formattedDate = date.toLocaleString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
// Create quote header
const quoteHeader = `<div style="font-weight: 500; direction: ltr; text-align: left;" dir="ltr">On ${formattedDate}, ${fromText} wrote:</div>`;
// Get and clean original content
const quotedContent = cleanHtmlContent(email.html || email.content || email.text || '');
// Format recipients
let to = formatEmailAddresses(email.from || []);
let cc = '';
if (type === 'reply-all') {
// For reply-all, add all original recipients to CC
const allRecipients = [
...(email.to || []),
...(email.cc || [])
];
cc = formatEmailAddresses(allRecipients);
}
// Format content with explicit LTR for quoted parts
const content = `
<div style="min-height: 20px;"></div>
<div class="reply-body" style="direction: ltr; text-align: left;" dir="ltr">
<div class="quote-header" style="color: #555; font-size: 13px; margin: 20px 0 10px 0; direction: ltr; text-align: left;" dir="ltr">${quoteHeader}</div>
<blockquote style="margin: 0; padding: 10px 0 10px 15px; border-left: 3px solid #ddd; color: #555; background-color: #f8f8f8; border-radius: 4px; direction: ltr; text-align: left;" dir="ltr">
<div class="quoted-content" style="font-size: 13px; direction: ltr; text-align: left;" dir="ltr">
${quotedContent}
</div>
</blockquote>
</div>
`;
return {
to,
cc: cc || undefined,
subject,
content
};
}
/**
* COMPATIBILITY LAYER: For backward compatibility with the old email-formatter.ts
* These functions map to our new implementation but preserve the old interface
*/
// For compatibility with old code that might be using the other email-formatter.ts
export function formatEmailForReplyOrForward(
email: EmailMessage,
type: 'reply' | 'reply-all' | 'forward'
): {
to: string;
cc?: string;
subject: string;
body: string;
} {
if (type === 'forward') {
const { subject, content } = formatForwardedEmail(email);
return {
to: '',
subject,
body: content
};
} else {
const { to, cc, subject, content } = formatReplyEmail(email, type as 'reply' | 'reply-all');
return {
to,
cc,
subject,
body: content
};
}
}