courrier formatting

This commit is contained in:
alma 2025-04-30 15:38:01 +02:00
parent a7b023e359
commit 53e6c3f7c8
4 changed files with 241 additions and 203 deletions

View File

@ -1,51 +1,97 @@
'use client'; 'use client';
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState } from 'react';
import DOMPurify from 'isomorphic-dompurify'; import DOMPurify from 'dompurify';
import { parseRawEmail } from '@/lib/utils/email-mime-decoder'; import { formatEmailContent } from '@/lib/utils/email-content';
import { sanitizeHtml } from '@/lib/utils/email-formatter';
interface EmailContentDisplayProps { /**
content: string; * Interface for email content types
type?: 'html' | 'text' | 'auto'; */
className?: string; interface ProcessedContent {
showQuotedText?: boolean; html: string;
text: string;
isHtml: boolean;
} }
/** /**
* Component for displaying properly formatted email content * Interface for component props
* Handles MIME decoding, sanitization, and proper rendering */
interface EmailContentDisplayProps {
content: string;
type?: 'html' | 'text' | 'auto';
isRawEmail?: boolean;
className?: string;
}
/**
* Parse raw email content into HTML and text parts
* This is a helper function used when processing raw email formats
*/
function parseRawEmail(rawContent: string): { html: string; text: string } {
// Simple parser for demonstration - in production, use a proper MIME parser
const hasHtmlPart = rawContent.includes('<html') ||
rawContent.includes('<body') ||
rawContent.includes('Content-Type: text/html');
if (hasHtmlPart) {
// Extract HTML part
let htmlPart = '';
const htmlMatch = rawContent.match(/<html[\s\S]*<\/html>/i) ||
rawContent.match(/<body[\s\S]*<\/body>/i);
if (htmlMatch) {
htmlPart = htmlMatch[0];
} else {
// Fallback extraction
const parts = rawContent.split(/(?:--boundary|\r\n\r\n)/);
htmlPart = parts.find(part =>
part.includes('Content-Type: text/html') ||
part.includes('<html') ||
part.includes('<body')
) || '';
}
return {
html: htmlPart,
text: rawContent.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
};
}
return {
html: '',
text: rawContent
};
}
/**
* EmailContentDisplay component - displays formatted email content
* with proper security, styling and support for different email formats
*/ */
const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({ const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
content, content,
type = 'auto', type = 'auto',
className = '', isRawEmail = false,
showQuotedText = true className = ''
}) => { }) => {
const [processedContent, setProcessedContent] = useState<{ const [processedContent, setProcessedContent] = useState<ProcessedContent>({
html: string;
text: string;
isHtml: boolean;
}>({
html: '', html: '',
text: '', text: '',
isHtml: false isHtml: false
}); });
const containerRef = useRef<HTMLDivElement>(null); // Process the email content when it changes
// Process and sanitize email content
useEffect(() => { useEffect(() => {
if (!content) { if (!content) {
setProcessedContent({ html: '', text: '', isHtml: false }); setProcessedContent({
html: '',
text: '',
isHtml: false
});
return; return;
} }
try { try {
// Check if this is raw email content
const isRawEmail = content.includes('Content-Type:') ||
content.includes('MIME-Version:') ||
content.includes('From:') && content.includes('To:');
if (isRawEmail) { if (isRawEmail) {
// Parse raw email content // Parse raw email content
const parsed = parseRawEmail(content); const parsed = parseRawEmail(content);
@ -54,12 +100,8 @@ const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
const useHtml = (type === 'html' || (type === 'auto' && parsed.html)) && !!parsed.html; const useHtml = (type === 'html' || (type === 'auto' && parsed.html)) && !!parsed.html;
if (useHtml) { if (useHtml) {
// Sanitize HTML content // Use the enhanced sanitizeHtml function from email-formatter
const sanitizedHtml = DOMPurify.sanitize(parsed.html, { const sanitizedHtml = sanitizeHtml(parsed.html);
ADD_TAGS: ['table', 'thead', 'tbody', 'tr', 'td', 'th'],
ADD_ATTR: ['target', 'rel', 'colspan', 'rowspan'],
ALLOW_DATA_ATTR: false
});
setProcessedContent({ setProcessedContent({
html: sanitizedHtml, html: sanitizedHtml,
@ -67,8 +109,9 @@ const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
isHtml: true isHtml: true
}); });
} else { } else {
// Format plain text with line breaks // Format plain text properly
const formattedText = parsed.text.replace(/\n/g, '<br />'); const formattedText = formatEmailContent({ text: parsed.text });
setProcessedContent({ setProcessedContent({
html: formattedText, html: formattedText,
text: parsed.text, text: parsed.text,
@ -77,28 +120,29 @@ const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
} }
} else { } else {
// Treat as direct content (not raw email) // Treat as direct content (not raw email)
const isHtmlContent = content.includes('<html') || const isHtmlContent = type === 'html' || (
type === 'auto' && (
content.includes('<html') ||
content.includes('<body') || content.includes('<body') ||
content.includes('<div') || content.includes('<div') ||
content.includes('<p>') || content.includes('<p>') ||
content.includes('<br'); content.includes('<br')
)
);
if (isHtmlContent || type === 'html') { if (isHtmlContent) {
// Sanitize HTML content // Use the enhanced sanitizeHtml function
const sanitizedHtml = DOMPurify.sanitize(content, { const sanitizedHtml = sanitizeHtml(content);
ADD_TAGS: ['table', 'thead', 'tbody', 'tr', 'td', 'th'],
ADD_ATTR: ['target', 'rel', 'colspan', 'rowspan', 'style', 'class', 'id', 'border'],
ALLOW_DATA_ATTR: false
});
setProcessedContent({ setProcessedContent({
html: sanitizedHtml, html: sanitizedHtml,
text: content, text: content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(),
isHtml: true isHtml: true
}); });
} else { } else {
// Format plain text with line breaks // Format plain text properly using formatEmailContent
const formattedText = content.replace(/\n/g, '<br />'); const formattedText = formatEmailContent({ text: content });
setProcessedContent({ setProcessedContent({
html: formattedText, html: formattedText,
text: content, text: content,
@ -108,89 +152,29 @@ const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
} }
} catch (err) { } catch (err) {
console.error('Error processing email content:', err); console.error('Error processing email content:', err);
// Fallback to plain text // Fallback to plain text with basic formatting
setProcessedContent({ setProcessedContent({
html: content.replace(/\n/g, '<br />'), html: `<div style="white-space: pre-wrap; font-family: monospace;">${content.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br />')}</div>`,
text: content, text: content,
isHtml: false isHtml: false
}); });
} }
}, [content, type]); }, [content, type, isRawEmail]);
// Process quoted content visibility and fix table styling
useEffect(() => {
if (!containerRef.current || !processedContent.html) return;
const container = containerRef.current;
// Handle quoted text visibility
if (!showQuotedText) {
// Add toggle buttons for quoted text sections
const quotedSections = container.querySelectorAll('blockquote');
quotedSections.forEach((quote, index) => {
// Check if this quoted section already has a toggle
if (quote.previousElementSibling?.classList.contains('quoted-toggle-btn')) {
return;
}
// Create toggle button
const toggleBtn = document.createElement('button');
toggleBtn.innerText = '▼ Show quoted text';
toggleBtn.className = 'quoted-toggle-btn';
toggleBtn.style.cssText = 'background: none; border: none; color: #666; font-size: 12px; cursor: pointer; padding: 4px 0; display: block;';
// Hide quoted section initially
quote.style.display = 'none';
// Add click handler
toggleBtn.addEventListener('click', () => {
const isHidden = quote.style.display === 'none';
quote.style.display = isHidden ? 'block' : 'none';
toggleBtn.innerText = isHidden ? '▲ Hide quoted text' : '▼ Show quoted text';
});
// Insert before the blockquote
quote.parentNode?.insertBefore(toggleBtn, quote);
});
}
// Process tables and ensure they're properly formatted
const tables = container.querySelectorAll('table');
tables.forEach(table => {
// Cast to HTMLTableElement to access style property
const tableElement = table as HTMLTableElement;
// Only apply styling if the table doesn't already have border styles
if (!tableElement.hasAttribute('border') &&
(!tableElement.style.border || tableElement.style.border === '')) {
// Apply proper table styling
tableElement.style.width = '100%';
tableElement.style.borderCollapse = 'collapse';
tableElement.style.margin = '10px 0';
tableElement.style.border = '1px solid #ddd';
}
const cells = table.querySelectorAll('td, th');
cells.forEach(cell => {
// Cast to HTMLTableCellElement to access style property
const cellElement = cell as HTMLTableCellElement;
// Only apply styling if the cell doesn't already have border styles
if (!cellElement.style.border || cellElement.style.border === '') {
cellElement.style.border = '1px solid #ddd';
cellElement.style.padding = '6px';
}
});
});
}, [processedContent.html, showQuotedText]);
return ( return (
<div className={`email-content-container ${className}`}>
<div <div
ref={containerRef} className="email-content-viewer"
className={`email-content-display ${className}`} style={{
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
fontSize: '15px',
lineHeight: '1.6',
color: '#1A202C',
overflow: 'auto'
}}
dangerouslySetInnerHTML={{ __html: processedContent.html }} dangerouslySetInnerHTML={{ __html: processedContent.html }}
/> />
</div>
); );
}; };

View File

@ -12,6 +12,7 @@ import {
EmailMessage as FormatterEmailMessage, EmailMessage as FormatterEmailMessage,
sanitizeHtml sanitizeHtml
} from '@/lib/utils/email-formatter'; } from '@/lib/utils/email-formatter';
import { formatEmailContent } from '@/lib/utils/email-content';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { AvatarImage } from '@/components/ui/avatar'; import { AvatarImage } from '@/components/ui/avatar';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
@ -103,43 +104,11 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
// Format the email content // Format the email content
const formattedContent = useMemo(() => { const formattedContent = useMemo(() => {
if (!email) { if (!email) {
console.log('EmailPreview: No email provided');
return ''; return '';
} }
try { // Use the improved, standardized email content formatter
console.log('EmailPreview: Full email object:', JSON.stringify(email, null, 2)); return formatEmailContent(email);
// Get the content in order of preference
let content = '';
// If content is an object with html/text
if (email.content && typeof email.content === 'object') {
console.log('EmailPreview: Using object content:', JSON.stringify(email.content, null, 2));
content = email.content.html || email.content.text || '';
}
// If content is a string
else if (typeof email.content === 'string') {
console.log('EmailPreview: Using direct string content:', email.content);
content = email.content;
}
console.log('EmailPreview: Final content before sanitization:', content);
// Sanitize the content for display
const sanitizedContent = DOMPurify.sanitize(content, {
ADD_TAGS: ['style', 'table', 'thead', 'tbody', 'tr', 'td', 'th'],
ADD_ATTR: ['class', 'style', 'dir', 'colspan', 'rowspan'],
ALLOW_DATA_ATTR: false
});
console.log('EmailPreview: Final sanitized content:', sanitizedContent);
return sanitizedContent;
} catch (error) {
console.error('EmailPreview: Error formatting email content:', error);
return '';
}
}, [email]); }, [email]);
// Display loading state // Display loading state
@ -249,12 +218,28 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
{/* Email content */} {/* Email content */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-2 p-6"> <div className="p-6">
<div className="border rounded-md overflow-hidden">
<div <div
ref={editorRef} ref={editorRef}
contentEditable={false} contentEditable={false}
className="w-full p-4 min-h-[300px] focus:outline-none email-content-display" className="w-full email-content-container"
style={{
backgroundColor: '#ffffff',
borderRadius: '4px',
border: '1px solid #e2e8f0',
boxShadow: '0 1px 3px rgba(0,0,0,0.05)',
overflow: 'hidden',
minHeight: '300px'
}}
>
<div
className="email-content-body p-6"
style={{
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
fontSize: '15px',
lineHeight: '1.6',
color: '#1A202C'
}}
dangerouslySetInnerHTML={{ __html: formattedContent }} dangerouslySetInnerHTML={{ __html: formattedContent }}
/> />
</div> </div>

View File

@ -1,5 +1,10 @@
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
/**
* Format and standardize email content for display following email industry standards.
* This function handles various email content formats and ensures proper display
* including support for HTML emails, plain text emails, RTL languages, and email client quirks.
*/
export function formatEmailContent(email: any): string { export function formatEmailContent(email: any): string {
if (!email) { if (!email) {
console.log('formatEmailContent: No email provided'); console.log('formatEmailContent: No email provided');
@ -7,47 +12,91 @@ export function formatEmailContent(email: any): string {
} }
try { try {
console.log('formatEmailContent: Raw email content:', { // Get the content in order of preference with proper fallbacks
content: email.content,
html: email.html,
text: email.text,
formattedContent: email.formattedContent
});
// Get the content in order of preference
let content = ''; let content = '';
let isHtml = false;
let textContent = '';
// Extract content based on standardized property hierarchy
if (email.content && typeof email.content === 'object') { if (email.content && typeof email.content === 'object') {
console.log('formatEmailContent: Using object content:', email.content); isHtml = !!email.content.html;
content = email.content.html || email.content.text || ''; content = email.content.html || '';
textContent = email.content.text || '';
} else if (typeof email.content === 'string') { } else if (typeof email.content === 'string') {
console.log('formatEmailContent: Using direct string content'); // 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>'));
content = email.content; content = email.content;
textContent = email.content;
} else if (email.html) { } else if (email.html) {
console.log('formatEmailContent: Using html content'); isHtml = true;
content = email.html; content = email.html;
textContent = email.text || '';
} else if (email.text) { } else if (email.text) {
console.log('formatEmailContent: Using text content'); isHtml = false;
content = email.text; content = '';
textContent = email.text;
} else if (email.formattedContent) { } else if (email.formattedContent) {
console.log('formatEmailContent: Using formattedContent'); // Assume formattedContent is already HTML
isHtml = true;
content = email.formattedContent; content = email.formattedContent;
textContent = '';
} }
console.log('formatEmailContent: Content before sanitization:', content); // If we have HTML content, sanitize and standardize it
if (isHtml && content) {
// Sanitize the content for display while preserving formatting // Sanitize with industry-standard email tags and attributes
const sanitizedContent = DOMPurify.sanitize(content, { const sanitizedContent = DOMPurify.sanitize(content, {
ADD_TAGS: ['style', 'table', 'thead', 'tbody', 'tr', 'td', 'th'], ADD_TAGS: [
ADD_ATTR: ['class', 'style', 'dir', 'colspan', 'rowspan'], 'style', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
ALLOW_DATA_ATTR: false 'caption', 'col', 'colgroup', 'div', 'span', 'img', 'br', 'hr',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'blockquote', 'pre',
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a', 'b', 'i', 'u', 'em',
'strong', 'del', 'ins', 'sub', 'sup', 'small', 'mark', 'q'
],
ADD_ATTR: [
'class', 'style', 'id', 'href', 'src', 'alt', 'title', 'width', 'height',
'border', 'cellspacing', 'cellpadding', 'bgcolor', 'color', 'dir', 'lang',
'align', 'valign', 'span', 'colspan', 'rowspan', 'target', 'rel',
'background', 'data-*'
],
ALLOW_DATA_ATTR: true,
WHOLE_DOCUMENT: false,
RETURN_DOM: false,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout']
}); });
console.log('formatEmailContent: Final sanitized content:', sanitizedContent); // Wrap the content in standard email container with responsive styling
return `
<div class="email-content" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 100%; word-wrap: break-word;">
${sanitizedContent}
</div>
`;
}
// If we only have text content, format it properly
else if (textContent) {
// Format plain text with proper line breaks and paragraphs
const formattedText = textContent
.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
return sanitizedContent; return `
<div class="email-content plain-text" style="font-family: monospace; white-space: pre-wrap; line-height: 1.5; color: #333; max-width: 100%; word-wrap: break-word;">
<p>${formattedText}</p>
</div>
`;
}
// Default case: empty or unrecognized content
return '<div class="email-content-empty">No content available</div>';
} catch (error) { } catch (error) {
console.error('formatEmailContent: Error formatting email content:', error); console.error('formatEmailContent: Error formatting email content:', error);
return ''; return '<div class="email-content-error">Error displaying email content</div>';
} }
} }

View File

@ -135,34 +135,54 @@ export function formatEmailDate(date: Date | string | undefined): string {
/** /**
* Sanitize HTML content before processing or displaying * Sanitize HTML content before processing or displaying
* This ensures the content is properly sanitized while preserving text direction * Implements email industry standards for proper, consistent, and secure rendering
*
* @param html HTML content to sanitize * @param html HTML content to sanitize
* @returns Sanitized HTML with preserved text direction * @returns Sanitized HTML with preserved styling and structure
*/ */
export function sanitizeHtml(html: string): string { export function sanitizeHtml(html: string): string {
if (!html) return ''; if (!html) return '';
try { try {
// Use DOMPurify but ensure we keep all elements and attributes that might be in emails // Use DOMPurify with comprehensive email HTML standards
const clean = DOMPurify.sanitize(html, { const clean = DOMPurify.sanitize(html, {
ADD_TAGS: ['button', 'style', 'img', 'iframe', 'meta', 'table', 'thead', 'tbody', 'tr', 'td', 'th'], ADD_TAGS: [
ADD_ATTR: ['target', 'rel', 'style', 'class', 'id', 'href', 'src', 'alt', 'title', 'width', 'height', 'onclick', 'colspan', 'rowspan'], '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, KEEP_CONTENT: true,
WHOLE_DOCUMENT: false, WHOLE_DOCUMENT: false,
ALLOW_DATA_ATTR: true, ALLOW_DATA_ATTR: true,
ALLOW_UNKNOWN_PROTOCOLS: true, ALLOW_UNKNOWN_PROTOCOLS: true, // Needed for some email clients
FORCE_BODY: false, FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'select', 'textarea'],
RETURN_DOM: false, FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout'],
RETURN_DOM_FRAGMENT: false, FORCE_BODY: false
}); });
return clean; // 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) { } catch (e) {
console.error('Error sanitizing HTML:', e); console.error('Error sanitizing HTML:', e);
// Fall back to a basic sanitization approach // Fall back to a basic sanitization approach
return html return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/on\w+="[^"]*"/g, ''); .replace(/on\w+="[^"]*"/g, '')
.replace(/(javascript|jscript|vbscript|mocha):/gi, 'removed:');
} }
} }