courrier preview

This commit is contained in:
alma 2025-04-30 20:55:17 +02:00
parent 3f017f82f8
commit d12a6c4670
6 changed files with 799 additions and 161 deletions

View File

@ -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<EmailContentDisplayProps> = ({
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: `<p>${contentToRender.text.replace(/\n/g, '<br>')}</p>`
};
} 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 (
<div
className={`email-content-display ${className} ${showQuotedText ? 'quoted-text' : ''}`}
style={containerStyle}
>
<div
className="email-content-inner"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
<style jsx>{`
.email-content-display {
width: 100%;
}
.email-content-display.quoted-text {
opacity: 0.85;
font-size: 0.95em;
}
.email-content-inner :global(img) {
max-width: 100%;
height: auto;
}
.email-content-inner :global(table) {
max-width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.email-content-inner :global(td),
.email-content-inner :global(th) {
padding: 0.5rem;
border: 1px solid #ddd;
}
`}</style>
</div>
);
};
export default EmailContentDisplay;

View File

@ -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<HTMLDivElement>(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 `<div class="error-message p-4 text-red-500">Error rendering email content: ${error instanceof Error ? error.message : 'Unknown error'}</div>`;
}
}, [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
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="font-medium">{sender?.name || sender?.address}</div>
<span className="text-sm text-muted-foreground">{formatDate(email.date)}</span>
<span className="text-sm text-muted-foreground">{formatEmailDate(email.date)}</span>
</div>
<div className="text-sm text-muted-foreground truncate mt-1">
@ -214,29 +117,31 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
)}
</div>
{/* Attachments */}
{/* Attachments list */}
{hasAttachments && (
<div className="px-6 py-3 border-b bg-muted/30">
<div className="text-sm font-medium mb-2">Attachments</div>
<div className="px-6 py-3 border-b bg-muted/20">
<h3 className="text-sm font-medium mb-2">Attachments ({email.attachments.length})</h3>
<div className="flex flex-wrap gap-2">
{email.attachments.map((attachment, index) => (
<Badge key={index} variant="outline" className="flex items-center gap-1 px-2 py-1">
<Paperclip className="h-3.5 w-3.5" />
<div
key={index}
className="flex items-center gap-2 bg-background rounded-md px-3 py-1.5 text-sm border"
>
<div className="flex-shrink-0">
<svg className="h-4 w-4 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>
</div>
<span>{attachment.filename}</span>
<span className="text-xs text-muted-foreground ml-1">
({Math.round(attachment.size / 1024)}KB)
</span>
</Badge>
</div>
))}
</div>
</div>
)}
</div>
{/* Email content */}
{/* Email body */}
<ScrollArea className="flex-1">
<div className="p-6">
{/* IMPROVED: Simplified email content container with better styling */}
{/* Render the email content using the new standardized component */}
<div
ref={editorRef}
className="email-content-container rounded-lg overflow-hidden bg-white shadow-sm"
@ -245,17 +150,11 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
border: '1px solid #e2e8f0'
}}
>
{/* Render the formatted content directly */}
{formattedContent ? (
<div
className="email-body"
dangerouslySetInnerHTML={{ __html: formattedContent }}
/>
) : (
<div className="p-8 text-center text-muted-foreground">
<p>This email does not contain any content.</p>
</div>
)}
<EmailContentDisplay
content={email.content}
type="auto"
className="p-6"
/>
</div>
{/* Only in development mode: Show debugging info */}
@ -264,16 +163,12 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
<summary className="cursor-pointer">Email Debug Info</summary>
<div className="mt-2 overflow-auto max-h-40 p-2 bg-gray-50 rounded">
<p><strong>Email ID:</strong> {email.id}</p>
<p><strong>Content Type:</strong> {
typeof email.content === 'object' && email.content?.html
? 'HTML'
: 'Plain Text'
}</p>
<p><strong>Content Size:</strong> {
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`
}</p>
<p><strong>Content Type:</strong> {email.content.isHtml ? 'HTML' : 'Plain Text'}</p>
<p><strong>Text Direction:</strong> {email.content.direction || 'ltr'}</p>
<p><strong>Content Size:</strong>
HTML: {email.content.html?.length || 0} chars,
Text: {email.content.text?.length || 0} chars
</p>
</div>
</details>
)}

View File

@ -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<QuotedEmailContentProps> = ({
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<QuotedEmailContentProps> = ({
<div>---------- Forwarded message ---------</div>
<div><b>From:</b> {senderName} &lt;{sender.email}&gt;</div>
<div><b>Date:</b> {formattedDate}</div>
<div><b>Subject:</b> {/* Subject would be passed as a prop if needed */}</div>
<div><b>To:</b> {/* Recipients would be passed as a prop if needed */}</div>
<div><b>Subject:</b> {subject || '(No subject)'}</div>
<div><b>To:</b> {recipients || 'Undisclosed recipients'}</div>
</div>
);
}

147
lib/utils/email-adapter.ts Normal file
View File

@ -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 <email@example.com>"
const match = address.match(/^(?:"?([^"]*)"?\s)?<?([^\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'
};
}

448
lib/utils/email-utils.ts Normal file
View File

@ -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(/<!--\[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(/(javascript|jscript|vbscript|mocha):/gi, 'removed:');
}
}
/**
* Format plain text for HTML display with proper line breaks
*/
export function formatPlainTextToHtml(text: string): string {
if (!text) return '';
// Escape HTML characters to prevent XSS
const escapedText = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// Format plain text with proper line breaks and paragraphs
return escapedText
.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
}
/**
* Normalize email content to our standard format regardless of input format
* This is the key function that handles all the different email content formats
*/
export function normalizeEmailContent(email: any): EmailContent {
// Default content structure
const normalizedContent: EmailContent = {
html: undefined,
text: '',
isHtml: false,
direction: 'ltr'
};
try {
// Extract content based on standardized property hierarchy
let htmlContent = '';
let textContent = '';
let isHtml = false;
// Step 1: Extract content from the various possible formats
if (email.content && typeof email.content === 'object') {
isHtml = !!email.content.html;
htmlContent = email.content.html || '';
textContent = email.content.text || '';
} else if (typeof email.content === 'string') {
// 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>'));
htmlContent = isHtml ? email.content : '';
textContent = isHtml ? '' : email.content;
} else if (email.html) {
isHtml = true;
htmlContent = email.html;
textContent = email.text || '';
} else if (email.text) {
isHtml = false;
htmlContent = '';
textContent = email.text;
} else if (email.formattedContent) {
// Assume formattedContent is already HTML
isHtml = true;
htmlContent = email.formattedContent;
textContent = '';
}
// Step 2: Set the normalized content properties
normalizedContent.isHtml = isHtml;
// Always ensure we have text content
if (textContent) {
normalizedContent.text = textContent;
} else if (htmlContent) {
// Extract text from HTML if we don't have plain text
if (typeof document !== 'undefined') {
// Browser environment
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
normalizedContent.text = tempDiv.textContent || tempDiv.innerText || '';
} else {
// Server environment - do simple strip
normalizedContent.text = htmlContent
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
}
// If we have HTML content, sanitize it
if (isHtml && htmlContent) {
normalizedContent.html = sanitizeHtml(htmlContent);
}
// Determine text direction
normalizedContent.direction = detectTextDirection(normalizedContent.text);
return normalizedContent;
} catch (error) {
console.error('Error normalizing email content:', error);
// Return minimal valid content in case of error
return {
text: 'Error loading email content',
isHtml: false,
direction: 'ltr'
};
}
}
/**
* Render normalized email content into HTML for display
*/
export function renderEmailContent(content: EmailContent): string {
if (!content) {
return '<div class="email-content-empty">No content available</div>';
}
try {
// Determine if we're rendering HTML or plain text
if (content.isHtml && content.html) {
// For HTML content, wrap it with proper styling
return `<div class="email-content" dir="${content.direction}" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 100%; overflow-x: auto; overflow-wrap: break-word; word-wrap: break-word;">${content.html}</div>`;
} else {
// For plain text, format it as HTML and wrap with monospace styling
const formattedText = formatPlainTextToHtml(content.text);
return `<div class="email-content plain-text" dir="${content.direction}" style="font-family: -apple-system, BlinkMacSystemFont, Menlo, Monaco, Consolas, 'Courier New', monospace; white-space: pre-wrap; line-height: 1.5; color: #333; padding: 15px; max-width: 100%; overflow-wrap: break-word;"><p>${formattedText}</p></div>`;
}
} catch (error) {
console.error('Error rendering email content:', error);
return `<div class="email-content-error" style="padding: 15px; color: #721c24; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px;"><p>Error displaying email content</p><p style="font-size: 12px; margin-top: 10px;">${error instanceof Error ? error.message : 'Unknown error'}</p></div>`;
}
}
/**
* Format an email for forwarding
*/
export function formatForwardedEmail(email: EmailMessage): {
subject: string;
content: EmailContent;
} {
// 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 original content as HTML
const originalContent = email.content.isHtml && email.content.html
? email.content.html
: formatPlainTextToHtml(email.content.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
let htmlContent = '';
if (hasExistingHeader) {
// Just wrap the content without additional formatting
htmlContent = `
<div style="min-height: 20px;"></div>
<div class="email-original-content">
${originalContent}
</div>
`;
} else {
// Create formatted content for forwarded email
htmlContent = `
<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>
`;
}
// Create normalized content with HTML and extracted text
const content: EmailContent = {
html: sanitizeHtml(htmlContent),
text: '', // Will be extracted when composing
isHtml: true,
direction: email.content.direction || 'ltr'
};
// Extract text from HTML if in browser environment
if (typeof document !== 'undefined') {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
content.text = tempDiv.textContent || tempDiv.innerText || '';
} else {
// Simple text extraction in server environment
content.text = htmlContent
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.trim();
}
return { subject, content };
}
/**
* Format an email for reply or reply-all
*/
export function formatReplyEmail(email: EmailMessage, type: 'reply' | 'reply-all'): {
to: string;
cc?: string;
subject: string;
content: EmailContent;
} {
// Format email addresses
const to = formatEmailAddresses(email.from || []);
// For reply-all, include all recipients in CC except our own address
let cc = undefined;
if (type === 'reply-all' && (email.to || email.cc)) {
const allRecipients = [
...(email.to || []),
...(email.cc || [])
];
// Remove duplicates, then convert to string
const uniqueRecipients = [...new Map(allRecipients.map(addr =>
[addr.address, addr]
)).values()];
cc = formatEmailAddresses(uniqueRecipients);
}
// Format subject with Re: prefix if needed
const subjectBase = email.subject || '(No subject)';
const subject = subjectBase.match(/^Re:/i) ? subjectBase : `Re: ${subjectBase}`;
// Get original content as HTML
const originalContent = email.content.isHtml && email.content.html
? email.content.html
: formatPlainTextToHtml(email.content.text);
// Format sender info
const sender = email.from && email.from.length > 0 ? email.from[0] : undefined;
const senderName = sender ? (sender.name || sender.address) : 'Unknown Sender';
const formattedDate = formatEmailDate(email.date);
// Create the reply content with attribution line
const htmlContent = `
<div style="min-height: 20px;"></div>
<div style="border-top: 1px solid #ccc; margin-top: 10px; padding-top: 10px;">
<div style="font-family: Arial, sans-serif; color: #666; margin-bottom: 10px;">
On ${formattedDate}, ${senderName} wrote:
</div>
<blockquote style="margin: 0 0 0 10px; padding: 0 0 0 10px; border-left: 2px solid #ccc;">
${originalContent}
</blockquote>
</div>
`;
// Create normalized content with HTML and extracted text
const content: EmailContent = {
html: sanitizeHtml(htmlContent),
text: '', // Will be extracted when composing
isHtml: true,
direction: email.content.direction || 'ltr'
};
// Extract text from HTML if in browser environment
if (typeof document !== 'undefined') {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
content.text = tempDiv.textContent || tempDiv.innerText || '';
} else {
// Simple text extraction in server environment
content.text = htmlContent
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.trim();
}
return { to, cc, subject, content };
}
/**
* Format an email for reply or forward - Unified API
*/
export function formatEmailForReplyOrForward(
email: EmailMessage,
type: 'reply' | 'reply-all' | 'forward'
): {
to?: string;
cc?: string;
subject: string;
content: EmailContent;
} {
if (type === 'forward') {
const { subject, content } = formatForwardedEmail(email);
return { subject, content };
} else {
return formatReplyEmail(email, type);
}
}

53
types/email.ts Normal file
View File

@ -0,0 +1,53 @@
/**
* Standardized Email Interfaces
*
* This file contains the core interfaces for email data structures
* used throughout the application. All components should use these
* interfaces for consistency.
*/
export interface EmailAddress {
name: string;
address: string;
}
export interface EmailAttachment {
filename: string;
contentType: string;
content?: string;
size?: number;
contentId?: string;
}
export interface EmailContent {
html?: string; // HTML content if available
text: string; // Plain text (always present)
isHtml: boolean; // Flag to indicate primary content type
direction?: 'ltr'|'rtl' // Text direction
}
export interface EmailFlags {
seen: boolean;
flagged: boolean;
answered: boolean;
deleted: boolean;
draft: boolean;
}
export interface EmailMessage {
id: string;
messageId?: string;
uid?: number;
subject: string;
from: EmailAddress[];
to: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
date: Date | string;
flags: EmailFlags;
preview?: string;
content: EmailContent; // Standardized content structure
attachments: EmailAttachment[];
folder?: string;
size?: number;
}