Neah/lib/utils/email-formatter.ts
2025-04-27 10:25:20 +02:00

436 lines
13 KiB
TypeScript

/**
* CENTRAL EMAIL FORMATTING UTILITY
*
* This is the centralized email formatting utility used throughout the application.
* It provides consistent handling of email content, sanitization, and text direction.
*
* All code that needs to format email content should import from this file.
* Text direction is preserved based on content language for proper RTL/LTR display.
*/
import DOMPurify from 'isomorphic-dompurify';
import sanitizeHtml from 'sanitize-html';
import { formatDateRelative } from './date-formatter';
// Reset any existing hooks to start clean
DOMPurify.removeAllHooks();
// Configure DOMPurify for English-only content (always LTR)
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
// We no longer force LTR direction on all elements
// This allows the natural text direction to be preserved
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');
}
// Don't forcibly modify text alignment or direction in style attributes
// This allows the component to control text direction instead
}
});
// Configure DOMPurify to preserve direction attributes
DOMPurify.setConfig({
ADD_ATTR: ['dir'],
ALLOWED_ATTR: ['style', 'class', 'id', 'dir']
});
// Note: We ensure LTR text direction is applied in the component level
// when rendering email content
// 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();
}
}
/**
* Sanitize HTML content before processing or displaying
* This ensures the content is properly sanitized while preserving text direction
* @param html HTML content to sanitize
* @returns Sanitized HTML with preserved text direction
*/
export function sanitizeHtml(html: string): string {
if (!html) return '';
try {
// Use DOMPurify but ensure we keep all elements and attributes that might be in emails
const clean = DOMPurify.sanitize(html, {
ADD_TAGS: ['button', 'style', 'img', 'iframe', 'meta'],
ADD_ATTR: ['target', 'rel', 'style', 'class', 'id', 'href', 'src', 'alt', 'title', 'width', 'height', 'onclick'],
KEEP_CONTENT: true,
WHOLE_DOCUMENT: false,
ALLOW_DATA_ATTR: true,
ALLOW_UNKNOWN_PROTOCOLS: true,
FORCE_BODY: false,
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
});
return clean;
} 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, '');
}
}
/**
* 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 sanitize original content (sanitization preserves content direction)
const originalContent = sanitizeHtml(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 without additional formatting
const content = `
<div style="min-height: 20px;"></div>
<div class="email-original-content">
${originalContent}
</div>
`;
return { subject, content };
}
// Create formatted content for forwarded email
const content = `
<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>
`;
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;">On ${formattedDate}, ${fromText} wrote:</div>`;
// Get and sanitize original content (sanitization preserves content direction)
const quotedContent = sanitizeHtml(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 for reply
const content = `
<div style="min-height: 20px;">
<div class="reply-body">
<div class="quote-header" style="color: #555; font-size: 13px; margin: 20px 0 10px 0;">${quoteHeader}</div>
<blockquote style="margin: 0; padding: 10px 0 10px 15px; border-left: 3px solid #ddd; color: #555; background-color: #f8f8f8; border-radius: 4px;">
<div class="quoted-content" style="font-size: 13px;">
${quotedContent}
</div>
</blockquote>
</div>
</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
*/
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
};
}
}
/**
* Decode compose content from MIME format to HTML and text
*/
export async function decodeComposeContent(content: string): Promise<{
html: string | null;
text: string | null;
}> {
if (!content.trim()) {
return { html: null, text: null };
}
try {
const response = await fetch('/api/parse-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: content }),
});
if (!response.ok) {
throw new Error('Failed to parse email');
}
const parsed = await response.json();
// Apply LTR sanitization to the parsed content
return {
html: parsed.html ? sanitizeHtml(parsed.html) : null,
text: parsed.text || null
};
} catch (error) {
console.error('Error parsing email content:', error);
// Fallback to basic content handling with sanitization
return {
html: sanitizeHtml(content),
text: content
};
}
}
/**
* Encode compose content to MIME format for sending
*/
export function encodeComposeContent(content: string): string {
if (!content.trim()) {
throw new Error('Email content is empty');
}
// Create MIME headers
const mimeHeaders = {
'MIME-Version': '1.0',
'Content-Type': 'text/html; charset="utf-8"',
'Content-Transfer-Encoding': 'quoted-printable'
};
// Combine headers and content
return Object.entries(mimeHeaders)
.map(([key, value]) => `${key}: ${value}`)
.join('\n') + '\n\n' + content;
}
// Email formatter functions for various email actions
export function formatReplyEmail(email: any): string {
const originalSender = email.sender?.name || email.sender?.email || 'Unknown Sender';
const originalDate = formatDateRelative(new Date(email.date));
const sanitizedBody = sanitizeHtml(email.content || '', {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'width', 'height']
}
});
return `
<p></p>
<p>On ${originalDate}, ${originalSender} wrote:</p>
<blockquote class="quoted-content">
${sanitizedBody}
</blockquote>
`.trim();
}
export function formatForwardedEmail(email: any): string {
const originalSender = email.sender?.name || email.sender?.email || 'Unknown Sender';
const originalRecipients = email.to?.map((recipient: any) =>
recipient.name || recipient.email
).join(', ') || 'Unknown Recipients';
const originalDate = formatDateRelative(new Date(email.date));
const originalSubject = email.subject || 'No Subject';
const sanitizedBody = sanitizeHtml(email.content || '', {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'width', 'height']
}
});
return `
<p></p>
<p>---------- Forwarded message ---------</p>
<p><strong>From:</strong> ${originalSender}</p>
<p><strong>Date:</strong> ${originalDate}</p>
<p><strong>Subject:</strong> ${originalSubject}</p>
<p><strong>To:</strong> ${originalRecipients}</p>
<br>
<div class="email-original-content">
${sanitizedBody}
</div>
`.trim();
}
export function formatReplyToAllEmail(email: any): string {
// For reply all, we use the same format as regular reply
return formatReplyEmail(email);
}
// Utility function to get the reply subject line
export function getReplySubject(subject: string): string {
return subject.startsWith('Re:') ? subject : `Re: ${subject}`;
}
// Utility function to get the forward subject line
export function getForwardSubject(subject: string): string {
return subject.startsWith('Fwd:') ? subject : `Fwd: ${subject}`;
}