mail page rest

This commit is contained in:
alma 2025-04-21 12:46:15 +02:00
parent d8cab53806
commit 037977b3fb

View File

@ -153,9 +153,29 @@ function processMultipartEmail(emailRaw: string, boundary: string, mainHeaders:
const partContent = emailRaw.substring(startPos, endPos).trim();
if (partContent) {
const decoded = processSinglePartEmail(partContent);
// Check if this is a nested multipart
const nestedBoundaryMatch = partContent.match(/boundary="?([^"\r\n;]+)"?/i);
if (nestedBoundaryMatch) {
const nestedResult = processMultipartEmail(partContent, nestedBoundaryMatch[1]);
result.text = result.text || nestedResult.text;
result.html = result.html || nestedResult.html;
result.attachments.push(...nestedResult.attachments);
continue;
}
if (decoded.contentType.includes('text/plain')) {
const decoded = processSinglePartEmail(partContent);
const contentDisposition = extractHeader(partContent, 'Content-Disposition');
const isAttachment = contentDisposition.toLowerCase().includes('attachment');
if (isAttachment) {
const filename = extractFilename(partContent) || 'attachment';
result.attachments.push({
filename,
contentType: decoded.contentType,
encoding: decoded.raw?.headers ? parseEmailHeaders(decoded.raw.headers).encoding : '7bit',
content: decoded.raw?.body || ''
});
} else if (decoded.contentType.includes('text/plain')) {
result.text = decoded.text || '';
} else if (decoded.contentType.includes('text/html')) {
result.html = cleanHtml(decoded.html || '');
@ -223,23 +243,37 @@ function parseEmailHeaders(headers: string): { contentType: string; encoding: st
charset: 'utf-8'
};
// Extract content type and charset
const contentTypeMatch = headers.match(/Content-Type:\s*([^;]+)(?:;\s*charset=([^;"\r\n]+)|(?:;\s*charset="([^"]+)"))?/i);
// Extract content type and charset with better handling of quoted strings
const contentTypeMatch = headers.match(/Content-Type:\s*([^;]+)(?:;\s*([^=]+)=([^;"\r\n]+|"[^"]*"))*/gi);
if (contentTypeMatch) {
result.contentType = contentTypeMatch[1].trim().toLowerCase();
if (contentTypeMatch[2]) {
result.charset = contentTypeMatch[2].trim().toLowerCase();
} else if (contentTypeMatch[3]) {
result.charset = contentTypeMatch[3].trim().toLowerCase();
const fullContentType = contentTypeMatch[0];
const typeMatch = fullContentType.match(/Content-Type:\s*([^;]+)/i);
if (typeMatch) {
result.contentType = typeMatch[1].trim().toLowerCase();
}
// Extract charset with better handling of quoted values
const charsetMatch = fullContentType.match(/charset=([^;"\r\n]+|"[^"]*")/i);
if (charsetMatch) {
result.charset = charsetMatch[1].replace(/"/g, '').trim().toLowerCase();
}
}
// Extract content transfer encoding
// Extract content transfer encoding with better pattern matching
const encodingMatch = headers.match(/Content-Transfer-Encoding:\s*([^\s;\r\n]+)/i);
if (encodingMatch) {
result.encoding = encodingMatch[1].trim().toLowerCase();
}
// Normalize charset names
if (result.charset === 'iso-8859-1' || result.charset === 'latin1') {
result.charset = 'iso-8859-1';
} else if (result.charset === 'utf-8' || result.charset === 'utf8') {
result.charset = 'utf-8';
} else if (result.charset === 'windows-1252' || result.charset === 'cp1252') {
result.charset = 'windows-1252';
}
return result;
}
@ -255,17 +289,34 @@ function decodeMIME(text: string, encoding?: string, charset: string = 'utf-8'):
if (encoding === 'quoted-printable') {
return decodeQuotedPrintable(text, charset);
} else if (encoding === 'base64') {
return decodeBase64(text, charset);
// Handle line breaks in base64 content
const cleanText = text.replace(/[\r\n]/g, '');
return decodeBase64(cleanText, 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);
// Unknown encoding, try to detect and handle
if (text.match(/^[A-Za-z0-9+/=]+$/)) {
// Looks like base64
return decodeBase64(text, charset);
} else if (text.includes('=') && text.match(/=[A-F0-9]{2}/)) {
// Looks like quoted-printable
return decodeQuotedPrintable(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;
// Try to recover by returning the original text with charset conversion
try {
return convertCharset(text, charset);
} catch (e) {
console.error('Error in fallback charset conversion:', e);
return text;
}
}
}
@ -277,7 +328,14 @@ function decodeBase64(text: string, charset: string): string {
binaryString = atob(cleanText);
} catch (e) {
console.error('Base64 decoding error:', e);
return text;
// Try to recover by removing invalid characters
const validBase64 = cleanText.replace(/[^A-Za-z0-9+/=]/g, '');
try {
binaryString = atob(validBase64);
} catch (e2) {
console.error('Base64 recovery failed:', e2);
return text;
}
}
return convertCharset(binaryString, charset);
@ -380,154 +438,44 @@ function cleanHtml(html: string): string {
function decodeMimeContent(content: string): string {
if (!content) return '';
try {
// First, try to extract the content type and encoding
const contentTypeMatch = content.match(/Content-Type:\s*([^;\r\n]+)/i);
const encodingMatch = content.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i);
const charsetMatch = content.match(/charset="?([^"\r\n;]+)"?/i);
const contentType = contentTypeMatch ? contentTypeMatch[1].toLowerCase() : 'text/plain';
const encoding = encodingMatch ? encodingMatch[1].toLowerCase() : '7bit';
const charset = charsetMatch ? charsetMatch[1].toLowerCase() : 'utf-8';
// Check if this is an Infomaniak 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 = '';
// Handle multipart messages
if (contentType.includes('multipart/')) {
const boundaryMatch = content.match(/boundary="?([^"\r\n;]+)"?/i);
if (boundaryMatch) {
const boundary = boundaryMatch[1];
const parts = content.split('--' + boundary);
let htmlContent = '';
let textContent = '';
for (const part of parts) {
if (!part.trim()) continue;
const partContentType = part.match(/Content-Type:\s*([^;\r\n]+)/i)?.[1]?.toLowerCase() || '';
const partEncoding = part.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i)?.[1]?.toLowerCase() || '7bit';
const partCharset = part.match(/charset="?([^"\r\n;]+)"?/i)?.[1]?.toLowerCase() || 'utf-8';
// Extract the actual content (after headers)
const contentMatch = part.match(/\r?\n\r?\n([\s\S]+?)(?=\r?\n--)/);
if (!contentMatch) continue;
let partContent = contentMatch[1];
// Decode based on encoding
if (partEncoding === 'quoted-printable') {
partContent = decodeQuotedPrintable(partContent, partCharset);
} else if (partEncoding === 'base64') {
partContent = decodeBase64(partContent, partCharset);
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]);
}
if (partContentType.includes('text/html')) {
htmlContent = partContent;
} else if (partContentType.includes('text/plain')) {
textContent = partContent;
} 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 || content;
}
}
});
// Handle single part messages
let decodedContent = content;
// Find the actual content (after headers)
const contentMatch = content.match(/\r?\n\r?\n([\s\S]+)/);
if (contentMatch) {
decodedContent = contentMatch[1];
// Prefer HTML content if available
return htmlContent || textContent;
}
// Decode based on encoding
if (encoding === 'quoted-printable') {
decodedContent = decodeQuotedPrintable(decodedContent, charset);
} else if (encoding === 'base64') {
decodedContent = decodeBase64(decodedContent, charset);
}
// Clean up the content
return cleanHtml(decodedContent);
} catch (e) {
console.error('Error decoding MIME content:', e);
return content;
}
// If not multipart or no boundary found, clean the content directly
return cleanHtml(content);
}
// Add this helper function
const renderEmailContent = (email: Email) => {
try {
// First try to decode the MIME content
const decodedContent = decodeMimeContent(email.body);
// Check if the content is HTML
const isHtml = decodedContent.includes('<') &&
(decodedContent.includes('<html') ||
decodedContent.includes('<body') ||
decodedContent.includes('<div'));
if (isHtml) {
// Extract the body content if it exists
const bodyMatch = decodedContent.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
const content = bodyMatch ? bodyMatch[1] : decodedContent;
// Sanitize HTML content
const sanitizedHtml = content
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
.replace(/on\w+="[^"]*"/g, '')
.replace(/on\w+='[^']*'/g, '')
.replace(/javascript:/gi, '')
.replace(/data:/gi, '')
.replace(/<meta[^>]*>/gi, '')
.replace(/<link[^>]*>/gi, '')
// Fix common encoding issues
.replace(/=C2=A0/g, ' ')
.replace(/=E2=80=93/g, '\u2013')
.replace(/=E2=80=94/g, '\u2014')
.replace(/=E2=80=98/g, '\u2018')
.replace(/=E2=80=99/g, '\u2019')
.replace(/=E2=80=9C/g, '\u201C')
.replace(/=E2=80=9D/g, '\u201D');
return (
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
);
} else {
// Format plain text content
const formattedText = decodedContent
.replace(/\n/g, '<br>')
.replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;')
.replace(/ /g, '&nbsp;&nbsp;')
// Fix common encoding issues
.replace(/=C2=A0/g, ' ')
.replace(/=E2=80=93/g, '\u2013')
.replace(/=E2=80=94/g, '\u2014')
.replace(/=E2=80=98/g, '\u2018')
.replace(/=E2=80=99/g, '\u2019')
.replace(/=E2=80=9C/g, '\u201C')
.replace(/=E2=80=9D/g, '\u201D');
return (
<div
className="prose prose-sm max-w-none whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: formattedText }}
/>
);
}
} catch (e) {
console.error('Error rendering email content:', e);
return (
<div className="text-sm text-gray-500">
Error rendering email content. Please try refreshing the page.
</div>
);
const decodedContent = decodeMimeContent(email.body);
if (email.body.includes('Content-Type: text/html')) {
return <div dangerouslySetInnerHTML={{ __html: decodedContent }} />;
}
return <div className="whitespace-pre-wrap">{decodedContent}</div>;
};
// Add this helper function
@ -580,7 +528,7 @@ const initialSidebarItems = [
}
];
export default function CourrierPage() {
export default function MailPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [accounts, setAccounts] = useState<Account[]>([
@ -694,7 +642,7 @@ export default function CourrierPage() {
}
// Process emails keeping exact folder names
const processedEmails = (data.emails || []).map((email: any) => ({
const processedEmails = data.emails.map((email: any) => ({
id: Number(email.id),
accountId: 1,
from: email.from || '',
@ -960,20 +908,20 @@ export default function CourrierPage() {
// Update the email count in the header to show filtered count
const renderEmailListHeader = () => (
<div className="border-b border-gray-100">
<div className="px-4 py-1">
<div className="px-4 py-2">
<div className="relative">
<Search className="absolute left-2 top-2 h-4 w-4 text-gray-400" />
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-400" />
<Input
type="search"
placeholder="Search in folder..."
className="pl-8 h-8 bg-gray-50"
className="pl-8 h-9 bg-gray-50"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="flex items-center justify-between px-4 h-10">
<div className="flex items-center gap-2">
<div className="flex items-center justify-between px-4 h-14">
<div className="flex items-center gap-3">
<Checkbox
checked={filteredEmails.length > 0 && selectedEmails.length === filteredEmails.length}
onCheckedChange={toggleSelectAll}
@ -1138,7 +1086,39 @@ export default function CourrierPage() {
</div>
<div className="prose max-w-none">
{renderEmailContent(selectedEmail)}
{(() => {
try {
const parsed = parseFullEmail(selectedEmail.body);
return (
<div>
{/* Display HTML content if available, otherwise fallback to text */}
<div dangerouslySetInnerHTML={{
__html: parsed.html || parsed.text || decodeMimeContent(selectedEmail.body)
}} />
{/* Display attachments if present */}
{parsed.attachments && parsed.attachments.length > 0 && (
<div className="mt-6 border-t border-gray-200 pt-6">
<h3 className="text-sm font-semibold text-gray-900 mb-4">Attachments</h3>
<div className="grid grid-cols-2 gap-4">
{parsed.attachments.map((attachment, index) => (
<div key={index} className="flex items-center space-x-2 p-2 border rounded">
<Paperclip className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-600 truncate">
{attachment.filename}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
} catch (e) {
console.error('Error parsing email:', e);
return selectedEmail.body;
}
})()}
</div>
</ScrollArea>
</>
@ -1219,32 +1199,43 @@ export default function CourrierPage() {
</h3>
<div className="text-xs text-gray-500 truncate">
{(() => {
// Get clean preview of the actual message content
let preview = '';
try {
// First decode the MIME content
const decodedContent = decodeMimeContent(email.body);
const parsed = parseFullEmail(email.body);
// Extract preview text
let preview = decodedContent
// Remove HTML tags
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, ' ')
// Remove email headers
.replace(/^(From|To|Sent|Subject|Date|Cc|Bcc):.*$/gim, '')
// Remove quoted text
.replace(/^>.*$/gm, '')
// Remove multiple spaces
.replace(/\s+/g, ' ')
// Remove special characters
.replace(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/g, ' ')
// Fix common encoding issues
.replace(/=C2=A0/g, ' ')
.replace(/=E2=80=93/g, '\u2013')
.replace(/=E2=80=94/g, '\u2014')
.replace(/=E2=80=98/g, '\u2018')
.replace(/=E2=80=99/g, '\u2019')
.replace(/=E2=80=9C/g, '\u201C')
.replace(/=E2=80=9D/g, '\u201D')
// Try to get content from parsed email
if (parsed.html) {
// Extract text from HTML
preview = parsed.html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
} else if (parsed.text) {
preview = parsed.text;
}
// If no preview from parsed content, try direct body
if (!preview) {
preview = email.body
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Remove email artifacts and clean up
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
@ -1259,11 +1250,12 @@ export default function CourrierPage() {
preview += '...';
}
return preview || 'No preview available';
} catch (e) {
console.error('Error generating preview:', e);
return 'Error loading preview';
preview = '(Error generating preview)';
}
return preview || 'No preview available';
})()}
</div>
</div>
@ -1695,93 +1687,92 @@ export default function CourrierPage() {
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<div className="flex h-full bg-background text-gray-900 overflow-hidden">
{/* Sidebar */}
<div className={`${sidebarOpen ? 'w-60' : 'w-16'} bg-white/95 backdrop-blur-sm border-r border-gray-100 flex flex-col transition-all duration-300 ease-in-out
${mobileSidebarOpen ? 'fixed inset-y-0 left-0 z-40' : 'hidden'} md:block`}>
{/* Courrier Title */}
<div className="p-3 border-b border-gray-100">
<>
{/* Main layout */}
<div className="flex h-[calc(100vh-theme(spacing.12))] bg-gray-50 text-gray-900 overflow-hidden mt-12">
{/* Sidebar */}
<div className={`${sidebarOpen ? 'w-60' : 'w-16'} bg-white/95 backdrop-blur-sm border-r border-gray-100 flex flex-col transition-all duration-300 ease-in-out
${mobileSidebarOpen ? 'fixed inset-y-0 left-0 z-40' : 'hidden'} md:block`}>
{/* Courrier Title */}
<div className="p-3 border-b border-gray-100">
<div className="flex items-center gap-2">
<Mail className="h-6 w-6 text-gray-600" />
<span className="text-xl font-semibold text-gray-900">COURRIER</span>
</div>
</div>
{/* Compose button and refresh button */}
<div className="p-2 border-b border-gray-100 flex items-center gap-2">
<Button
className="flex-1 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center transition-all py-1.5 text-sm"
onClick={() => {
setShowCompose(true);
setComposeTo('');
setComposeCc('');
setComposeBcc('');
setComposeSubject('');
setComposeBody('');
setShowCc(false);
setShowBcc(false);
}}
>
<div className="flex items-center gap-2">
<Mail className="h-6 w-6 text-gray-600" />
<span className="text-xl font-semibold text-gray-900">COURRIER</span>
<PlusIcon className="h-3.5 w-3.5" />
<span>Compose</span>
</div>
</div>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleMailboxChange('INBOX')}
className="text-gray-600 hover:text-gray-900 hover:bg-gray-100"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* Compose button and refresh button */}
<div className="p-2 border-b border-gray-100 flex items-center gap-2">
<Button
className="flex-1 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center transition-all py-1.5 text-sm"
onClick={() => {
setShowCompose(true);
setComposeTo('');
setComposeCc('');
setComposeBcc('');
setComposeSubject('');
setComposeBody('');
setShowCc(false);
setShowBcc(false);
}}
>
<div className="flex items-center gap-2">
<PlusIcon className="h-3.5 w-3.5" />
<span>Compose</span>
</div>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleMailboxChange('INBOX')}
className="text-gray-600 hover:text-gray-900 hover:bg-gray-100"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* Accounts Section */}
<div className="p-3 border-b border-gray-100">
<Button
variant="ghost"
className="w-full justify-between mb-2 text-sm font-medium text-gray-500"
onClick={() => setAccountsDropdownOpen(!accountsDropdownOpen)}
>
<span>Accounts</span>
{accountsDropdownOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
{accountsDropdownOpen && (
<div className="space-y-1 pl-2">
{accounts.map(account => (
<div key={account.id} className="relative group">
<Button
variant="ghost"
className="w-full justify-between px-2 py-1.5 text-sm group"
onClick={() => setSelectedAccount(account)}
>
<div className="flex flex-col items-start">
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${account.color}`}></div>
<span className="font-medium">{account.name}</span>
</div>
<span className="text-xs text-gray-500 ml-4">{account.email}</span>
{/* Accounts Section */}
<div className="p-3 border-b border-gray-100">
<Button
variant="ghost"
className="w-full justify-between mb-2 text-sm font-medium text-gray-500"
onClick={() => setAccountsDropdownOpen(!accountsDropdownOpen)}
>
<span>Accounts</span>
{accountsDropdownOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
{accountsDropdownOpen && (
<div className="space-y-1 pl-2">
{accounts.map(account => (
<div key={account.id} className="relative group">
<Button
variant="ghost"
className="w-full justify-between px-2 py-1.5 text-sm group"
onClick={() => setSelectedAccount(account)}
>
<div className="flex flex-col items-start">
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${account.color}`}></div>
<span className="font-medium">{account.name}</span>
</div>
</Button>
</div>
))}
</div>
)}
</div>
{/* Navigation */}
{renderSidebarNav()}
<span className="text-xs text-gray-500 ml-4">{account.email}</span>
</div>
</Button>
</div>
))}
</div>
)}
</div>
{/* Main content area */}
<div className="flex-1 flex overflow-hidden">
{/* Email list panel */}
{renderEmailListWrapper()}
</div>
{/* Navigation */}
{renderSidebarNav()}
</div>
{/* Main content area */}
<div className="flex-1 flex overflow-hidden">
{/* Email list panel */}
{renderEmailListWrapper()}
</div>
</div>
@ -1946,6 +1937,6 @@ export default function CourrierPage() {
</div>
)}
{renderDeleteConfirmDialog()}
</main>
</>
);
}
}