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 ComposeEmailForm from './ComposeEmailForm';
|
||||||
import ComposeEmailFooter from './ComposeEmailFooter';
|
import ComposeEmailFooter from './ComposeEmailFooter';
|
||||||
import RichEmailEditor from './RichEmailEditor';
|
import RichEmailEditor from './RichEmailEditor';
|
||||||
|
import QuotedEmailContent from './QuotedEmailContent';
|
||||||
|
|
||||||
// Import ONLY from the centralized formatter
|
// Import ONLY from the centralized formatter
|
||||||
import {
|
import {
|
||||||
formatForwardedEmail,
|
|
||||||
formatReplyEmail,
|
formatReplyEmail,
|
||||||
formatEmailForReplyOrForward,
|
formatForwardedEmail,
|
||||||
EmailMessage as FormatterEmailMessage,
|
formatEmailAddresses,
|
||||||
sanitizeHtml
|
type EmailMessage,
|
||||||
|
type EmailAddress
|
||||||
} from '@/lib/utils/email-formatter';
|
} from '@/lib/utils/email-formatter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -39,40 +40,7 @@ import {
|
|||||||
* for consistent handling of email content and text direction.
|
* for consistent handling of email content and text direction.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Define EmailMessage interface locally instead of importing from server-only file
|
// Define interface for the legacy props
|
||||||
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
|
|
||||||
interface LegacyComposeEmailProps {
|
interface LegacyComposeEmailProps {
|
||||||
showCompose: boolean;
|
showCompose: boolean;
|
||||||
setShowCompose: (show: boolean) => void;
|
setShowCompose: (show: boolean) => void;
|
||||||
@ -103,7 +71,7 @@ interface LegacyComposeEmailProps {
|
|||||||
forwardFrom?: any | null;
|
forwardFrom?: any | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// New interface for the modern ComposeEmail component
|
// Define interface for the modern props
|
||||||
interface ComposeEmailProps {
|
interface ComposeEmailProps {
|
||||||
initialEmail?: EmailMessage | null;
|
initialEmail?: EmailMessage | null;
|
||||||
type?: 'new' | 'reply' | 'reply-all' | 'forward';
|
type?: 'new' | 'reply' | 'reply-all' | 'forward';
|
||||||
@ -122,12 +90,46 @@ interface ComposeEmailProps {
|
|||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Union type to handle both new and legacy props
|
// Union type for handling both types of props
|
||||||
type ComposeEmailAllProps = ComposeEmailProps | LegacyComposeEmailProps;
|
type ComposeEmailAllProps = ComposeEmailProps | LegacyComposeEmailProps;
|
||||||
|
|
||||||
// Type guard to check if props are legacy
|
// Type guard to check if props are legacy
|
||||||
function isLegacyProps(props: ComposeEmailAllProps): props is LegacyComposeEmailProps {
|
function isLegacyProps(
|
||||||
return 'showCompose' in props && 'setShowCompose' in props;
|
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) {
|
export default function ComposeEmail(props: ComposeEmailAllProps) {
|
||||||
@ -158,39 +160,56 @@ export default function ComposeEmail(props: ComposeEmailAllProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialEmail && type !== 'new') {
|
if (initialEmail && type !== 'new') {
|
||||||
try {
|
try {
|
||||||
const formatterEmail: FormatterEmailMessage = {
|
// Set recipients based on type
|
||||||
id: initialEmail.id,
|
if (type === 'reply' || type === 'reply-all') {
|
||||||
messageId: initialEmail.messageId,
|
// Reply goes to the original sender
|
||||||
subject: initialEmail.subject,
|
setTo(formatEmailAddresses(initialEmail.from || []));
|
||||||
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 reply-all, include all original recipients in CC
|
||||||
// For forwarding, use the dedicated formatter
|
if (type === 'reply-all') {
|
||||||
const { subject, content } = formatForwardedEmail(formatterEmail);
|
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);
|
setSubject(subject);
|
||||||
setEmailContent(content);
|
|
||||||
} else {
|
// Set an empty content with proper spacing before the quoted content
|
||||||
// For reply/reply-all, use the reply formatter
|
setEmailContent('<div><br/><br/></div>');
|
||||||
const { to, cc, subject, content } = formatReplyEmail(formatterEmail, type as 'reply' | 'reply-all');
|
|
||||||
setTo(to);
|
// Show CC field if there are CC recipients
|
||||||
if (cc) {
|
if (initialEmail.cc && initialEmail.cc.length > 0) {
|
||||||
setCc(cc);
|
|
||||||
setShowCc(true);
|
setShowCc(true);
|
||||||
}
|
}
|
||||||
setSubject(subject);
|
|
||||||
setEmailContent(content);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
else if (type === 'forward') {
|
||||||
console.error('Error formatting email for reply/forward:', err);
|
// Set subject with Fwd: prefix
|
||||||
|
const subjectBase = initialEmail.subject || '(No subject)';
|
||||||
|
const subject = subjectBase.match(/^(Fwd|FW|Forward):/i) ? subjectBase : `Fwd: ${subjectBase}`;
|
||||||
|
setSubject(subject);
|
||||||
|
|
||||||
|
// 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 (error) {
|
||||||
|
console.error('Error initializing compose form:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [initialEmail, type]);
|
}, [initialEmail, type]);
|
||||||
@ -339,10 +358,21 @@ export default function ComposeEmail(props: ComposeEmailAllProps) {
|
|||||||
onChange={setEmailContent}
|
onChange={setEmailContent}
|
||||||
minHeight="200px"
|
minHeight="200px"
|
||||||
maxHeight="none"
|
maxHeight="none"
|
||||||
|
preserveFormatting={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="border rounded-md p-3 mt-4">
|
<div className="border rounded-md p-3 mt-4">
|
||||||
@ -625,10 +655,23 @@ function LegacyAdapter({
|
|||||||
onChange={setComposeBody}
|
onChange={setComposeBody}
|
||||||
minHeight="200px"
|
minHeight="200px"
|
||||||
maxHeight="none"
|
maxHeight="none"
|
||||||
|
preserveFormatting={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="border rounded-md p-3 mt-4">
|
<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;
|
placeholder?: string;
|
||||||
minHeight?: string;
|
minHeight?: string;
|
||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
|
preserveFormatting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
|
const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
|
||||||
@ -18,6 +19,7 @@ const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
|
|||||||
placeholder = 'Write your message here...',
|
placeholder = 'Write your message here...',
|
||||||
minHeight = '200px',
|
minHeight = '200px',
|
||||||
maxHeight = 'calc(100vh - 400px)',
|
maxHeight = 'calc(100vh - 400px)',
|
||||||
|
preserveFormatting = false,
|
||||||
}) => {
|
}) => {
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
@ -32,6 +34,21 @@ const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
|
|||||||
|
|
||||||
const Quill = (await import('quill')).default;
|
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
|
// Define custom formats/modules with table support
|
||||||
const emailToolbarOptions = [
|
const emailToolbarOptions = [
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
@ -53,6 +70,15 @@ const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
|
|||||||
// Add any custom toolbar handlers here
|
// Add any custom toolbar handlers here
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'better-table': preserveFormatting ? {
|
||||||
|
operationMenu: {
|
||||||
|
items: {
|
||||||
|
unmergeCells: {
|
||||||
|
text: 'Unmerge cells'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} : false,
|
||||||
},
|
},
|
||||||
placeholder: placeholder,
|
placeholder: placeholder,
|
||||||
theme: 'snow',
|
theme: 'snow',
|
||||||
@ -64,14 +90,29 @@ const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
|
|||||||
// First, ensure we preserve the raw HTML structure
|
// First, ensure we preserve the raw HTML structure
|
||||||
const preservedContent = sanitizeHtml(initialContent);
|
const preservedContent = sanitizeHtml(initialContent);
|
||||||
|
|
||||||
// Use root's innerHTML for complete reset to avoid Quill's automatic formatting
|
// Set editor content with paste method which preserves most formatting
|
||||||
quillRef.current.root.innerHTML = '';
|
|
||||||
|
|
||||||
// Now use clipboard API to insert the content with proper Quill delta conversion
|
|
||||||
quillRef.current.clipboard.dangerouslyPasteHTML(0, preservedContent);
|
quillRef.current.clipboard.dangerouslyPasteHTML(0, preservedContent);
|
||||||
|
|
||||||
// Force update to ensure content is rendered
|
// 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();
|
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) {
|
} catch (err) {
|
||||||
console.error('Error setting initial content:', err);
|
console.error('Error setting initial content:', err);
|
||||||
// Fallback method if the above fails
|
// 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"
|
"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": {
|
"node_modules/quill-delta": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||||
@ -6832,6 +6838,12 @@
|
|||||||
"utf8": "^2.1.1"
|
"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": {
|
"node_modules/vcd-parser": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz",
|
"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",
|
"nodemailer": "^6.10.1",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
|
"quill-better-table": "^1.2.10",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-datepicker": "^8.3.0",
|
"react-datepicker": "^8.3.0",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
@ -84,6 +85,7 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.6",
|
"vaul": "^0.9.6",
|
||||||
"vcard-js": "^1.2.2",
|
"vcard-js": "^1.2.2",
|
||||||
|
"vcard-parser": "^1.0.0",
|
||||||
"vcd-parser": "^1.0.1",
|
"vcd-parser": "^1.0.1",
|
||||||
"webdav": "^5.8.0"
|
"webdav": "^5.8.0"
|
||||||
},
|
},
|
||||||
@ -6591,6 +6593,12 @@
|
|||||||
"npm": ">=8.2.3"
|
"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": {
|
"node_modules/quill-delta": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||||
@ -7801,6 +7809,12 @@
|
|||||||
"utf8": "^2.1.1"
|
"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": {
|
"node_modules/vcd-parser": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz",
|
||||||
|
|||||||
@ -72,6 +72,7 @@
|
|||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
|
"quill-better-table": "^1.2.10",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-datepicker": "^8.3.0",
|
"react-datepicker": "^8.3.0",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
@ -85,6 +86,7 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.6",
|
"vaul": "^0.9.6",
|
||||||
"vcard-js": "^1.2.2",
|
"vcard-js": "^1.2.2",
|
||||||
|
"vcard-parser": "^1.0.0",
|
||||||
"vcd-parser": "^1.0.1",
|
"vcd-parser": "^1.0.1",
|
||||||
"webdav": "^5.8.0"
|
"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"
|
resolved "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz"
|
||||||
integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==
|
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:
|
quill-delta@^5.1.0:
|
||||||
version "5.1.0"
|
version "5.1.0"
|
||||||
resolved "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz"
|
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"
|
quoted-printable "^1.0.0"
|
||||||
utf8 "^2.1.1"
|
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:
|
vcd-parser@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz"
|
resolved "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user