mail page rest

This commit is contained in:
alma 2025-04-21 15:51:08 +02:00
parent dc081b1b6d
commit eb8e2c96cf

View File

@ -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(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// Convert <br> and <p> to newlines
html = html.replace(/<br[^>]*>/gi, '\n')
.replace(/<p[^>]*>/gi, '\n')
.replace(/<\/p>/gi, '\n');
// Remove all other HTML tags
html = html.replace(/<[^>]+>/g, '');
// Decode HTML entities
html = html.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/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(/<body[^>]*>([\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 (
<div className="email-content">
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: parsed.html }} />
{parsed.attachments && parsed.attachments.length > 0 && (
<div className="mt-4">
<h3 className="text-sm font-medium mb-2">Attachments:</h3>
<ul className="space-y-2">
{parsed.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>
))}
</ul>
</div>
)}
</div>
);
}
// 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 (
<div className="email-content">
<div className="whitespace-pre-wrap font-sans text-base leading-relaxed">
{parsed.text.split('\n').map((line, i) => (
<p key={i} className="mb-2">{line}</p>
))}
// 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 (
<div className="email-content">
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: cleanHtml(htmlContent) }} />
{attachments.length > 0 && (
<div className="mt-4">
<h3 className="text-sm font-medium mb-2">Attachments:</h3>
<ul 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>
))}
</ul>
</div>
)}
</div>
{parsed.attachments && parsed.attachments.length > 0 && (
<div className="mt-4">
<h3 className="text-sm font-medium mb-2">Attachments:</h3>
<ul className="space-y-2">
{parsed.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>
))}
</ul>
);
}
// Fall back to text content
if (textContent) {
return (
<div className="email-content">
<div className="whitespace-pre-wrap font-sans text-base leading-relaxed">
{textContent.split('\n').map((line: string, i: number) => (
<p key={i} className="mb-2">{line}</p>
))}
</div>
)}
</div>
);
{attachments.length > 0 && (
<div className="mt-4">
<h3 className="text-sm font-medium mb-2">Attachments:</h3>
<ul 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>
))}
</ul>
</div>
)}
</div>
);
}
}
// 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 (
<div className="email-content">
<div className="whitespace-pre-wrap font-sans text-base leading-relaxed">
{cleanedContent.split('\n').map((line, i) => (
{cleanedContent.split('\n').map((line: string, i: number) => (
<p key={i} className="mb-2">{line}</p>
))}
</div>
@ -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(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\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(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/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(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/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(/<br\s*\/?>/gi, '\n')
.replace(/<div[^>]*>/gi, '\n')
.replace(/<\/div>/gi, '')
.replace(/<p[^>]*>/gi, '\n')
.replace(/<\/p>/gi, '')
.replace(/&nbsp;/g, ' ')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&')
.replace(/&quot;/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