436 lines
13 KiB
TypeScript
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}`;
|
|
}
|