courrier refactor rebuild 2
This commit is contained in:
parent
51a92f27dd
commit
4ef9268b86
@ -16,14 +16,15 @@ import ComposeEmailHeader from './ComposeEmailHeader';
|
||||
import ComposeEmailForm from './ComposeEmailForm';
|
||||
import ComposeEmailFooter from './ComposeEmailFooter';
|
||||
import RichEmailEditor from './RichEmailEditor';
|
||||
import QuotedEmailContent from './QuotedEmailContent';
|
||||
|
||||
// Import ONLY from the centralized formatter
|
||||
import {
|
||||
formatReplyEmail,
|
||||
formatForwardedEmail,
|
||||
formatReplyEmail,
|
||||
formatEmailForReplyOrForward,
|
||||
EmailMessage as FormatterEmailMessage,
|
||||
sanitizeHtml
|
||||
formatEmailAddresses,
|
||||
type EmailMessage,
|
||||
type EmailAddress
|
||||
} from '@/lib/utils/email-formatter';
|
||||
|
||||
/**
|
||||
@ -39,40 +40,7 @@ import {
|
||||
* for consistent handling of email content and text direction.
|
||||
*/
|
||||
|
||||
// Define EmailMessage interface locally instead of importing from server-only file
|
||||
interface EmailAddress {
|
||||
name: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
interface EmailMessage {
|
||||
id: string;
|
||||
messageId?: string;
|
||||
subject: string;
|
||||
from: EmailAddress[];
|
||||
to: EmailAddress[];
|
||||
cc?: EmailAddress[];
|
||||
bcc?: EmailAddress[];
|
||||
date: Date | string;
|
||||
flags?: {
|
||||
seen: boolean;
|
||||
flagged: boolean;
|
||||
answered: boolean;
|
||||
deleted: boolean;
|
||||
draft: boolean;
|
||||
};
|
||||
preview?: string;
|
||||
content?: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
hasAttachments?: boolean;
|
||||
attachments?: any[];
|
||||
folder?: string;
|
||||
size?: number;
|
||||
contentFetched?: boolean;
|
||||
}
|
||||
|
||||
// Legacy interface for backward compatibility with old ComposeEmail component
|
||||
// Define interface for the legacy props
|
||||
interface LegacyComposeEmailProps {
|
||||
showCompose: boolean;
|
||||
setShowCompose: (show: boolean) => void;
|
||||
@ -103,7 +71,7 @@ interface LegacyComposeEmailProps {
|
||||
forwardFrom?: any | null;
|
||||
}
|
||||
|
||||
// New interface for the modern ComposeEmail component
|
||||
// Define interface for the modern props
|
||||
interface ComposeEmailProps {
|
||||
initialEmail?: EmailMessage | null;
|
||||
type?: 'new' | 'reply' | 'reply-all' | 'forward';
|
||||
@ -122,12 +90,46 @@ interface ComposeEmailProps {
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
// Union type to handle both new and legacy props
|
||||
// Union type for handling both types of props
|
||||
type ComposeEmailAllProps = ComposeEmailProps | LegacyComposeEmailProps;
|
||||
|
||||
// Type guard to check if props are legacy
|
||||
function isLegacyProps(props: ComposeEmailAllProps): props is LegacyComposeEmailProps {
|
||||
return 'showCompose' in props && 'setShowCompose' in props;
|
||||
function isLegacyProps(
|
||||
props: ComposeEmailAllProps
|
||||
): props is LegacyComposeEmailProps {
|
||||
return 'showCompose' in props;
|
||||
}
|
||||
|
||||
// Helper function to adapt EmailMessage to QuotedEmailContent props format
|
||||
function EmailMessageToQuotedContentAdapter({
|
||||
email,
|
||||
type
|
||||
}: {
|
||||
email: EmailMessage,
|
||||
type: 'reply' | 'reply-all' | 'forward'
|
||||
}) {
|
||||
// Get the email content
|
||||
const content = email.content || email.html || email.text || '';
|
||||
|
||||
// Get the sender
|
||||
const sender = email.from && email.from.length > 0
|
||||
? {
|
||||
name: email.from[0].name,
|
||||
email: email.from[0].address
|
||||
}
|
||||
: { email: 'unknown@example.com' };
|
||||
|
||||
// Map the type to what QuotedEmailContent expects
|
||||
const mappedType = type === 'reply-all' ? 'reply' : type;
|
||||
|
||||
return (
|
||||
<QuotedEmailContent
|
||||
content={content}
|
||||
sender={sender}
|
||||
date={email.date}
|
||||
type={mappedType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComposeEmail(props: ComposeEmailAllProps) {
|
||||
@ -158,39 +160,56 @@ export default function ComposeEmail(props: ComposeEmailAllProps) {
|
||||
useEffect(() => {
|
||||
if (initialEmail && type !== 'new') {
|
||||
try {
|
||||
const formatterEmail: FormatterEmailMessage = {
|
||||
id: initialEmail.id,
|
||||
messageId: initialEmail.messageId,
|
||||
subject: initialEmail.subject,
|
||||
from: initialEmail.from || [],
|
||||
to: initialEmail.to || [],
|
||||
cc: initialEmail.cc || [],
|
||||
bcc: initialEmail.bcc || [],
|
||||
date: initialEmail.date,
|
||||
content: initialEmail.content,
|
||||
html: initialEmail.html,
|
||||
text: initialEmail.text,
|
||||
hasAttachments: initialEmail.hasAttachments || false
|
||||
};
|
||||
|
||||
if (type === 'forward') {
|
||||
// For forwarding, use the dedicated formatter
|
||||
const { subject, content } = formatForwardedEmail(formatterEmail);
|
||||
// Set recipients based on type
|
||||
if (type === 'reply' || type === 'reply-all') {
|
||||
// Reply goes to the original sender
|
||||
setTo(formatEmailAddresses(initialEmail.from || []));
|
||||
|
||||
// For reply-all, include all original recipients in CC
|
||||
if (type === 'reply-all') {
|
||||
const allRecipients = [
|
||||
...(initialEmail.to || []),
|
||||
...(initialEmail.cc || [])
|
||||
];
|
||||
// Filter out the current user if they were a recipient
|
||||
// This would need some user context to properly implement
|
||||
setCc(formatEmailAddresses(allRecipients));
|
||||
}
|
||||
|
||||
// Set subject with Re: prefix
|
||||
const subjectBase = initialEmail.subject || '(No subject)';
|
||||
const subject = subjectBase.match(/^Re:/i) ? subjectBase : `Re: ${subjectBase}`;
|
||||
setSubject(subject);
|
||||
setEmailContent(content);
|
||||
} else {
|
||||
// For reply/reply-all, use the reply formatter
|
||||
const { to, cc, subject, content } = formatReplyEmail(formatterEmail, type as 'reply' | 'reply-all');
|
||||
setTo(to);
|
||||
if (cc) {
|
||||
setCc(cc);
|
||||
|
||||
// Set an empty content with proper spacing before the quoted content
|
||||
setEmailContent('<div><br/><br/></div>');
|
||||
|
||||
// Show CC field if there are CC recipients
|
||||
if (initialEmail.cc && initialEmail.cc.length > 0) {
|
||||
setShowCc(true);
|
||||
}
|
||||
}
|
||||
else if (type === 'forward') {
|
||||
// Set subject with Fwd: prefix
|
||||
const subjectBase = initialEmail.subject || '(No subject)';
|
||||
const subject = subjectBase.match(/^(Fwd|FW|Forward):/i) ? subjectBase : `Fwd: ${subjectBase}`;
|
||||
setSubject(subject);
|
||||
setEmailContent(content);
|
||||
|
||||
// Set an empty content with proper spacing before the quoted content
|
||||
setEmailContent('<div><br/><br/></div>');
|
||||
|
||||
// If the original email has attachments, we should include them
|
||||
if (initialEmail.attachments && initialEmail.attachments.length > 0) {
|
||||
const formattedAttachments = initialEmail.attachments.map(att => ({
|
||||
name: att.filename || 'attachment',
|
||||
type: att.contentType || 'application/octet-stream',
|
||||
content: att.content || ''
|
||||
}));
|
||||
setAttachments(formattedAttachments);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error formatting email for reply/forward:', err);
|
||||
} catch (error) {
|
||||
console.error('Error initializing compose form:', error);
|
||||
}
|
||||
}
|
||||
}, [initialEmail, type]);
|
||||
@ -339,10 +358,21 @@ export default function ComposeEmail(props: ComposeEmailAllProps) {
|
||||
onChange={setEmailContent}
|
||||
minHeight="200px"
|
||||
maxHeight="none"
|
||||
preserveFormatting={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add QuotedEmailContent for replies and forwards */}
|
||||
{initialEmail && (type === 'reply' || type === 'reply-all' || type === 'forward') && (
|
||||
<div className="mt-4">
|
||||
<EmailMessageToQuotedContentAdapter
|
||||
email={initialEmail}
|
||||
type={type}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachments */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="border rounded-md p-3 mt-4">
|
||||
@ -625,10 +655,23 @@ function LegacyAdapter({
|
||||
onChange={setComposeBody}
|
||||
minHeight="200px"
|
||||
maxHeight="none"
|
||||
preserveFormatting={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add QuotedEmailContent for replies and forwards */}
|
||||
{(originalEmail || replyTo || forwardFrom) &&
|
||||
(determineType() === 'reply' || determineType() === 'reply-all' || determineType() === 'forward') && (
|
||||
<div className="mt-4">
|
||||
{/* For legacy adapter, we'd need to convert the different formats */}
|
||||
{/* Since we don't have the full implementation for this, we'll add a placeholder */}
|
||||
<div className="border-t border-gray-200 pt-4 mt-2 text-gray-500 italic">
|
||||
<p>Original message content would appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachments */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="border rounded-md p-3 mt-4">
|
||||
|
||||
197
components/email/EmailContentDisplay.tsx
Normal file
197
components/email/EmailContentDisplay.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { parseRawEmail } from '@/lib/utils/email-mime-decoder';
|
||||
|
||||
interface EmailContentDisplayProps {
|
||||
content: string;
|
||||
type?: 'html' | 'text' | 'auto';
|
||||
className?: string;
|
||||
showQuotedText?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying properly formatted email content
|
||||
* Handles MIME decoding, sanitization, and proper rendering
|
||||
*/
|
||||
const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
|
||||
content,
|
||||
type = 'auto',
|
||||
className = '',
|
||||
showQuotedText = true
|
||||
}) => {
|
||||
const [processedContent, setProcessedContent] = useState<{
|
||||
html: string;
|
||||
text: string;
|
||||
isHtml: boolean;
|
||||
}>({
|
||||
html: '',
|
||||
text: '',
|
||||
isHtml: false
|
||||
});
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Process and sanitize email content
|
||||
useEffect(() => {
|
||||
if (!content) {
|
||||
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);
|
||||
|
||||
// Check which content to use based on type and availability
|
||||
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
|
||||
});
|
||||
|
||||
setProcessedContent({
|
||||
html: sanitizedHtml,
|
||||
text: parsed.text,
|
||||
isHtml: true
|
||||
});
|
||||
} else {
|
||||
// Format plain text with line breaks
|
||||
const formattedText = parsed.text.replace(/\n/g, '<br />');
|
||||
setProcessedContent({
|
||||
html: formattedText,
|
||||
text: parsed.text,
|
||||
isHtml: false
|
||||
});
|
||||
}
|
||||
} 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');
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
setProcessedContent({
|
||||
html: sanitizedHtml,
|
||||
text: content,
|
||||
isHtml: true
|
||||
});
|
||||
} else {
|
||||
// Format plain text with line breaks
|
||||
const formattedText = content.replace(/\n/g, '<br />');
|
||||
setProcessedContent({
|
||||
html: formattedText,
|
||||
text: content,
|
||||
isHtml: false
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing email content:', err);
|
||||
// Fallback to plain text
|
||||
setProcessedContent({
|
||||
html: content.replace(/\n/g, '<br />'),
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`email-content-display ${className}`}
|
||||
dangerouslySetInnerHTML={{ __html: processedContent.html }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailContentDisplay;
|
||||
144
components/email/QuotedEmailContent.tsx
Normal file
144
components/email/QuotedEmailContent.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import EmailContentDisplay from './EmailContentDisplay';
|
||||
|
||||
interface QuotedEmailContentProps {
|
||||
content: string;
|
||||
sender: {
|
||||
name?: string;
|
||||
email: string;
|
||||
};
|
||||
date: Date | string;
|
||||
type: 'reply' | 'forward';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying properly formatted quoted email content in replies and forwards
|
||||
*/
|
||||
const QuotedEmailContent: React.FC<QuotedEmailContentProps> = ({
|
||||
content,
|
||||
sender,
|
||||
date,
|
||||
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);
|
||||
|
||||
// Create header based on type
|
||||
const renderQuoteHeader = () => {
|
||||
if (type === 'reply') {
|
||||
return (
|
||||
<div className="quote-header">
|
||||
On {formattedDate}, {senderName} wrote:
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="forward-header">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`quoted-email-container ${className}`}>
|
||||
{renderQuoteHeader()}
|
||||
<div className="quoted-content">
|
||||
<EmailContentDisplay
|
||||
content={content}
|
||||
type="auto"
|
||||
className="quoted-email-body"
|
||||
showQuotedText={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.quoted-email-container {
|
||||
margin-top: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.quote-header {
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
margin: 10px 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.forward-header {
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
margin-bottom: 15px;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.forward-header div {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.quoted-content {
|
||||
border-left: 2px solid #ddd;
|
||||
padding: 0 0 0 15px;
|
||||
margin: 10px 0;
|
||||
color: #505050;
|
||||
}
|
||||
|
||||
:global(.quoted-email-body) {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:global(.quoted-email-body table) {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
:global(.quoted-email-body th),
|
||||
:global(.quoted-email-body td) {
|
||||
padding: 0.5rem;
|
||||
vertical-align: top;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
:global(.quoted-email-body th) {
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotedEmailContent;
|
||||
@ -10,6 +10,7 @@ interface RichEmailEditorProps {
|
||||
placeholder?: string;
|
||||
minHeight?: string;
|
||||
maxHeight?: string;
|
||||
preserveFormatting?: boolean;
|
||||
}
|
||||
|
||||
const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
|
||||
@ -18,6 +19,7 @@ const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
|
||||
placeholder = 'Write your message here...',
|
||||
minHeight = '200px',
|
||||
maxHeight = 'calc(100vh - 400px)',
|
||||
preserveFormatting = false,
|
||||
}) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
@ -32,6 +34,21 @@ const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
|
||||
|
||||
const Quill = (await import('quill')).default;
|
||||
|
||||
// Import quill-better-table
|
||||
try {
|
||||
const QuillBetterTable = await import('quill-better-table');
|
||||
|
||||
// Register the table module if available
|
||||
if (QuillBetterTable && QuillBetterTable.default) {
|
||||
Quill.register({
|
||||
'modules/better-table': QuillBetterTable.default
|
||||
}, true);
|
||||
console.log('Better Table module registered successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Table module not available:', err);
|
||||
}
|
||||
|
||||
// Define custom formats/modules with table support
|
||||
const emailToolbarOptions = [
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
@ -53,6 +70,15 @@ const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
|
||||
// Add any custom toolbar handlers here
|
||||
}
|
||||
},
|
||||
'better-table': preserveFormatting ? {
|
||||
operationMenu: {
|
||||
items: {
|
||||
unmergeCells: {
|
||||
text: 'Unmerge cells'
|
||||
}
|
||||
}
|
||||
}
|
||||
} : false,
|
||||
},
|
||||
placeholder: placeholder,
|
||||
theme: 'snow',
|
||||
@ -64,14 +90,29 @@ const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
|
||||
// First, ensure we preserve the raw HTML structure
|
||||
const preservedContent = sanitizeHtml(initialContent);
|
||||
|
||||
// Use root's innerHTML for complete reset to avoid Quill's automatic formatting
|
||||
quillRef.current.root.innerHTML = '';
|
||||
|
||||
// Now use clipboard API to insert the content with proper Quill delta conversion
|
||||
// Set editor content with paste method which preserves most formatting
|
||||
quillRef.current.clipboard.dangerouslyPasteHTML(0, preservedContent);
|
||||
|
||||
// Force update to ensure content is rendered
|
||||
quillRef.current.update();
|
||||
// If we're specifically trying to preserve complex HTML like tables
|
||||
if (preserveFormatting) {
|
||||
// For tables and complex formatting, we may need to manually preserve some elements
|
||||
// Get all table elements from the original content
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = preservedContent;
|
||||
|
||||
// Force better table rendering in Quill
|
||||
setTimeout(() => {
|
||||
// This ensures tables are properly rendered by forcing a refresh
|
||||
quillRef.current.update();
|
||||
|
||||
// Additional step: directly set HTML if tables aren't rendering properly
|
||||
if (tempDiv.querySelectorAll('table').length > 0 &&
|
||||
!quillRef.current.root.querySelectorAll('table').length) {
|
||||
console.log('Using HTML fallback for tables');
|
||||
quillRef.current.root.innerHTML = preservedContent;
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error setting initial content:', err);
|
||||
// Fallback method if the above fails
|
||||
|
||||
2
global.d.ts
vendored
Normal file
2
global.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
// Global type declarations
|
||||
declare module 'quill-better-table';
|
||||
275
lib/utils/email-mime-decoder.ts
Normal file
275
lib/utils/email-mime-decoder.ts
Normal file
@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Email MIME Decoder
|
||||
*
|
||||
* This module provides functions to decode MIME-encoded email content
|
||||
* for proper display in a frontend application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decode a MIME encoded string (quoted-printable or base64)
|
||||
* @param {string} text - The encoded text
|
||||
* @param {string} encoding - The encoding type ('quoted-printable', 'base64', etc)
|
||||
* @param {string} charset - The character set (utf-8, iso-8859-1, etc)
|
||||
* @returns {string} - The decoded text
|
||||
*/
|
||||
export function decodeMIME(text: string, encoding?: string, charset = 'utf-8'): string {
|
||||
if (!text) return '';
|
||||
|
||||
// Normalize encoding to lowercase
|
||||
encoding = (encoding || '').toLowerCase();
|
||||
charset = (charset || 'utf-8').toLowerCase();
|
||||
|
||||
try {
|
||||
// Handle different encoding types
|
||||
if (encoding === 'quoted-printable') {
|
||||
return decodeQuotedPrintable(text, charset);
|
||||
} else if (encoding === 'base64') {
|
||||
return decodeBase64(text, charset);
|
||||
} else {
|
||||
// Plain text or other encoding
|
||||
return text;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error decoding MIME:', error);
|
||||
return text; // Return original text if decoding fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a quoted-printable encoded string
|
||||
* @param {string} text - The quoted-printable encoded text
|
||||
* @param {string} charset - The character set
|
||||
* @returns {string} - The decoded text
|
||||
*/
|
||||
export function decodeQuotedPrintable(text: string, charset: string): string {
|
||||
// Replace soft line breaks (=\r\n or =\n)
|
||||
let decoded = text.replace(/=(?:\r\n|\n)/g, '');
|
||||
|
||||
// Replace quoted-printable encoded characters
|
||||
decoded = decoded.replace(/=([0-9A-F]{2})/gi, (match, p1) => {
|
||||
return String.fromCharCode(parseInt(p1, 16));
|
||||
});
|
||||
|
||||
// Handle character encoding
|
||||
if (charset !== 'utf-8' && typeof TextDecoder !== 'undefined') {
|
||||
try {
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
bytes[i] = decoded.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder(charset).decode(bytes);
|
||||
} catch (e) {
|
||||
console.warn('TextDecoder error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 encoded string
|
||||
* @param {string} text - The base64 encoded text
|
||||
* @param {string} charset - The character set
|
||||
* @returns {string} - The decoded text
|
||||
*/
|
||||
export function decodeBase64(text: string, charset: string): string {
|
||||
// Remove whitespace that might be present in the base64 string
|
||||
const cleanText = text.replace(/\s/g, '');
|
||||
|
||||
try {
|
||||
// Use built-in atob function and TextDecoder for charset handling
|
||||
const binary = atob(cleanText);
|
||||
if (charset !== 'utf-8' && typeof TextDecoder !== 'undefined') {
|
||||
// If TextDecoder is available and the charset is not utf-8
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder(charset).decode(bytes);
|
||||
}
|
||||
return binary;
|
||||
} catch (e) {
|
||||
console.error('Base64 decoding error:', e);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse email headers to extract content type, encoding and charset
|
||||
* @param {string} headers - The raw email headers
|
||||
* @returns {Object} - Object containing content type, encoding and charset
|
||||
*/
|
||||
export function parseEmailHeaders(headers: string): {
|
||||
contentType: string;
|
||||
encoding: string;
|
||||
charset: string;
|
||||
} {
|
||||
const result = {
|
||||
contentType: 'text/plain',
|
||||
encoding: 'quoted-printable',
|
||||
charset: 'utf-8'
|
||||
};
|
||||
|
||||
// Extract content type
|
||||
const contentTypeMatch = headers.match(/Content-Type:\s*([^;]+)(?:;\s*charset=([^;]+))?/i);
|
||||
if (contentTypeMatch) {
|
||||
result.contentType = contentTypeMatch[1].trim().toLowerCase();
|
||||
if (contentTypeMatch[2]) {
|
||||
result.charset = contentTypeMatch[2].trim().replace(/"/g, '').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Extract content transfer encoding
|
||||
const encodingMatch = headers.match(/Content-Transfer-Encoding:\s*([^\s]+)/i);
|
||||
if (encodingMatch) {
|
||||
result.encoding = encodingMatch[1].trim().toLowerCase();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an email body based on its headers
|
||||
* @param {string} emailRaw - The raw email content (headers + body)
|
||||
* @returns {Object} - Object containing decoded text and html parts
|
||||
*/
|
||||
export function decodeEmail(emailRaw: string): {
|
||||
contentType: string;
|
||||
charset: string;
|
||||
encoding: string;
|
||||
decodedBody: string;
|
||||
headers: string;
|
||||
} {
|
||||
// Separate headers and body
|
||||
const parts = emailRaw.split(/\r?\n\r?\n/);
|
||||
const headers = parts[0];
|
||||
const body = parts.slice(1).join('\n\n');
|
||||
|
||||
// Parse headers
|
||||
const { contentType, encoding, charset } = parseEmailHeaders(headers);
|
||||
|
||||
// Decode the body
|
||||
const decodedBody = decodeMIME(body, encoding, charset);
|
||||
|
||||
return {
|
||||
contentType,
|
||||
charset,
|
||||
encoding,
|
||||
decodedBody,
|
||||
headers
|
||||
};
|
||||
}
|
||||
|
||||
interface EmailContent {
|
||||
text: string;
|
||||
html: string;
|
||||
attachments: Array<{
|
||||
contentType: string;
|
||||
content: string;
|
||||
filename?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a multipart email to extract text and HTML parts
|
||||
* @param {string} emailRaw - The raw email content
|
||||
* @param {string} boundary - The multipart boundary
|
||||
* @returns {Object} - Object containing text and html parts
|
||||
*/
|
||||
export function processMultipartEmail(emailRaw: string, boundary: string): EmailContent {
|
||||
const result: EmailContent = {
|
||||
text: '',
|
||||
html: '',
|
||||
attachments: []
|
||||
};
|
||||
|
||||
// Split by boundary
|
||||
const boundaryRegex = new RegExp(`--${boundary}\\r?\\n|--${boundary}--\\r?\\n?`, 'g');
|
||||
const parts = emailRaw.split(boundaryRegex).filter(part => part.trim());
|
||||
|
||||
// Process each part
|
||||
parts.forEach(part => {
|
||||
const decoded = decodeEmail(part);
|
||||
|
||||
if (decoded.contentType === 'text/plain') {
|
||||
result.text = decoded.decodedBody;
|
||||
} else if (decoded.contentType === 'text/html') {
|
||||
result.html = decoded.decodedBody;
|
||||
} else if (decoded.contentType.startsWith('image/') ||
|
||||
decoded.contentType.startsWith('application/')) {
|
||||
// Extract filename if available
|
||||
const filenameMatch = decoded.headers.match(/filename=["']?([^"';\r\n]+)/i);
|
||||
const filename = filenameMatch ? filenameMatch[1] : 'attachment';
|
||||
|
||||
// Handle attachments
|
||||
result.attachments.push({
|
||||
contentType: decoded.contentType,
|
||||
content: decoded.decodedBody,
|
||||
filename
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract boundary from Content-Type header
|
||||
* @param {string} contentType - The Content-Type header value
|
||||
* @returns {string|null} - The boundary string or null if not found
|
||||
*/
|
||||
export function extractBoundary(contentType: string): string | null {
|
||||
const boundaryMatch = contentType.match(/boundary=["']?([^"';]+)/i);
|
||||
return boundaryMatch ? boundaryMatch[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an email from its raw content
|
||||
* @param {string} rawEmail - The raw email content
|
||||
* @returns {Object} - The parsed email with text and html parts
|
||||
*/
|
||||
export function parseRawEmail(rawEmail: string): EmailContent {
|
||||
// Default result structure
|
||||
const result: EmailContent = {
|
||||
text: '',
|
||||
html: '',
|
||||
attachments: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Split headers and body
|
||||
const headerBodySplit = rawEmail.split(/\r?\n\r?\n/);
|
||||
const headers = headerBodySplit[0];
|
||||
const body = headerBodySplit.slice(1).join('\n\n');
|
||||
|
||||
// Check if multipart
|
||||
const contentTypeHeader = headers.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||
|
||||
if (contentTypeHeader && contentTypeHeader[1].includes('multipart/')) {
|
||||
// Get boundary
|
||||
const boundary = extractBoundary(contentTypeHeader[1]);
|
||||
|
||||
if (boundary) {
|
||||
// Process multipart email
|
||||
return processMultipartEmail(rawEmail, boundary);
|
||||
}
|
||||
}
|
||||
|
||||
// Not multipart, decode as a single part
|
||||
const { contentType, encoding, charset, decodedBody } = decodeEmail(rawEmail);
|
||||
|
||||
// Set content based on type
|
||||
if (contentType.includes('text/html')) {
|
||||
result.html = decodedBody;
|
||||
} else {
|
||||
result.text = decodedBody;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error parsing raw email:', error);
|
||||
// Return raw content as text on error
|
||||
result.text = rawEmail;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
12
node_modules/.package-lock.json
generated
vendored
12
node_modules/.package-lock.json
generated
vendored
@ -5622,6 +5622,12 @@
|
||||
"npm": ">=8.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-better-table": {
|
||||
"version": "1.2.10",
|
||||
"resolved": "https://registry.npmjs.org/quill-better-table/-/quill-better-table-1.2.10.tgz",
|
||||
"integrity": "sha512-CFwxAQzt4EPCQuynQ65R/FU7Yu//kcDBb/rmBBOsFfO758+q50zvG/PDt4Lenv9DcrSgwnyNkfo4yeA5fzzVYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quill-delta": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||
@ -6832,6 +6838,12 @@
|
||||
"utf8": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vcard-parser": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vcard-parser/-/vcard-parser-1.0.0.tgz",
|
||||
"integrity": "sha512-rSEjrjBK3of4VimMR5vBjLLcN5ZCSp9yuVzyx5i4Fwx74Yd0s+DnHtSit/wAAtj1a7/T/qQc0ykwXADoD0+fTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vcd-parser": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz",
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@ -71,6 +71,7 @@
|
||||
"nodemailer": "^6.10.1",
|
||||
"pg": "^8.14.1",
|
||||
"quill": "^2.0.3",
|
||||
"quill-better-table": "^1.2.10",
|
||||
"react": "^18",
|
||||
"react-datepicker": "^8.3.0",
|
||||
"react-day-picker": "8.10.1",
|
||||
@ -84,6 +85,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.6",
|
||||
"vcard-js": "^1.2.2",
|
||||
"vcard-parser": "^1.0.0",
|
||||
"vcd-parser": "^1.0.1",
|
||||
"webdav": "^5.8.0"
|
||||
},
|
||||
@ -6591,6 +6593,12 @@
|
||||
"npm": ">=8.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-better-table": {
|
||||
"version": "1.2.10",
|
||||
"resolved": "https://registry.npmjs.org/quill-better-table/-/quill-better-table-1.2.10.tgz",
|
||||
"integrity": "sha512-CFwxAQzt4EPCQuynQ65R/FU7Yu//kcDBb/rmBBOsFfO758+q50zvG/PDt4Lenv9DcrSgwnyNkfo4yeA5fzzVYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quill-delta": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||
@ -7801,6 +7809,12 @@
|
||||
"utf8": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vcard-parser": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vcard-parser/-/vcard-parser-1.0.0.tgz",
|
||||
"integrity": "sha512-rSEjrjBK3of4VimMR5vBjLLcN5ZCSp9yuVzyx5i4Fwx74Yd0s+DnHtSit/wAAtj1a7/T/qQc0ykwXADoD0+fTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vcd-parser": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz",
|
||||
|
||||
@ -72,6 +72,7 @@
|
||||
"nodemailer": "^6.10.1",
|
||||
"pg": "^8.14.1",
|
||||
"quill": "^2.0.3",
|
||||
"quill-better-table": "^1.2.10",
|
||||
"react": "^18",
|
||||
"react-datepicker": "^8.3.0",
|
||||
"react-day-picker": "8.10.1",
|
||||
@ -85,6 +86,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.6",
|
||||
"vcard-js": "^1.2.2",
|
||||
"vcard-parser": "^1.0.0",
|
||||
"vcd-parser": "^1.0.1",
|
||||
"webdav": "^5.8.0"
|
||||
},
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@ -3031,6 +3031,11 @@ quick-format-unescaped@^4.0.3:
|
||||
resolved "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz"
|
||||
integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==
|
||||
|
||||
quill-better-table@^1.2.10:
|
||||
version "1.2.10"
|
||||
resolved "https://registry.npmjs.org/quill-better-table/-/quill-better-table-1.2.10.tgz"
|
||||
integrity sha512-CFwxAQzt4EPCQuynQ65R/FU7Yu//kcDBb/rmBBOsFfO758+q50zvG/PDt4Lenv9DcrSgwnyNkfo4yeA5fzzVYQ==
|
||||
|
||||
quill-delta@^5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz"
|
||||
@ -3742,6 +3747,11 @@ vcard-js@^1.2.2:
|
||||
quoted-printable "^1.0.0"
|
||||
utf8 "^2.1.1"
|
||||
|
||||
vcard-parser@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/vcard-parser/-/vcard-parser-1.0.0.tgz"
|
||||
integrity sha512-rSEjrjBK3of4VimMR5vBjLLcN5ZCSp9yuVzyx5i4Fwx74Yd0s+DnHtSit/wAAtj1a7/T/qQc0ykwXADoD0+fTQ==
|
||||
|
||||
vcd-parser@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user