diff --git a/app/courrier/page.tsx b/app/courrier/page.tsx index 12bac525..8a33f9cc 100644 --- a/app/courrier/page.tsx +++ b/app/courrier/page.tsx @@ -97,242 +97,101 @@ interface ParsedEmailMetadata { }; } -function parseFullEmail(emailContent: string): ParsedEmailContent { - if (!emailContent) return { headers: '', body: '' }; - - // Split headers and body - const headerEnd = emailContent.indexOf('\r\n\r\n'); - if (headerEnd === -1) return { headers: '', body: emailContent }; - - const headers = emailContent.substring(0, headerEnd); - const body = emailContent.substring(headerEnd + 4); - - // Parse headers - const headerInfo = parseEmailHeaders(headers); - const boundary = extractBoundary(headers); - - // Initialize result object - const result: ParsedEmailContent = { - headers, - body: '', - html: undefined, - text: undefined, - attachments: [] - }; - - // Handle multipart content - if (boundary && headerInfo.contentType.startsWith('multipart/')) { - const parts = body.split(`--${boundary}`); - parts - .filter(part => part.trim() && !part.includes('--')) - .forEach(part => { - const partHeaderEnd = part.indexOf('\r\n\r\n'); - if (partHeaderEnd === -1) return; - - const partHeaders = part.substring(0, partHeaderEnd); - const partBody = part.substring(partHeaderEnd + 4); - const partInfo = parseEmailHeaders(partHeaders); - - let decodedContent = partBody; - if (partInfo.encoding === 'quoted-printable') { - decodedContent = decodeQuotedPrintable(partBody, partInfo.charset); - } else if (partInfo.encoding === 'base64') { - decodedContent = decodeBase64(partBody, partInfo.charset); - } - - // Handle different content types - if (partInfo.contentType.includes('text/html')) { - result.html = cleanHtml(decodedContent); - } else if (partInfo.contentType.includes('text/plain')) { - result.text = decodedContent; - } else if (partInfo.contentType.includes('application/') || partInfo.contentType.includes('image/')) { - // Handle attachments - const filename = extractFilename(partHeaders) || `attachment-${Date.now()}`; - result.attachments?.push({ - filename, - content: decodedContent, - contentType: partInfo.contentType - }); - } - }); - - // Set the body to the text content if available, otherwise use HTML - result.body = result.text || result.html || ''; - } else { - // Handle single part content - let decodedBody = body; - if (headerInfo.encoding === 'quoted-printable') { - decodedBody = decodeQuotedPrintable(body, headerInfo.charset); - } else if (headerInfo.encoding === 'base64') { - decodedBody = decodeBase64(body, headerInfo.charset); - } - - if (headerInfo.contentType.includes('text/html')) { - result.html = cleanHtml(decodedBody); - result.body = result.html; - } else if (headerInfo.contentType.includes('text/plain')) { - result.text = decodedBody; - result.body = result.text; - } else { - result.body = decodedBody; - } - } - - return result; -} - -function extractTextFromHtml(html: string): string { - // Remove scripts and style tags - html = html.replace(/]*>[\s\S]*?<\/script>/gi, '') - .replace(/]*>[\s\S]*?<\/style>/gi, ''); - - // Convert
and

to newlines - html = html.replace(/]*>/gi, '\n') - .replace(/]*>/gi, '\n') - .replace(/<\/p>/gi, '\n'); - - // Remove all other HTML tags - html = html.replace(/<[^>]+>/g, ''); - - // Decode HTML entities - html = html.replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"'); - - // Clean up whitespace - return html.replace(/\n\s*\n/g, '\n\n').trim(); -} - -function decodeMIME(text: string, encoding?: string, charset: string = 'utf-8'): string { - if (!text) return ''; - - // Normalize encoding and charset - 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 if (encoding === '7bit' || encoding === '8bit' || encoding === 'binary') { - // For these encodings, we still need to handle the character set - return convertCharset(text, charset); - } else { - // Unknown encoding, return as is but still handle charset - return convertCharset(text, charset); - } - } catch (error) { - console.error('Error decoding MIME:', error); - return text; - } -} - -function extractHtmlBody(html: string): string { - const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); - return bodyMatch ? bodyMatch[1] : html; -} - -function decodeMimeContent(content: string): string { - if (!content) return ''; - - // Check if this is a multipart message - if (content.includes('Content-Type: multipart/')) { - const boundary = content.match(/boundary="([^"]+)"/)?.[1]; - if (boundary) { - const parts = content.split('--' + boundary); - let htmlContent = ''; - let textContent = ''; - - parts.forEach(part => { - if (part.includes('Content-Type: text/html')) { - const match = part.match(/\r?\n\r?\n([\s\S]+?)(?=\r?\n--)/); - if (match) { - htmlContent = cleanHtml(match[1]); - } - } else if (part.includes('Content-Type: text/plain')) { - const match = part.match(/\r?\n\r?\n([\s\S]+?)(?=\r?\n--)/); - if (match) { - textContent = cleanHtml(match[1]); - } - } - }); - - // Prefer HTML content if available - return htmlContent || textContent; - } - } - - // If not multipart or no boundary found, clean the content directly - return cleanHtml(content); -} - function renderEmailContent(email: Email) { if (!email.body) return null; try { - // Parse the email content using our MIME decoder - const parsed = parseFullEmail(email.body); + // Split email into headers and body + const [headersPart, ...bodyParts] = email.body.split('\r\n\r\n'); + const body = bodyParts.join('\r\n\r\n'); - // If we have HTML content, render it - if (parsed.html) { - return ( -

