From 522683b5996f39c713fb6b415f62a5c8f8bdb326 Mon Sep 17 00:00:00 2001 From: alma Date: Fri, 25 Apr 2025 20:32:29 +0200 Subject: [PATCH] panel 2 courier api restore --- app/api/courrier/[id]/route.ts | 244 +++++++++++++++++++++++---------- app/api/courrier/route.ts | 36 +++-- app/courrier/page.tsx | 164 +++++++++++++++------- 3 files changed, 310 insertions(+), 134 deletions(-) diff --git a/app/api/courrier/[id]/route.ts b/app/api/courrier/[id]/route.ts index 8b2e6301..449e4391 100644 --- a/app/api/courrier/[id]/route.ts +++ b/app/api/courrier/[id]/route.ts @@ -11,6 +11,7 @@ import { ImapFlow } from 'imapflow'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { prisma } from '@/lib/prisma'; +import { simpleParser } from 'mailparser'; // Simple in-memory cache for email content const emailContentCache = new Map(); @@ -20,108 +21,207 @@ export async function GET( { params }: { params: { id: string } } ) { try { - const { id } = await Promise.resolve(params); - - // Authentication check const session = await getServerSession(authOptions); - if (!session?.user?.id) { + if (!session || !session.user?.id) { return NextResponse.json( - { error: 'Unauthorized' }, + { error: "Not authenticated" }, { status: 401 } ); } - // Check cache first - const cacheKey = `email:${session.user.id}:${id}`; - if (emailContentCache.has(cacheKey)) { - return NextResponse.json(emailContentCache.get(cacheKey)); + const { id } = params; + if (!id) { + return NextResponse.json( + { error: "Missing email ID" }, + { status: 400 } + ); } - // Get credentials from database + // Get mail credentials const credentials = await prisma.mailCredentials.findUnique({ where: { - userId: session.user.id - } + userId: session.user.id, + }, }); if (!credentials) { return NextResponse.json( - { error: 'No mail credentials found. Please configure your email account.' }, - { status: 401 } + { error: "No mail credentials found" }, + { status: 404 } ); } + const { searchParams } = new URL(request.url); + const folder = searchParams.get("folder") || "INBOX"; + // Create IMAP client - const client = new ImapFlow({ - host: credentials.host, - port: credentials.port, - secure: true, - auth: { - user: credentials.email, - pass: credentials.password, - }, - logger: false, - emitLogs: false, - tls: { - rejectUnauthorized: false - } - }); - + let imapClient: any = null; try { - await client.connect(); - - // Open INBOX - await client.mailboxOpen('INBOX'); - - // Fetch the email with UID search - const message = await client.fetchOne(id, { - uid: true, - source: true, - envelope: true, - bodyStructure: true, - flags: true + imapClient = new ImapFlow({ + host: credentials.host, + port: credentials.port, + secure: true, + auth: { + user: credentials.email, + pass: credentials.password, + }, + logger: false, }); - + + await imapClient.connect(); + console.log(`Connected to IMAP server to fetch full email ${id}`); + + // Select mailbox + const mailboxData = await imapClient.mailboxOpen(folder); + console.log(`Opened mailbox ${folder} to fetch email ${id}`); + + // Fetch the complete email with its source + const message = await imapClient.fetchOne(Number(id), { + source: true, + envelope: true + }); + if (!message) { return NextResponse.json( - { error: 'Email not found' }, + { error: "Email not found" }, { status: 404 } ); } - // Parse the email content - const emailContent = { - id: message.uid.toString(), - from: message.envelope.from?.[0]?.address || '', - fromName: message.envelope.from?.[0]?.name || - message.envelope.from?.[0]?.address?.split('@')[0] || '', - to: message.envelope.to?.map((addr: any) => addr.address).join(', ') || '', - subject: message.envelope.subject || '(No subject)', - date: message.envelope.date?.toISOString() || new Date().toISOString(), - content: message.source?.toString() || '', - read: message.flags.has('\\Seen'), - starred: message.flags.has('\\Flagged'), - flags: Array.from(message.flags), - hasAttachments: message.bodyStructure?.type === 'multipart' - }; - - // Cache the email content (with a 15-minute expiry) - emailContentCache.set(cacheKey, emailContent); - setTimeout(() => emailContentCache.delete(cacheKey), 15 * 60 * 1000); - - // Return the email content - return NextResponse.json(emailContent); + const { source, envelope } = message; + + // Parse the full email content + const parsedEmail = await simpleParser(source.toString()); + + // Return only the content + return NextResponse.json({ + id, + subject: envelope.subject, + content: parsedEmail.html || parsedEmail.textAsHtml || parsedEmail.text || '', + contentFetched: true + }); + } catch (error: any) { + console.error("Error fetching email content:", error); + return NextResponse.json( + { error: "Failed to fetch email content", message: error.message }, + { status: 500 } + ); } finally { - try { - await client.logout(); - } catch (e) { - console.error('Error during IMAP logout:', e); + // Close the mailbox and connection + if (imapClient) { + try { + await imapClient.mailboxClose(); + await imapClient.logout(); + } catch (e) { + console.error("Error closing IMAP connection:", e); + } } } - } catch (error) { - console.error('Error fetching email:', error); + } catch (error: any) { + console.error("Error in GET:", error); return NextResponse.json( - { error: 'Failed to fetch email content' }, + { error: "Internal server error", message: error.message }, + { status: 500 } + ); + } +} + +// Add a route to mark email as read +export async function POST( + request: Request, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session || !session.user?.id) { + return NextResponse.json( + { error: "Not authenticated" }, + { status: 401 } + ); + } + + const { id } = params; + if (!id) { + return NextResponse.json( + { error: "Missing email ID" }, + { status: 400 } + ); + } + + const { action } = await request.json(); + + if (action !== 'mark-read' && action !== 'mark-unread') { + return NextResponse.json( + { error: "Invalid action. Supported actions: mark-read, mark-unread" }, + { status: 400 } + ); + } + + // Get mail credentials + const credentials = await prisma.mailCredentials.findUnique({ + where: { + userId: session.user.id, + }, + }); + + if (!credentials) { + return NextResponse.json( + { error: "No mail credentials found" }, + { status: 404 } + ); + } + + const { searchParams } = new URL(request.url); + const folder = searchParams.get("folder") || "INBOX"; + + // Create IMAP client + let imapClient: any = null; + try { + imapClient = new ImapFlow({ + host: credentials.host, + port: credentials.port, + secure: true, + auth: { + user: credentials.email, + pass: credentials.password, + }, + logger: false, + }); + + await imapClient.connect(); + + // Select mailbox + await imapClient.mailboxOpen(folder); + + // Set flag based on action + if (action === 'mark-read') { + await imapClient.messageFlagsAdd(Number(id), ['\\Seen']); + } else { + await imapClient.messageFlagsRemove(Number(id), ['\\Seen']); + } + + return NextResponse.json({ success: true }); + } catch (error: any) { + console.error(`Error ${action === 'mark-read' ? 'marking email as read' : 'marking email as unread'}:`, error); + return NextResponse.json( + { error: `Failed to ${action === 'mark-read' ? 'mark email as read' : 'mark email as unread'}`, message: error.message }, + { status: 500 } + ); + } finally { + // Close the mailbox and connection + if (imapClient) { + try { + await imapClient.mailboxClose(); + await imapClient.logout(); + } catch (e) { + console.error("Error closing IMAP connection:", e); + } + } + } + } catch (error: any) { + console.error("Error in POST:", error); + return NextResponse.json( + { error: "Internal server error", message: error.message }, { status: 500 } ); } diff --git a/app/api/courrier/route.ts b/app/api/courrier/route.ts index 818898fa..d5e5ea6e 100644 --- a/app/api/courrier/route.ts +++ b/app/api/courrier/route.ts @@ -253,19 +253,34 @@ export async function GET(request: Request) { bodyStructure: true, internalDate: true, size: true, - source: true // Include full message source to get content + // Only fetch a preview of the body initially for faster loading + bodyParts: [ + { + query: { + type: "text", + }, + limit: 5000, // Limit to first 5KB of text + } + ] }); if (!message) continue; - const { envelope, flags, bodyStructure, internalDate, size, source } = message; + const { envelope, flags, bodyStructure, internalDate, size, bodyParts } = message; - // Extract content from the message source - let content = ''; - if (source) { - const parsedEmail = await simpleParser(source.toString()); - // Get HTML or text content - content = parsedEmail.html || parsedEmail.text || ''; + // Extract content from the body parts for a preview + let preview = ''; + if (bodyParts && bodyParts.length > 0) { + const textPart = bodyParts.find((part: any) => part.type === 'text/plain'); + const htmlPart = bodyParts.find((part: any) => part.type === 'text/html'); + // Prefer text for preview as it's smaller and faster to process + const content = textPart?.content || htmlPart?.content || ''; + + if (typeof content === 'string') { + preview = content.substring(0, 150) + '...'; + } else if (Buffer.isBuffer(content)) { + preview = content.toString('utf-8', 0, 150) + '...'; + } } // Convert attachments to our format @@ -335,7 +350,10 @@ export async function GET(request: Request) { hasAttachments: attachments.length > 0, attachments, size, - content // Include content directly in email object + // Just include the preview instead of the full content initially + preview, + // Store the fetched state to know we only have preview + contentFetched: false }); } catch (messageError) { console.error(`Error fetching message ${id}:`, messageError); diff --git a/app/courrier/page.tsx b/app/courrier/page.tsx index 506aa88f..879fb789 100644 --- a/app/courrier/page.tsx +++ b/app/courrier/page.tsx @@ -49,13 +49,16 @@ export interface Email { to: string; subject: string; content: string; - body?: string; // For backward compatibility + preview?: string; // Preview content for list view + body?: string; // For backward compatibility date: string; read: boolean; starred: boolean; attachments?: { name: string; url: string }[]; folder: string; cc?: string; + bcc?: string; + contentFetched?: boolean; // Track if full content has been fetched } interface Attachment { @@ -115,22 +118,42 @@ function EmailContent({ email }: { email: Email }) { setDebugInfo(null); try { console.log('Loading content for email:', email.id); - console.log('Email content length:', email.content?.length || 0); - // Check if content is available in either content property or body property (for backward compatibility) - const emailContent = email.content || email.body || ''; - - if (!emailContent) { - console.log('No content available for email:', email.id); + // Check if we need to fetch full content + if (!email.content || email.content.length === 0) { + console.log('Fetching full content for email:', email.id); + + const response = await fetch(`/api/courrier/${email.id}?folder=${encodeURIComponent(email.folder || 'INBOX')}`); + + if (!response.ok) { + throw new Error(`Failed to fetch email content: ${response.status}`); + } + + const fullContent = await response.json(); + if (mounted) { - setContent(
No content available
); - setDebugInfo('No content available for this email'); + // Update the email content with the fetched full content + email.content = fullContent.content; + + // Render the content + const sanitizedHtml = DOMPurify.sanitize(fullContent.content); + setContent( +
+ ); + setDebugInfo('Rendered fetched HTML content'); + setError(null); setIsLoading(false); } return; } - - const formattedEmail = emailContent.trim(); + + // Use existing content if available + console.log('Using existing content for email'); + + const formattedEmail = email.content.trim(); if (!formattedEmail) { console.log('Empty content for email:', email.id); if (mounted) { @@ -141,16 +164,22 @@ function EmailContent({ email }: { email: Email }) { return; } - console.log('Parsing email content:', formattedEmail.substring(0, 100) + '...'); - const parsedEmail = await decodeEmail(formattedEmail); - console.log('Parsed email result:', { - hasHtml: !!parsedEmail.html, - hasText: !!parsedEmail.text, - htmlLength: parsedEmail.html?.length || 0, - textLength: parsedEmail.text?.length || 0 - }); - - if (mounted) { + // Check if content is already HTML + if (formattedEmail.startsWith('<') && formattedEmail.endsWith('>')) { + // Content is likely HTML, sanitize and display directly + const sanitizedHtml = DOMPurify.sanitize(formattedEmail); + setContent( +
+ ); + setDebugInfo('Rendered existing HTML content'); + } else { + // Use mailparser for more complex formats + console.log('Parsing email content'); + const parsedEmail = await decodeEmail(formattedEmail); + if (parsedEmail.html) { const sanitizedHtml = DOMPurify.sanitize(parsedEmail.html); setContent( @@ -159,27 +188,30 @@ function EmailContent({ email }: { email: Email }) { dangerouslySetInnerHTML={{ __html: sanitizedHtml }} /> ); - setDebugInfo('Rendered HTML content'); + setDebugInfo('Rendered HTML content from parser'); } else if (parsedEmail.text) { setContent(
{parsedEmail.text}
); - setDebugInfo('Rendered text content'); + setDebugInfo('Rendered text content from parser'); } else { setContent(
No displayable content available
); setDebugInfo('No HTML or text content in parsed email'); } - setError(null); - setIsLoading(false); } + + setError(null); } catch (err) { console.error('Error rendering email content:', err); if (mounted) { setError('Error rendering email content. Please try again.'); setDebugInfo(err instanceof Error ? err.message : 'Unknown error'); setContent(null); + } + } finally { + if (mounted) { setIsLoading(false); } } @@ -190,12 +222,13 @@ function EmailContent({ email }: { email: Email }) { return () => { mounted = false; }; - }, [email?.id, email?.content, email?.body]); + }, [email?.id, email?.content, email?.folder]); if (isLoading) { return (
+ Loading email content...
); } @@ -370,23 +403,41 @@ function EmailPreview({ email }: { email: Email }) { let mounted = true; async function loadPreview() { - if (!email?.content) { + if (!email) { if (mounted) setPreview('No content available'); return; } + // If email already has a preview, use it directly + if (email.preview) { + if (mounted) setPreview(email.preview); + return; + } + setIsLoading(true); try { - const decoded = await decodeEmail(email.content); - if (mounted) { - if (decoded.text) { - setPreview(decoded.text.substring(0, 150) + '...'); - } else if (decoded.html) { - const cleanText = decoded.html.replace(/<[^>]*>/g, ' ').trim(); - setPreview(cleanText.substring(0, 150) + '...'); - } else { - setPreview('No preview available'); + // If we have the content already, extract preview from it + if (email.content) { + const plainText = email.content.replace(/<[^>]*>/g, ' ').trim(); + if (mounted) { + setPreview(plainText.substring(0, 150) + '...'); } + } else { + // Fallback to using parser for older emails + const decoded = await decodeEmail(email.content || ''); + if (mounted) { + if (decoded.text) { + setPreview(decoded.text.substring(0, 150) + '...'); + } else if (decoded.html) { + const cleanText = decoded.html.replace(/<[^>]*>/g, ' ').trim(); + setPreview(cleanText.substring(0, 150) + '...'); + } else { + setPreview('No preview available'); + } + } + } + + if (mounted) { setError(null); } } catch (err) { @@ -405,7 +456,7 @@ function EmailPreview({ email }: { email: Email }) { return () => { mounted = false; }; - }, [email?.content]); + }, [email]); if (isLoading) { return Loading preview...; @@ -795,22 +846,29 @@ export default function CourrierPage() { // Set selected email from our existing data (which now includes full content) setSelectedEmail(selectedEmail); - // Try to mark as read in the background - try { - await fetch(`/api/courrier/${emailId}/mark-read`, { - method: 'POST', - }); - - // Update read status in the list - setEmails(prevEmails => - prevEmails.map(email => - email.id === emailId - ? { ...email, read: true } - : email - ) - ); - } catch (error) { - console.error('Error marking email as read:', error); + // Try to mark as read in the background if not already read + if (!selectedEmail.read) { + try { + // Use the new API endpoint + await fetch(`/api/courrier/${emailId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ action: 'mark-read' }), + }); + + // Update read status in the list + setEmails(prevEmails => + prevEmails.map(email => + email.id === emailId + ? { ...email, read: true } + : email + ) + ); + } catch (error) { + console.error('Error marking email as read:', error); + } } } catch (error) { console.error('Error selecting email:', error);