mime change

This commit is contained in:
alma 2025-04-24 16:04:46 +02:00
parent d62b6d2a34
commit f4990623da
2 changed files with 145 additions and 345 deletions

View File

@ -40,6 +40,8 @@ import {
} from '@/lib/infomaniak-mime-decoder';
import DOMPurify from 'isomorphic-dompurify';
import ComposeEmail from '@/components/ComposeEmail';
import { decodeEmail } from '@/lib/mail-parser-wrapper';
import { Attachment as MailParserAttachment } from 'mailparser';
export interface Account {
id: number;
@ -108,188 +110,67 @@ function splitEmailHeadersAndBody(emailBody: string): { headers: string; body: s
};
}
function renderEmailContent(email: Email) {
async function renderEmailContent(email: Email) {
if (!email.body) {
console.warn('No email body provided');
return null;
}
try {
// Split email into headers and body
const [headersPart, ...bodyParts] = email.body.split('\r\n\r\n');
if (!headersPart || bodyParts.length === 0) {
throw new Error('Invalid email format: missing headers or body');
const decoded = await decodeEmail(email.body);
// Prefer HTML content if available
if (decoded.html) {
return (
<div className="email-content" dir="ltr">
<div className="prose max-w-none" dir="ltr" dangerouslySetInnerHTML={{ __html: cleanHtml(decoded.html) }} />
{decoded.attachments.length > 0 && renderAttachments(decoded.attachments)}
</div>
);
}
const body = bodyParts.join('\r\n\r\n');
// Parse headers using Infomaniak MIME decoder
const headerInfo = parseEmailHeaders(headersPart);
const boundary = extractBoundary(headersPart);
// If it's a multipart email
if (boundary) {
try {
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');
if (!partHeaders || partBodyParts.length === 0) continue;
const partBody = partBodyParts.join('\r\n\r\n');
const contentType = extractHeader(partHeaders, 'Content-Type').toLowerCase();
const encoding = extractHeader(partHeaders, 'Content-Transfer-Encoding').toLowerCase();
const charset = extractHeader(partHeaders, 'charset') || 'utf-8';
try {
let decodedContent = '';
if (encoding === 'base64') {
decodedContent = decodeBase64(partBody, charset);
} else if (encoding === 'quoted-printable') {
decodedContent = decodeQuotedPrintable(partBody, charset);
} else {
decodedContent = convertCharset(partBody, charset);
}
if (contentType.includes('text/html')) {
// For HTML content, we want to preserve the HTML structure
// Only clean up problematic elements while keeping the formatting
htmlContent = decodedContent
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<meta[^>]*>/gi, '')
.replace(/<link[^>]*>/gi, '')
.replace(/<base[^>]*>/gi, '')
.replace(/<title[^>]*>[\s\S]*?<\/title>/gi, '')
.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
.replace(/<body[^>]*>/gi, '')
.replace(/<\/body>/gi, '')
.replace(/<html[^>]*>/gi, '')
.replace(/<\/html>/gi, '');
} else if (contentType.includes('text/plain')) {
textContent = decodedContent;
} else if (contentType.includes('attachment') || extractHeader(partHeaders, 'Content-Disposition').includes('attachment')) {
attachments.push({
filename: extractFilename(partHeaders) || 'unnamed_attachment',
content: decodedContent
});
}
} catch (partError) {
console.error('Error processing email part:', partError);
continue;
}
}
// Prefer HTML content if available
if (htmlContent) {
return (
<div className="email-content" dir="ltr">
<div className="prose max-w-none" dir="ltr" dangerouslySetInnerHTML={{ __html: htmlContent }} />
{attachments.length > 0 && renderAttachments(attachments)}
</div>
);
}
// Fall back to text content
if (textContent) {
return (
<div className="email-content" dir="ltr">
<div className="whitespace-pre-wrap font-sans text-base leading-relaxed" dir="ltr">
{textContent.split('\n').map((line: string, i: number) => (
<p key={i} className="mb-2">{line}</p>
))}
</div>
{attachments.length > 0 && renderAttachments(attachments)}
</div>
);
}
} catch (multipartError) {
console.error('Error processing multipart email:', multipartError);
throw new Error('Failed to process multipart email');
}
}
// If it's a simple email, try to detect content type and decode
const contentType = extractHeader(headersPart, 'Content-Type').toLowerCase();
const encoding = extractHeader(headersPart, 'Content-Transfer-Encoding').toLowerCase();
const charset = extractHeader(headersPart, 'charset') || 'utf-8';
try {
let decodedBody = '';
if (encoding === 'base64') {
decodedBody = decodeBase64(body, charset);
} else if (encoding === 'quoted-printable') {
decodedBody = decodeQuotedPrintable(body, charset);
} else {
decodedBody = convertCharset(body, charset);
}
if (contentType.includes('text/html')) {
// For HTML content, preserve the HTML structure
const cleanedHtml = decodedBody
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<meta[^>]*>/gi, '')
.replace(/<link[^>]*>/gi, '')
.replace(/<base[^>]*>/gi, '')
.replace(/<title[^>]*>[\s\S]*?<\/title>/gi, '')
.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
.replace(/<body[^>]*>/gi, '')
.replace(/<\/body>/gi, '')
.replace(/<html[^>]*>/gi, '')
.replace(/<\/html>/gi, '');
return (
<div className="email-content" dir="ltr">
<div className="prose max-w-none" dir="ltr" dangerouslySetInnerHTML={{ __html: cleanedHtml }} />
</div>
);
} else {
return (
<div className="email-content" dir="ltr">
// Fall back to text content
if (decoded.text) {
return (
<div className="email-content" dir="ltr">
<div className="whitespace-pre-wrap font-sans text-base leading-relaxed" dir="ltr">
{decodedBody.split('\n').map((line: string, i: number) => (
{decoded.text.split('\n').map((line: string, i: number) => (
<p key={i} className="mb-2">{line}</p>
))}
</div>
</div>
);
}
} catch (decodeError) {
console.error('Error decoding email body:', decodeError);
throw new Error('Failed to decode email body');
{decoded.attachments.length > 0 && renderAttachments(decoded.attachments)}
</div>
);
}
return null;
} catch (error) {
console.error('Error rendering email content:', error);
return (
<div className="email-content">
<div className="text-red-500 mb-4">Error displaying email content: {error instanceof Error ? error.message : 'Unknown error'}</div>
<pre className="whitespace-pre-wrap text-sm bg-gray-100 p-4 rounded">
{email.body}
</pre>
<div className="email-content text-red-500">
Error rendering email content
</div>
);
}
}
// Helper function to render attachments
function renderAttachments(attachments: { filename: string; content: string }[]) {
function renderAttachments(attachments: MailParserAttachment[]) {
if (!attachments.length) return null;
return (
<div className="mt-4">
<h3 className="text-sm font-medium mb-2">Attachments:</h3>
<ul className="space-y-2">
<div className="mt-4 border-t border-gray-200 pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-2">Attachments</h3>
<div className="space-y-2">
{attachments.map((attachment, index) => (
<li key={index} className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{attachment.filename}</span>
</li>
<div key={index} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<Paperclip className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-600">{attachment.filename || 'unnamed_attachment'}</span>
<span className="text-xs text-gray-400">
{attachment.size ? `(${Math.round(attachment.size / 1024)} KB)` : ''}
</span>
</div>
))}
</ul>
</div>
</div>
);
}
@ -328,124 +209,43 @@ const initialSidebarItems = [
}
];
function getReplyBody(email: Email, type: 'reply' | 'reply-all' | 'forward' = 'reply') {
async function getReplyBody(email: Email, type: 'reply' | 'reply-all' | 'forward' = 'reply') {
if (!email.body) return '';
let content = '';
let headers = '';
let body = '';
// Split headers and body
const parts = email.body.split('\r\n\r\n');
if (parts.length > 1) {
headers = parts[0];
body = parts.slice(1).join('\r\n\r\n');
} else {
body = email.body;
}
// Parse headers
const headerInfo = parseEmailHeaders(headers);
const boundary = extractBoundary(headers);
// Handle multipart emails
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');
if (!partHeaders || partBodyParts.length === 0) continue;
const partBody = partBodyParts.join('\r\n\r\n');
const partHeaderInfo = parseEmailHeaders(partHeaders);
try {
let decodedContent = '';
if (partHeaderInfo.encoding === 'base64') {
decodedContent = decodeBase64(partBody, partHeaderInfo.charset);
} else if (partHeaderInfo.encoding === 'quoted-printable') {
decodedContent = decodeQuotedPrintable(partBody, partHeaderInfo.charset);
} else {
decodedContent = convertCharset(partBody, partHeaderInfo.charset);
}
// Preserve the original MIME structure
if (partHeaderInfo.contentType.includes('text/html')) {
content = `
<div class="email-part" data-mime-type="text/html" data-charset="${partHeaderInfo.charset}">
${decodedContent}
</div>
`;
break;
} else if (partHeaderInfo.contentType.includes('text/plain') && !content) {
content = `
<div class="email-part" data-mime-type="text/plain" data-charset="${partHeaderInfo.charset}">
<pre style="white-space: pre-wrap; font-family: inherit;">${decodedContent}</pre>
</div>
`;
}
} catch (error) {
console.error('Error decoding email part:', error);
}
}
} else {
// Handle simple email
try {
let decodedBody = '';
if (headerInfo.encoding === 'base64') {
decodedBody = decodeBase64(body, headerInfo.charset);
} else if (headerInfo.encoding === 'quoted-printable') {
decodedBody = decodeQuotedPrintable(body, headerInfo.charset);
} else {
decodedBody = convertCharset(body, headerInfo.charset);
}
content = `
<div class="email-part" data-mime-type="${headerInfo.contentType}" data-charset="${headerInfo.charset}">
${headerInfo.contentType.includes('text/html') ? decodedBody : `<pre style="white-space: pre-wrap; font-family: inherit;">${decodedBody}</pre>`}
try {
const decoded = await decodeEmail(email.body);
// Format the reply/forward content with proper structure and direction
let formattedContent = '';
if (type === 'forward') {
formattedContent = `
<div class="forwarded-message">
<p>---------- Forwarded message ---------</p>
<p>From: ${decoded.from}</p>
<p>Date: ${decoded.date.toLocaleString()}</p>
<p>Subject: ${decoded.subject}</p>
<p>To: ${decoded.to}</p>
<br>
${decoded.html || `<pre>${decoded.text}</pre>`}
</div>
`;
} else {
formattedContent = `
<div class="quoted-message">
<p>On ${decoded.date.toLocaleString()}, ${decoded.from} wrote:</p>
<blockquote>
${decoded.html || `<pre>${decoded.text}</pre>`}
</blockquote>
</div>
`;
} catch (error) {
console.error('Error decoding email body:', error);
content = body;
}
return cleanHtml(formattedContent);
} catch (error) {
console.error('Error generating reply body:', error);
return '';
}
// Clean and sanitize HTML content while preserving structure
content = cleanHtml(content);
// Format the reply/forward content with proper structure and direction
let formattedContent = '';
if (type === 'forward') {
formattedContent = `
<div class="email-container" dir="ltr">
<div class="email-header" dir="ltr">
<p class="text-sm text-gray-600 mb-2">Forwarded message from ${email.from}</p>
<p class="text-sm text-gray-600 mb-2">Date: ${new Date(email.date).toLocaleString()}</p>
<p class="text-sm text-gray-600 mb-2">Subject: ${email.subject}</p>
<p class="text-sm text-gray-600 mb-2">To: ${email.to}</p>
${email.cc ? `<p class="text-sm text-gray-600 mb-2">Cc: ${email.cc}</p>` : ''}
</div>
<div class="email-content" dir="auto">
${content}
</div>
</div>
`;
} else {
formattedContent = `
<div class="email-container" dir="ltr">
<div class="email-header" dir="ltr">
<p class="text-sm text-gray-600 mb-2">On ${new Date(email.date).toLocaleString()}, ${email.from} wrote:</p>
</div>
<div class="email-content" dir="auto">
${content}
</div>
</div>
`;
}
return formattedContent;
}
export default function CourrierPage() {
@ -1170,84 +970,13 @@ export default function CourrierPage() {
);
};
const generateEmailPreview = (email: Email): string => {
console.log('=== generateEmailPreview Debug ===');
console.log('Email ID:', email.id);
console.log('Subject:', email.subject);
console.log('Body length:', email.body.length);
console.log('First 200 chars of body:', email.body.substring(0, 200));
const generateEmailPreview = async (email: Email): Promise<string> => {
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 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) {
preview = decodeQuotedPrintable(body, headerInfo.charset);
if (headerInfo.contentType.includes('text/html')) {
preview = cleanHtml(preview);
}
}
// Clean up the preview
preview = preview
.replace(/^>+/gm, '')
.replace(/Content-Type:[^\n]+/g, '')
.replace(/Content-Transfer-Encoding:[^\n]+/g, '')
.replace(/--[a-zA-Z0-9]+(-[a-zA-Z0-9]+)?/g, '')
.replace(/boundary=[^\n]+/g, '')
.replace(/charset=[^\n]+/g, '')
.replace(/[\r\n]+/g, ' ')
.trim();
// Take first 100 characters
preview = preview.substring(0, 100);
// Try to end at a complete word
if (preview.length === 100) {
const lastSpace = preview.lastIndexOf(' ');
if (lastSpace > 80) {
preview = preview.substring(0, lastSpace);
}
preview += '...';
}
return preview;
const decoded = await decodeEmail(email.body);
return decoded.text || cleanHtml(decoded.html || '');
} catch (error) {
console.error('Error generating email preview:', error);
return email.body
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/g, ' ')
.replace(/\s+/g, ' ')
.substring(0, 100)
.trim() + '...';
return '';
}
};

View File

@ -0,0 +1,71 @@
import { simpleParser, ParsedMail, Attachment, HeaderValue, AddressObject } from 'mailparser';
export interface DecodedEmail {
html: string | false;
text: string | false;
attachments: Attachment[];
headers: Map<string, HeaderValue>;
subject: string;
from: string;
to: string;
date: Date;
}
function getAddressText(address: AddressObject | AddressObject[] | undefined): string {
if (!address) return '';
if (Array.isArray(address)) {
return address.map(addr => addr.value?.[0]?.address || '').filter(Boolean).join(', ');
}
return address.value?.[0]?.address || '';
}
export async function decodeEmail(rawEmail: string): Promise<DecodedEmail> {
try {
const parsed = await simpleParser(rawEmail);
return {
html: parsed.html || false,
text: parsed.text || false,
attachments: parsed.attachments || [],
headers: parsed.headers,
subject: parsed.subject || '',
from: getAddressText(parsed.from),
to: getAddressText(parsed.to),
date: parsed.date || new Date()
};
} catch (error) {
console.error('Error decoding email:', error);
throw new Error('Failed to decode email');
}
}
export function cleanHtml(html: string): string {
if (!html) return '';
// Detect text direction from the content
const hasRtlChars = /[\u0591-\u07FF\u200F\u202B\u202E\uFB1D-\uFDFD\uFE70-\uFEFC]/.test(html);
const defaultDir = hasRtlChars ? 'rtl' : 'ltr';
// Basic HTML cleaning while preserving structure
const cleaned = html
// Remove script and style tags
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
// Remove meta tags
.replace(/<meta[^>]*>/gi, '')
// Remove head and title
.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
.replace(/<title[^>]*>[\s\S]*?<\/title>/gi, '')
// Remove body tags
.replace(/<body[^>]*>/gi, '')
.replace(/<\/body>/gi, '')
// Remove html tags
.replace(/<html[^>]*>/gi, '')
.replace(/<\/html>/gi, '')
// Clean up whitespace
.replace(/\s+/g, ' ')
.trim();
// Wrap in a div with the detected direction
return `<div dir="${defaultDir}">${cleaned}</div>`;
}