From d12a6c46709e5d27d5940906c29b1d256a4a17d2 Mon Sep 17 00:00:00 2001 From: alma Date: Wed, 30 Apr 2025 20:55:17 +0200 Subject: [PATCH] courrier preview --- components/email/EmailContentDisplay.tsx | 109 ++++++ components/email/EmailPreview.tsx | 167 ++------- components/email/QuotedEmailContent.tsx | 36 +- lib/utils/email-adapter.ts | 147 ++++++++ lib/utils/email-utils.ts | 448 +++++++++++++++++++++++ types/email.ts | 53 +++ 6 files changed, 799 insertions(+), 161 deletions(-) create mode 100644 components/email/EmailContentDisplay.tsx create mode 100644 lib/utils/email-adapter.ts create mode 100644 lib/utils/email-utils.ts create mode 100644 types/email.ts diff --git a/components/email/EmailContentDisplay.tsx b/components/email/EmailContentDisplay.tsx new file mode 100644 index 00000000..67c0ddde --- /dev/null +++ b/components/email/EmailContentDisplay.tsx @@ -0,0 +1,109 @@ +'use client'; + +import React, { useMemo, CSSProperties } from 'react'; +import { renderEmailContent, normalizeEmailContent } from '@/lib/utils/email-utils'; +import { EmailContent } from '@/types/email'; + +interface EmailContentDisplayProps { + content: EmailContent | any; + className?: string; + showQuotedText?: boolean; + type?: 'html' | 'text' | 'auto'; +} + +/** + * Unified component for displaying email content in a consistent way + * This handles both HTML and plain text content with proper styling + */ +const EmailContentDisplay: React.FC = ({ + content, + className = '', + showQuotedText = true, + type = 'auto' +}) => { + // Normalize the content to our standard format if needed + const normalizedContent = useMemo(() => { + // If content is already in our EmailContent format + if (content && + typeof content === 'object' && + 'text' in content && + 'isHtml' in content) { + return content as EmailContent; + } + + // Otherwise normalize it + return normalizeEmailContent(content); + }, [content]); + + // Render the normalized content + const htmlContent = useMemo(() => { + if (!normalizedContent) return ''; + + // Override content type if specified + let contentToRender: EmailContent = { ...normalizedContent }; + + if (type === 'html' && !contentToRender.isHtml) { + // Force HTML rendering for text content + contentToRender = { + ...contentToRender, + isHtml: true, + html: `

${contentToRender.text.replace(/\n/g, '
')}

` + }; + } else if (type === 'text' && contentToRender.isHtml) { + // Force text rendering + contentToRender = { + ...contentToRender, + isHtml: false + }; + } + + return renderEmailContent(contentToRender); + }, [normalizedContent, type]); + + // Apply quoted text styling if needed + const containerStyle: CSSProperties = showQuotedText + ? {} + : { maxHeight: '400px', overflowY: 'auto' }; + + return ( +
+
+ + +
+ ); +}; + +export default EmailContentDisplay; \ No newline at end of file diff --git a/components/email/EmailPreview.tsx b/components/email/EmailPreview.tsx index 2395e3ae..17d2e1f8 100644 --- a/components/email/EmailPreview.tsx +++ b/components/email/EmailPreview.tsx @@ -1,58 +1,14 @@ 'use client'; -import { useState, useRef, useEffect, useMemo } from 'react'; -import { Loader2, Paperclip, User } from 'lucide-react'; +import { useRef } from 'react'; +import { Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { - formatReplyEmail, - formatForwardedEmail, - formatEmailForReplyOrForward, - 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'; -import { cn } from '@/lib/utils'; -import { CalendarIcon, PaperclipIcon } from 'lucide-react'; -import Link from 'next/link'; -import DOMPurify from 'dompurify'; - -interface EmailAddress { - name: string; - address: string; -} - -interface EmailAttachment { - filename: string; - contentType: string; - size: number; - path?: string; - content?: string; -} - -interface EmailMessage { - id: string; - uid: number; - from: EmailAddress[]; - to: EmailAddress[]; - cc?: EmailAddress[]; - bcc?: EmailAddress[]; - subject: string; - date: string; - flags: string[]; - attachments: EmailAttachment[]; - content?: string | { - text?: string; - html?: string; - }; - html?: string; - text?: string; - formattedContent?: string; -} +import { EmailMessage, EmailAddress } from '@/types/email'; +import { formatEmailAddresses, formatEmailDate } from '@/lib/utils/email-utils'; +import EmailContentDisplay from './EmailContentDisplay'; interface EmailPreviewProps { email: EmailMessage | null; @@ -64,32 +20,6 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP // Add editorRef to match ComposeEmail exactly const editorRef = useRef(null); - // Format the date - const formatDate = (date: Date | string) => { - if (!date) return ''; - - const dateObj = date instanceof Date ? date : new Date(date); - return dateObj.toLocaleString('en-US', { - weekday: 'short', - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - }; - - // Format email addresses - const formatEmailAddresses = (addresses: Array<{name: string, address: string}> | undefined) => { - if (!addresses || addresses.length === 0) return ''; - - return addresses.map(addr => - addr.name && addr.name !== addr.address - ? `${addr.name} <${addr.address}>` - : addr.address - ).join(', '); - }; - // Get sender initials for avatar const getSenderInitials = (name: string) => { if (!name) return ''; @@ -101,33 +31,6 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP .slice(0, 2); }; - // Format the email content - const formattedContent = useMemo(() => { - if (!email) { - return ''; - } - - // CRITICAL FIX: Send consistent input format to formatEmailContent - try { - // Log what we're sending to formatEmailContent for debugging - console.log('EmailPreview: Calling formatEmailContent with email:', - JSON.stringify({ - id: email.id, - contentType: typeof email.content, - hasHtml: typeof email.content === 'object' ? !!email.content.html : false, - hasText: typeof email.content === 'object' ? !!email.content.text : false, - hasHtmlProp: !!email.html, - hasTextProp: !!email.text - }) - ); - - return formatEmailContent(email); - } catch (error) { - console.error('Error formatting email content:', error); - return `
Error rendering email content: ${error instanceof Error ? error.message : 'Unknown error'}
`; - } - }, [email]); - // Display loading state if (loading) { return ( @@ -153,7 +56,7 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP const sender = email.from && email.from.length > 0 ? email.from[0] : undefined; - // Update the array access to use proper type checking + // Check for attachments const hasAttachments = email.attachments && email.attachments.length > 0; return ( @@ -171,7 +74,7 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
{sender?.name || sender?.address}
- {formatDate(email.date)} + {formatEmailDate(email.date)}
@@ -214,29 +117,31 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP )}
- {/* Attachments */} + {/* Attachments list */} {hasAttachments && ( -
-
Attachments
+
+

Attachments ({email.attachments.length})

{email.attachments.map((attachment, index) => ( - - +
+
+ +
{attachment.filename} - - ({Math.round(attachment.size / 1024)}KB) - - +
))}
)}
- {/* Email content */} + {/* Email body */}
- {/* IMPROVED: Simplified email content container with better styling */} + {/* Render the email content using the new standardized component */}
- {/* Render the formatted content directly */} - {formattedContent ? ( -
- ) : ( -
-

This email does not contain any content.

-
- )} +
{/* Only in development mode: Show debugging info */} @@ -264,16 +163,12 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP Email Debug Info

Email ID: {email.id}

-

Content Type: { - typeof email.content === 'object' && email.content?.html - ? 'HTML' - : 'Plain Text' - }

-

Content Size: { - typeof email.content === 'object' - ? `HTML: ${email.content?.html?.length || 0} chars, Text: ${email.content?.text?.length || 0} chars` - : `${typeof email.content === 'string' ? email.content.length : 0} chars` - }

+

Content Type: {email.content.isHtml ? 'HTML' : 'Plain Text'}

+

Text Direction: {email.content.direction || 'ltr'}

+

Content Size: + HTML: {email.content.html?.length || 0} chars, + Text: {email.content.text?.length || 0} chars +

)} diff --git a/components/email/QuotedEmailContent.tsx b/components/email/QuotedEmailContent.tsx index 87f49820..a0311601 100644 --- a/components/email/QuotedEmailContent.tsx +++ b/components/email/QuotedEmailContent.tsx @@ -1,15 +1,19 @@ 'use client'; import React from 'react'; -import EmailContentDisplay from './EmailContentDisplay'; +import EmailContentDisplay from '@/components/email/EmailContentDisplay'; +import { formatEmailDate } from '@/lib/utils/email-utils'; +import { EmailContent } from '@/types/email'; interface QuotedEmailContentProps { - content: string; + content: EmailContent | string; sender: { name?: string; email: string; }; date: Date | string; + subject?: string; + recipients?: string; type: 'reply' | 'forward'; className?: string; } @@ -21,32 +25,14 @@ const QuotedEmailContent: React.FC = ({ content, sender, date, + subject, + recipients, type, className = '' }) => { - // Format the date - const formatDate = (date: Date | string) => { - if (!date) return ''; - - const dateObj = typeof date === 'string' ? new Date(date) : date; - - try { - 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(); - } - }; - // Format sender info const senderName = sender.name || sender.email; - const formattedDate = formatDate(date); + const formattedDate = formatEmailDate(date); // Create header based on type const renderQuoteHeader = () => { @@ -62,8 +48,8 @@ const QuotedEmailContent: React.FC = ({
---------- Forwarded message ---------
From: {senderName} <{sender.email}>
Date: {formattedDate}
-
Subject: {/* Subject would be passed as a prop if needed */}
-
To: {/* Recipients would be passed as a prop if needed */}
+
Subject: {subject || '(No subject)'}
+
To: {recipients || 'Undisclosed recipients'}
); } diff --git a/lib/utils/email-adapter.ts b/lib/utils/email-adapter.ts new file mode 100644 index 00000000..298f2b19 --- /dev/null +++ b/lib/utils/email-adapter.ts @@ -0,0 +1,147 @@ +/** + * Email Adapter Utility + * + * This utility provides adapter functions to convert legacy email + * formats to the standardized EmailMessage format. + * + * Use these functions to migrate code gradually without breaking changes. + */ + +import { + EmailMessage, + EmailContent, + EmailAddress, + EmailAttachment, + EmailFlags +} from '@/types/email'; +import { normalizeEmailContent } from './email-utils'; + +/** + * Convert a legacy email format to our standardized EmailMessage format + * + * This adapter function handles all the various ways email data might be structured + * in the legacy codebase and converts it to our new standardized format. + */ +export function adaptLegacyEmail(legacyEmail: any): EmailMessage { + if (!legacyEmail) { + throw new Error('Cannot adapt a null or undefined email'); + } + + // Handle case where it's already in the right format + if ( + legacyEmail.content && + typeof legacyEmail.content === 'object' && + 'isHtml' in legacyEmail.content && + 'text' in legacyEmail.content + ) { + return legacyEmail as EmailMessage; + } + + // Create normalized content + const normalizedContent = normalizeEmailContent(legacyEmail); + + // Normalize flags to standard format + let normalizedFlags: EmailFlags = { + seen: false, + flagged: false, + answered: false, + deleted: false, + draft: false + }; + + // Handle different possible formats for flags + if (legacyEmail.flags) { + if (typeof legacyEmail.flags === 'object' && !Array.isArray(legacyEmail.flags)) { + // Object format: { seen: true, flagged: false, ... } + normalizedFlags = { + seen: !!legacyEmail.flags.seen, + flagged: !!legacyEmail.flags.flagged, + answered: !!legacyEmail.flags.answered, + deleted: !!legacyEmail.flags.deleted, + draft: !!legacyEmail.flags.draft + }; + } else if (Array.isArray(legacyEmail.flags)) { + // Array format: ['\\Seen', '\\Flagged', ...] + normalizedFlags.seen = legacyEmail.flags.includes('\\Seen'); + normalizedFlags.flagged = legacyEmail.flags.includes('\\Flagged'); + normalizedFlags.answered = legacyEmail.flags.includes('\\Answered'); + normalizedFlags.deleted = legacyEmail.flags.includes('\\Deleted'); + normalizedFlags.draft = legacyEmail.flags.includes('\\Draft'); + } + } + + // Normalize attachments to standard format + const normalizedAttachments: EmailAttachment[] = Array.isArray(legacyEmail.attachments) + ? legacyEmail.attachments.map((att: any) => ({ + filename: att.filename || att.name || 'attachment', + contentType: att.contentType || att.type || 'application/octet-stream', + content: att.content || att.data || undefined, + size: att.size || 0, + contentId: att.contentId || att.cid || undefined + })) + : []; + + // Return a normalized EmailMessage + return { + id: legacyEmail.id || legacyEmail.uid?.toString() || `email-${Date.now()}`, + messageId: legacyEmail.messageId, + uid: typeof legacyEmail.uid === 'number' ? legacyEmail.uid : undefined, + subject: legacyEmail.subject || '(No subject)', + from: Array.isArray(legacyEmail.from) ? legacyEmail.from : [], + to: Array.isArray(legacyEmail.to) ? legacyEmail.to : [], + cc: Array.isArray(legacyEmail.cc) ? legacyEmail.cc : undefined, + bcc: Array.isArray(legacyEmail.bcc) ? legacyEmail.bcc : undefined, + date: legacyEmail.date || new Date(), + flags: normalizedFlags, + preview: legacyEmail.preview || '', + content: normalizedContent, + attachments: normalizedAttachments, + folder: legacyEmail.folder || undefined, + size: typeof legacyEmail.size === 'number' ? legacyEmail.size : undefined + }; +} + +/** + * Helper function to detect if an object is an EmailAddress + */ +export function isEmailAddress(obj: any): obj is EmailAddress { + return obj && + typeof obj === 'object' && + 'address' in obj && + typeof obj.address === 'string'; +} + +/** + * Convert legacy email address format to standardized EmailAddress format + */ +export function adaptEmailAddress(address: any): EmailAddress { + if (isEmailAddress(address)) { + return { + name: address.name || '', + address: address.address + }; + } + + if (typeof address === 'string') { + // Try to extract name and address from string like "Name " + const match = address.match(/^(?:"?([^"]*)"?\s)?]+@[^\s>]+)>?$/); + if (match) { + return { + name: match[1] || '', + address: match[2] + }; + } + + // If no match, assume it's just an email address + return { + name: '', + address + }; + } + + // Return a placeholder if we can't parse the address + return { + name: '', + address: 'unknown@example.com' + }; +} \ No newline at end of file diff --git a/lib/utils/email-utils.ts b/lib/utils/email-utils.ts new file mode 100644 index 00000000..2711a845 --- /dev/null +++ b/lib/utils/email-utils.ts @@ -0,0 +1,448 @@ +/** + * Unified Email Utilities + * + * This file contains all email-related utility functions: + * - Content normalization + * - Content sanitization + * - Email formatting (replies, forwards) + * - Text direction detection + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { + EmailMessage, + EmailContent, + EmailAddress +} from '@/types/email'; + +// Reset any existing hooks to start clean +DOMPurify.removeAllHooks(); + +// Configure DOMPurify for auto text direction +DOMPurify.addHook('afterSanitizeAttributes', function(node) { + 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'); + } + } +}); + +// Configure DOMPurify to preserve direction attributes +DOMPurify.setConfig({ + ADD_ATTR: ['dir'], + ALLOWED_ATTR: ['style', 'class', 'id', 'dir'] +}); + +/** + * Detect if text contains RTL characters + */ +export function detectTextDirection(text: string): 'ltr' | 'rtl' { + // Pattern for RTL characters (Arabic, Hebrew, etc.) + const rtlLangPattern = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/; + return rtlLangPattern.test(text) ? 'rtl' : 'ltr'; +} + +/** + * 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 + * Uses email industry standards for proper, consistent, and secure rendering + */ +export function sanitizeHtml(html: string): string { + if (!html) return ''; + + try { + // Use DOMPurify with comprehensive email HTML standards + const clean = DOMPurify.sanitize(html, { + 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, // 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 + }); + + // Fix common email rendering issues + return clean + // Fix for Outlook WebVML content + .replace(/