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';
import React, { useEffect, useState, useRef } from 'react';
import DOMPurify from 'isomorphic-dompurify';
import { parseRawEmail } from '@/lib/utils/email-mime-decoder';
import React, { useEffect, useState } from 'react';
import DOMPurify from 'dompurify';
import { formatEmailContent } from '@/lib/utils/email-content';
import { sanitizeHtml } from '@/lib/utils/email-formatter';
interface EmailContentDisplayProps {
content: string;
type?: 'html' | 'text' | 'auto';
className?: string;
showQuotedText?: boolean;
/**
* Interface for email content types
*/
interface ProcessedContent {
html: string;
text: string;
isHtml: boolean;
}
/**
* Component for displaying properly formatted email content
* Handles MIME decoding, sanitization, and proper rendering
* Interface for component props
*/
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> = ({
content,
type = 'auto',
className = '',
showQuotedText = true
isRawEmail = false,
className = ''
}) => {
const [processedContent, setProcessedContent] = useState<{
html: string;
text: string;
isHtml: boolean;
}>({
const [processedContent, setProcessedContent] = useState<ProcessedContent>({
html: '',
text: '',
isHtml: false
});
const containerRef = useRef<HTMLDivElement>(null);
// Process and sanitize email content
// Process the email content when it changes
useEffect(() => {
if (!content) {
setProcessedContent({ html: '', text: '', isHtml: false });
setProcessedContent({
html: '',
text: '',
isHtml: false
});
return;
}
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) {
// Parse raw email content
const parsed = parseRawEmail(content);
@ -54,12 +100,8 @@ const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
const useHtml = (type === 'html' || (type === 'auto' && parsed.html)) && !!parsed.html;
if (useHtml) {
// Sanitize HTML content
const sanitizedHtml = DOMPurify.sanitize(parsed.html, {
ADD_TAGS: ['table', 'thead', 'tbody', 'tr', 'td', 'th'],
ADD_ATTR: ['target', 'rel', 'colspan', 'rowspan'],
ALLOW_DATA_ATTR: false
});
// Use the enhanced sanitizeHtml function from email-formatter
const sanitizedHtml = sanitizeHtml(parsed.html);
setProcessedContent({
html: sanitizedHtml,
@ -67,8 +109,9 @@ const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
isHtml: true
});
} else {
// Format plain text with line breaks
const formattedText = parsed.text.replace(/\n/g, '<br />');
// Format plain text properly
const formattedText = formatEmailContent({ text: parsed.text });
setProcessedContent({
html: formattedText,
text: parsed.text,
@ -77,28 +120,29 @@ const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
}
} else {
// Treat as direct content (not raw email)
const isHtmlContent = content.includes('<html') ||
content.includes('<body') ||
content.includes('<div') ||
content.includes('<p>') ||
content.includes('<br');
const isHtmlContent = type === 'html' || (
type === 'auto' && (
content.includes('<html') ||
content.includes('<body') ||
content.includes('<div') ||
content.includes('<p>') ||
content.includes('<br')
)
);
if (isHtmlContent || type === 'html') {
// Sanitize HTML content
const sanitizedHtml = DOMPurify.sanitize(content, {
ADD_TAGS: ['table', 'thead', 'tbody', 'tr', 'td', 'th'],
ADD_ATTR: ['target', 'rel', 'colspan', 'rowspan', 'style', 'class', 'id', 'border'],
ALLOW_DATA_ATTR: false
});
if (isHtmlContent) {
// Use the enhanced sanitizeHtml function
const sanitizedHtml = sanitizeHtml(content);
setProcessedContent({
html: sanitizedHtml,
text: content,
text: content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(),
isHtml: true
});
} else {
// Format plain text with line breaks
const formattedText = content.replace(/\n/g, '<br />');
// Format plain text properly using formatEmailContent
const formattedText = formatEmailContent({ text: content });
setProcessedContent({
html: formattedText,
text: content,
@ -108,89 +152,29 @@ const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
}
} catch (err) {
console.error('Error processing email content:', err);
// Fallback to plain text
// Fallback to plain text with basic formatting
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,
isHtml: false
});
}
}, [content, type]);
// 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]);
}, [content, type, isRawEmail]);
return (
<div
ref={containerRef}
className={`email-content-display ${className}`}
dangerouslySetInnerHTML={{ __html: processedContent.html }}
/>
<div className={`email-content-container ${className}`}>
<div
className="email-content-viewer"
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 }}
/>
</div>
);
};

View File

@ -12,6 +12,7 @@ import {
EmailMessage as FormatterEmailMessage,
sanitizeHtml
} from '@/lib/utils/email-formatter';
import { formatEmailContent } from '@/lib/utils/email-content';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { AvatarImage } from '@/components/ui/avatar';
import { Card } from '@/components/ui/card';
@ -103,43 +104,11 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
// Format the email content
const formattedContent = useMemo(() => {
if (!email) {
console.log('EmailPreview: No email provided');
return '';
}
try {
console.log('EmailPreview: Full email object:', JSON.stringify(email, null, 2));
// 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 '';
}
// Use the improved, standardized email content formatter
return formatEmailContent(email);
}, [email]);
// Display loading state
@ -249,12 +218,28 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
{/* Email content */}
<ScrollArea className="flex-1">
<div className="space-y-2 p-6">
<div className="border rounded-md overflow-hidden">
<div
ref={editorRef}
contentEditable={false}
className="w-full p-4 min-h-[300px] focus:outline-none email-content-display"
<div className="p-6">
<div
ref={editorRef}
contentEditable={false}
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 }}
/>
</div>

