courrier preview
This commit is contained in:
parent
3f017f82f8
commit
d12a6c4670
109
components/email/EmailContentDisplay.tsx
Normal file
109
components/email/EmailContentDisplay.tsx
Normal 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;
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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} <{sender.email}></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
147
lib/utils/email-adapter.ts
Normal 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
448
lib/utils/email-utils.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
// 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(/ /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(/ /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(/ /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
53
types/email.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user