courrier refactor rebuild 2

This commit is contained in:
alma 2025-04-27 11:03:34 +02:00
parent 51a92f27dd
commit 4ef9268b86
10 changed files with 816 additions and 76 deletions

View File

@ -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">

View 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;

View 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} &lt;{sender.email}&gt;</div>
<div><b>Date:</b> {formattedDate}</div>
<div><b>Subject:</b> {/* Subject would be passed as a prop if needed */}</div>
<div><b>To:</b> {/* Recipients would be passed as a prop if needed */}</div>
</div>
);
}
};
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;

View File

@ -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
View File

@ -0,0 +1,2 @@
// Global type declarations
declare module 'quill-better-table';

View 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
View File

@ -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
View File

@ -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",

View File

@ -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"
},

View File

@ -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"