View File

@ -1,5 +1,10 @@
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 {
if (!email) {
console.log('formatEmailContent: No email provided');
@ -7,47 +12,91 @@ export function formatEmailContent(email: any): string {
}
try {
console.log('formatEmailContent: Raw email content:', {
content: email.content,
html: email.html,
text: email.text,
formattedContent: email.formattedContent
});
// Get the content in order of preference
// Get the content in order of preference with proper fallbacks
let content = '';
let isHtml = false;
let textContent = '';
// Extract content based on standardized property hierarchy
if (email.content && typeof email.content === 'object') {
console.log('formatEmailContent: Using object content:', email.content);
content = email.content.html || email.content.text || '';
isHtml = !!email.content.html;
content = email.content.html || '';
textContent = email.content.text || '';
} 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;
textContent = email.content;
} else if (email.html) {
console.log('formatEmailContent: Using html content');
isHtml = true;
content = email.html;
textContent = email.text || '';
} else if (email.text) {
console.log('formatEmailContent: Using text content');
content = email.text;
isHtml = false;
content = '';
textContent = email.text;
} else if (email.formattedContent) {
console.log('formatEmailContent: Using formattedContent');
// Assume formattedContent is already HTML
isHtml = true;
content = email.formattedContent;
textContent = '';
}
console.log('formatEmailContent: Content before sanitization:', content);
// If we have HTML content, sanitize and standardize it
if (isHtml && content) {
// Sanitize with industry-standard email tags and attributes
const sanitizedContent = DOMPurify.sanitize(content, {
ADD_TAGS: [
'style', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
'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']
});
// 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 `
<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>
`;
}
// Sanitize the content for display while preserving formatting
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('formatEmailContent: Final sanitized content:', sanitizedContent);
return sanitizedContent;
// Default case: empty or unrecognized content
return '<div class="email-content-empty">No content available</div>';
} catch (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
* 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
* @returns Sanitized HTML with preserved text direction
* @returns Sanitized HTML with preserved styling and structure
*/
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
// Use DOMPurify with comprehensive email HTML standards
const clean = DOMPurify.sanitize(html, {
ADD_TAGS: ['button', 'style', 'img', 'iframe', 'meta', 'table', 'thead', 'tbody', 'tr', 'td', 'th'],
ADD_ATTR: ['target', 'rel', 'style', 'class', 'id', 'href', 'src', 'alt', 'title', 'width', 'height', 'onclick', 'colspan', 'rowspan'],
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,
FORCE_BODY: false,
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
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
});
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) {
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(/on\w+="[^"]*"/g, '')
.replace(/(javascript|jscript|vbscript|mocha):/gi, 'removed:');
}
}