mail page rest

This commit is contained in:
alma 2025-04-21 12:49:30 +02:00
parent 037977b3fb
commit 0139845f18
2 changed files with 163 additions and 262 deletions

View File

@ -48,70 +48,22 @@ export async function GET() {
await client.connect();
const mailbox = await client.mailboxOpen('INBOX');
// Fetch messages with body content
// Fetch only essential message data
const messages = await client.fetch('1:20', {
envelope: true,
flags: true,
bodyStructure: true,
bodyParts: ['HEADER', 'TEXT']
flags: true
});
const result = [];
for await (const message of messages) {
// Get the body content
let body = '';
if (message.bodyParts && Array.isArray(message.bodyParts)) {
for (const [partType, content] of message.bodyParts) {
if (partType === 'text') {
// Convert buffer to string and decode if needed
const contentStr = content.toString('utf-8');
// Check if content is base64 encoded
if (contentStr.includes('Content-Transfer-Encoding: base64')) {
const base64Content = contentStr.split('\r\n\r\n')[1];
body = Buffer.from(base64Content, 'base64').toString('utf-8');
} else {
body = contentStr;
}
break;
}
}
}
// If no body found, try to get it from the message structure
if (!body && message.bodyStructure) {
try {
const fetch = await client.fetchOne(message.uid.toString(), {
bodyStructure: true,
bodyParts: ['TEXT']
});
if (fetch?.bodyParts) {
for (const [partType, content] of fetch.bodyParts) {
if (partType === 'text') {
body = content.toString('utf-8');
break;
}
}
}
} catch (error) {
console.error('Error fetching message content:', error);
}
}
result.push({
id: message.uid.toString(),
accountId: 1, // Default account ID
from: message.envelope.from?.[0]?.address || '',
fromName: message.envelope.from?.[0]?.name || message.envelope.from?.[0]?.address?.split('@')[0] || '',
to: message.envelope.to?.[0]?.address || '',
from: message.envelope.from[0].address,
subject: message.envelope.subject || '(No subject)',
body: body,
date: message.envelope.date.toISOString(),
read: message.flags.has('\\Seen'),
starred: message.flags.has('\\Flagged'),
folder: mailbox.path,
cc: message.envelope.cc?.map(addr => addr.address).join(', '),
bcc: message.envelope.bcc?.map(addr => addr.address).join(', '),
flags: Array.from(message.flags)
folder: mailbox.path
});
}

View File

@ -153,29 +153,9 @@ function processMultipartEmail(emailRaw: string, boundary: string, mainHeaders:
const partContent = emailRaw.substring(startPos, endPos).trim();
if (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;
}
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')) {
if (decoded.contentType.includes('text/plain')) {
result.text = decoded.text || '';
} else if (decoded.contentType.includes('text/html')) {
result.html = cleanHtml(decoded.html || '');
@ -243,37 +223,23 @@ function parseEmailHeaders(headers: string): { contentType: string; encoding: st
charset: 'utf-8'
};
// Extract content type and charset with better handling of quoted strings
const contentTypeMatch = headers.match(/Content-Type:\s*([^;]+)(?:;\s*([^=]+)=([^;"\r\n]+|"[^"]*"))*/gi);
// Extract content type and charset
const contentTypeMatch = headers.match(/Content-Type:\s*([^;]+)(?:;\s*charset=([^;"\r\n]+)|(?:;\s*charset="([^"]+)"))?/i);
if (contentTypeMatch) {
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();
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();
}
}
// Extract content transfer encoding with better pattern matching
// Extract content transfer encoding
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;
}
@ -289,34 +255,17 @@ function decodeMIME(text: string, encoding?: string, charset: string = 'utf-8'):
if (encoding === 'quoted-printable') {
return decodeQuotedPrintable(text, charset);
} else if (encoding === 'base64') {
// Handle line breaks in base64 content
const cleanText = text.replace(/[\r\n]/g, '');
return decodeBase64(cleanText, charset);
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, 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);
}
// Unknown encoding, return as is but still handle charset
return convertCharset(text, charset);
}
} catch (error) {
console.error('Error decoding MIME:', error);
// 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;
}
return text;
}
}
@ -328,14 +277,7 @@ function decodeBase64(text: string, charset: string): string {
binaryString = atob(cleanText);
} catch (e) {
console.error('Base64 decoding error:', e);
// 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 text;
}
return convertCharset(binaryString, charset);
@ -471,11 +413,51 @@ function decodeMimeContent(content: string): string {
// Add this helper function
const renderEmailContent = (email: Email) => {
const decodedContent = decodeMimeContent(email.body);
if (email.body.includes('Content-Type: text/html')) {
return <div dangerouslySetInnerHTML={{ __html: decodedContent }} />;
try {
const parsed = parseFullEmail(email.body);
const content = parsed.text || parsed.html || email.body;
const isHtml = parsed.html || content.includes('<');
if (isHtml) {
// 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, '');
return (
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
);
} else {
// Format plain text content
const formattedText = content
.replace(/\n/g, '<br>')
.replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;')
.replace(/ /g, '&nbsp;&nbsp;');
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>
);
}
return <div className="whitespace-pre-wrap">{decodedContent}</div>;
};
// Add this helper function
@ -528,7 +510,7 @@ const initialSidebarItems = [
}
];
export default function MailPage() {
export default function CourrierPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [accounts, setAccounts] = useState<Account[]>([
@ -642,7 +624,7 @@ export default function MailPage() {
}
// 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 || '',
@ -908,20 +890,20 @@ export default function MailPage() {
// 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-2">
<div className="px-4 py-1">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-400" />
<Search className="absolute left-2 top-2 h-4 w-4 text-gray-400" />
<Input
type="search"
placeholder="Search in folder..."
className="pl-8 h-9 bg-gray-50"
className="pl-8 h-8 bg-gray-50"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="flex items-center justify-between px-4 h-14">
<div className="flex items-center gap-3">
<div className="flex items-center justify-between px-4 h-10">
<div className="flex items-center gap-2">
<Checkbox
checked={filteredEmails.length > 0 && selectedEmails.length === filteredEmails.length}
onCheckedChange={toggleSelectAll}
@ -1086,39 +1068,7 @@ export default function MailPage() {
</div>
<div className="prose max-w-none">
{(() => {
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;
}
})()}
{renderEmailContent(selectedEmail)}
</div>
</ScrollArea>
</>
@ -1199,23 +1149,22 @@ export default function MailPage() {
</h3>
<div className="text-xs text-gray-500 truncate">
{(() => {
// Get clean preview of the actual message content
let preview = '';
try {
// First try to parse the full email
const parsed = parseFullEmail(email.body);
// Try to get content from parsed email
if (parsed.html) {
// Extract text from HTML
// Get text content from parsed email
let preview = '';
if (parsed.text) {
preview = parsed.text;
} else if (parsed.html) {
// If only HTML is available, extract text content
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
@ -1227,7 +1176,7 @@ export default function MailPage() {
.trim();
}
// Remove email artifacts and clean up
// Clean up the preview
preview = preview
.replace(/^>+/gm, '')
.replace(/Content-Type:[^\n]+/g, '')
@ -1250,12 +1199,11 @@ export default function MailPage() {
preview += '...';
}
return preview || 'No preview available';
} catch (e) {
console.error('Error generating preview:', e);
preview = '(Error generating preview)';
return 'Error loading preview';
}
return preview || 'No preview available';
})()}
</div>
</div>
@ -1687,92 +1635,93 @@ export default function MailPage() {
}
return (
<>
{/* 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);
}}
>
<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">
<div className="flex items-center gap-2">
<PlusIcon className="h-3.5 w-3.5" />
<span>Compose</span>
<Mail className="h-6 w-6 text-gray-600" />
<span className="text-xl font-semibold text-gray-900">COURRIER</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>
</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>
{/* 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>
</div>
<span className="text-xs text-gray-500 ml-4">{account.email}</span>
</div>
</Button>
</div>
))}
</div>
)}
</Button>
</div>
))}
</div>
)}
</div>
{/* Navigation */}
{renderSidebarNav()}
</div>
{/* Navigation */}
{renderSidebarNav()}
</div>
{/* Main content area */}
<div className="flex-1 flex overflow-hidden">
{/* Email list panel */}
{renderEmailListWrapper()}
{/* Main content area */}
<div className="flex-1 flex overflow-hidden">
{/* Email list panel */}
{renderEmailListWrapper()}
</div>
</div>
</div>
@ -1937,6 +1886,6 @@ export default function MailPage() {
</div>
)}
{renderDeleteConfirmDialog()}
</>
</main>
);
}
}