-
- {parsed.attachments && parsed.attachments.length > 0 && ( -
-

Attachments:

-
    - {parsed.attachments.map((attachment, index) => ( -
  • - - {attachment.filename} -
  • - ))} -
-
- )} -
- ); - } + // Parse headers using our MIME decoder + const headerInfo = parseEmailHeaders(headersPart); + const boundary = extractBoundary(headersPart); - // If we have text content, render it - if (parsed.text) { - return ( -
-
- {parsed.text.split('\n').map((line, i) => ( -

{line}

- ))} + // If it's a multipart email + if (boundary) { + const parts = body.split(`--${boundary}`); + let htmlContent = ''; + let textContent = ''; + let attachments: { filename: string; content: string }[] = []; + + for (const part of parts) { + if (!part.trim()) continue; + + const [partHeaders, ...partBodyParts] = part.split('\r\n\r\n'); + const partBody = partBodyParts.join('\r\n\r\n'); + const partHeaderInfo = parseEmailHeaders(partHeaders); + + if (partHeaderInfo.contentType.includes('text/html')) { + htmlContent = decodeQuotedPrintable(partBody, partHeaderInfo.charset); + } else if (partHeaderInfo.contentType.includes('text/plain')) { + textContent = decodeQuotedPrintable(partBody, partHeaderInfo.charset); + } else if (partHeaderInfo.contentType.includes('attachment')) { + attachments.push({ + filename: extractFilename(partHeaders), + content: decodeBase64(partBody, partHeaderInfo.charset) + }); + } + } + + // Prefer HTML content if available + if (htmlContent) { + return ( +
+
+ {attachments.length > 0 && ( +
+

Attachments:

+
    + {attachments.map((attachment, index) => ( +
  • + + {attachment.filename} +
  • + ))} +
+
+ )}
- {parsed.attachments && parsed.attachments.length > 0 && ( -
-

Attachments:

-
    - {parsed.attachments.map((attachment, index) => ( -
  • - - {attachment.filename} -
  • - ))} -
+ ); + } + + // Fall back to text content + if (textContent) { + return ( +
+
+ {textContent.split('\n').map((line: string, i: number) => ( +

{line}

+ ))}
- )} -
- ); + {attachments.length > 0 && ( +
+

Attachments:

+
    + {attachments.map((attachment, index) => ( +
  • + + {attachment.filename} +
  • + ))} +
+
+ )} +
+ ); + } } - // If we couldn't parse the content, try to decode and clean the raw body - const decodedBody = decodeMIME(email.body, 'quoted-printable', 'utf-8'); + // If it's a simple email, try to decode it + const decodedBody = decodeQuotedPrintable(body, headerInfo.charset); const cleanedContent = cleanHtml(decodedBody); return (
- {cleanedContent.split('\n').map((line, i) => ( + {cleanedContent.split('\n').map((line: string, i: number) => (

{line}

))}
@@ -382,6 +241,87 @@ const initialSidebarItems = [ } ]; +function getReplyBody(email: Email | null, type: 'reply' | 'replyAll' | 'forward'): string { + if (!email?.body) return ''; + + try { + // Split email into headers and body + const [headersPart, ...bodyParts] = email.body.split('\r\n\r\n'); + const body = bodyParts.join('\r\n\r\n'); + + // Parse headers using our MIME decoder + const headerInfo = parseEmailHeaders(headersPart); + const boundary = extractBoundary(headersPart); + + let content = ''; + + // If it's a multipart email + if (boundary) { + const parts = body.split(`--${boundary}`); + + for (const part of parts) { + if (!part.trim()) continue; + + const [partHeaders, ...partBodyParts] = part.split('\r\n\r\n'); + const partBody = partBodyParts.join('\r\n\r\n'); + const partHeaderInfo = parseEmailHeaders(partHeaders); + + if (partHeaderInfo.contentType.includes('text/plain')) { + content = decodeQuotedPrintable(partBody, partHeaderInfo.charset); + break; + } else if (partHeaderInfo.contentType.includes('text/html') && !content) { + content = cleanHtml(decodeQuotedPrintable(partBody, partHeaderInfo.charset)); + } + } + } + + // If no content found or not multipart, try to decode the whole body + if (!content) { + content = decodeQuotedPrintable(body, headerInfo.charset); + if (headerInfo.contentType.includes('text/html')) { + content = cleanHtml(content); + } + } + + // Format the reply + const date = new Date(email.date); + const formattedDate = date.toLocaleString('en-GB', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + + let replyHeader = ''; + if (type === 'forward') { + replyHeader = `\n\n---------- Forwarded message ----------\n`; + replyHeader += `From: ${email.from}\n`; + replyHeader += `Date: ${formattedDate}\n`; + replyHeader += `Subject: ${email.subject}\n`; + replyHeader += `To: ${email.to}\n`; + if (email.cc) { + replyHeader += `Cc: ${email.cc}\n`; + } + replyHeader += `\n`; + } else { + replyHeader = `\n\nOn ${formattedDate}, ${email.from} wrote:\n`; + } + + // Add reply prefix to each line + const prefixedContent = content + .split('\n') + .map(line => `> ${line}`) + .join('\n'); + + return replyHeader + prefixedContent; + } catch (error) { + console.error('Error getting reply body:', error); + return email.body; + } +} + export default function CourrierPage() { const router = useRouter(); const { data: session } = useSession(); @@ -1121,39 +1061,44 @@ export default function CourrierPage() { console.log('First 200 chars of body:', email.body.substring(0, 200)); try { - const parsed = parseFullEmail(email.body); - console.log('Parsed content:', { - hasText: !!parsed.body, - hasHtml: !!parsed.headers, - textPreview: parsed.body?.substring(0, 100) || 'No text', - htmlPreview: parsed.headers?.substring(0, 100) || 'No HTML' - }); - + // Split email into headers and body + const [headersPart, ...bodyParts] = email.body.split('\r\n\r\n'); + const body = bodyParts.join('\r\n\r\n'); + + // Parse headers using our MIME decoder + const headerInfo = parseEmailHeaders(headersPart); + const boundary = extractBoundary(headersPart); + let preview = ''; - if (parsed.body) { - preview = parsed.body; - console.log('Using text content for preview'); - } else if (parsed.headers) { - preview = parsed.headers - .replace(/]*>[\s\S]*?<\/style>/gi, '') - .replace(/]*>[\s\S]*?<\/script>/gi, '') - .replace(/<[^>]+>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - console.log('Using HTML content for preview'); + + // If it's a multipart email + if (boundary) { + const parts = body.split(`--${boundary}`); + + for (const part of parts) { + if (!part.trim()) continue; + + const [partHeaders, ...partBodyParts] = part.split('\r\n\r\n'); + const partBody = partBodyParts.join('\r\n\r\n'); + const partHeaderInfo = parseEmailHeaders(partHeaders); + + if (partHeaderInfo.contentType.includes('text/plain')) { + preview = decodeQuotedPrintable(partBody, partHeaderInfo.charset); + break; + } else if (partHeaderInfo.contentType.includes('text/html') && !preview) { + preview = cleanHtml(decodeQuotedPrintable(partBody, partHeaderInfo.charset)); + } + } } + // If no preview from multipart, try to decode the whole body if (!preview) { - console.log('No preview from parsed content, using raw body'); - preview = email.body - .replace(/<[^>]+>/g, ' ') - .replace(/ |‌|»|«|>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); + preview = decodeQuotedPrintable(body, headerInfo.charset); + if (headerInfo.contentType.includes('text/html')) { + preview = cleanHtml(preview); + } } - console.log('Final preview before cleaning:', preview.substring(0, 100) + '...'); - // Clean up the preview preview = preview .replace(/^>+/gm, '') @@ -1177,12 +1122,15 @@ export default function CourrierPage() { preview += '...'; } - console.log('Final preview:', preview); return preview; - - } catch (e) { - console.error('Error generating preview:', e); - return 'No preview available'; + } catch (error) { + console.error('Error generating email preview:', error); + return email.body + .replace(/<[^>]+>/g, ' ') + .replace(/ |‌|»|«|>/g, ' ') + .replace(/\s+/g, ' ') + .substring(0, 100) + .trim() + '...'; } }; @@ -1346,88 +1294,24 @@ export default function CourrierPage() { // Add handleReply function const handleReply = async (type: 'reply' | 'replyAll' | 'forward') => { - // First, ensure we have the full email content - if (!selectedEmail) { - setError('No email selected'); - return; - } + if (!selectedEmail) return; - if (!selectedEmail.body) { - try { - // Fetch the full email content - const response = await fetch(`/api/mail/${selectedEmail.id}`); - if (!response.ok) { - throw new Error('Failed to fetch email content'); - } - const emailData = await response.json(); - - // Update the selected email with the full content - setSelectedEmail(prev => { - if (!prev) return null; - return { - ...prev, - body: emailData.body, - to: emailData.to, - cc: emailData.cc, - bcc: emailData.bcc - }; - }); - } catch (error) { - console.error('Error fetching email content:', error); - setError('Failed to load email content. Please try again.'); - return; - } - } - - // Helper functions for reply composition - const getReplySubject = (): string => { - if (!selectedEmail) return ''; - const prefix = type === 'forward' ? 'Fwd:' : 'Re:'; - return `${prefix} ${selectedEmail.subject}`; - }; - - const getReplyTo = (): string => { - if (!selectedEmail) return ''; + const getReplyTo = () => { if (type === 'forward') return ''; return selectedEmail.from; }; - const getReplyCc = (): string => { - if (!selectedEmail) return ''; - if (type === 'forward' || type === 'reply') return ''; + const getReplyCc = () => { + if (type !== 'replyAll') return ''; return selectedEmail.cc || ''; }; - const getReplyBody = () => { - if (!selectedEmail?.body) return ''; - - const parsed = parseFullEmail(selectedEmail.body); - if (!parsed) return ''; - - const body = parsed.body; - - // Convert HTML to plain text if needed - const plainText = body - .replace(//gi, '\n') - .replace(/]*>/gi, '\n') - .replace(/<\/div>/gi, '') - .replace(/]*>/gi, '\n') - .replace(/<\/p>/gi, '') - .replace(/ /g, ' ') - .replace(/>/g, '>') - .replace(/</g, '<') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/<[^>]+>/g, '') - .replace(/^\s+$/gm, '') - .replace(/\n{3,}/g, '\n\n') - .trim(); - - // Add reply prefix to each line - return plainText - .split('\n') - .map(line => `> ${line}`) - .join('\n'); + const getReplySubject = () => { + const subject = selectedEmail.subject || ''; + if (type === 'forward') { + return subject.startsWith('Fwd:') ? subject : `Fwd: ${subject}`; + } + return subject.startsWith('Re:') ? subject : `Re: ${subject}`; }; // Prepare the reply email @@ -1435,7 +1319,7 @@ export default function CourrierPage() { to: getReplyTo(), cc: getReplyCc(), subject: getReplySubject(), - body: getReplyBody() + body: getReplyBody(selectedEmail, type) }; // Update the compose form with the reply content