panel 2 courier api restore

This commit is contained in:
alma 2025-04-25 20:32:29 +02:00
parent 3aae79e76b
commit 522683b599
3 changed files with 310 additions and 134 deletions

View File

@ -11,6 +11,7 @@ import { ImapFlow } from 'imapflow';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { simpleParser } from 'mailparser';
// Simple in-memory cache for email content // Simple in-memory cache for email content
const emailContentCache = new Map<string, any>(); const emailContentCache = new Map<string, any>();
@ -20,108 +21,207 @@ export async function GET(
{ params }: { params: { id: string } } { params }: { params: { id: string } }
) { ) {
try { try {
const { id } = await Promise.resolve(params);
// Authentication check
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session || !session.user?.id) {
return NextResponse.json( return NextResponse.json(
{ error: 'Unauthorized' }, { error: "Not authenticated" },
{ status: 401 } { status: 401 }
); );
} }
// Check cache first const { id } = params;
const cacheKey = `email:${session.user.id}:${id}`; if (!id) {
if (emailContentCache.has(cacheKey)) { return NextResponse.json(
return NextResponse.json(emailContentCache.get(cacheKey)); { error: "Missing email ID" },
{ status: 400 }
);
} }
// Get credentials from database // Get mail credentials
const credentials = await prisma.mailCredentials.findUnique({ const credentials = await prisma.mailCredentials.findUnique({
where: { where: {
userId: session.user.id userId: session.user.id,
} },
}); });
if (!credentials) { if (!credentials) {
return NextResponse.json( return NextResponse.json(
{ error: 'No mail credentials found. Please configure your email account.' }, { error: "No mail credentials found" },
{ status: 401 } { status: 404 }
); );
} }
const { searchParams } = new URL(request.url);
const folder = searchParams.get("folder") || "INBOX";
// Create IMAP client // Create IMAP client
const client = new ImapFlow({ let imapClient: any = null;
host: credentials.host,
port: credentials.port,
secure: true,
auth: {
user: credentials.email,
pass: credentials.password,
},
logger: false,
emitLogs: false,
tls: {
rejectUnauthorized: false
}
});
try { try {
await client.connect(); imapClient = new ImapFlow({
host: credentials.host,
// Open INBOX port: credentials.port,
await client.mailboxOpen('INBOX'); secure: true,
auth: {
// Fetch the email with UID search user: credentials.email,
const message = await client.fetchOne(id, { pass: credentials.password,
uid: true, },
source: true, logger: false,
envelope: true,
bodyStructure: true,
flags: true
}); });
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) { if (!message) {
return NextResponse.json( return NextResponse.json(
{ error: 'Email not found' }, { error: "Email not found" },
{ status: 404 } { status: 404 }
); );
} }
// Parse the email content const { source, envelope } = message;
const emailContent = {
id: message.uid.toString(), // Parse the full email content
from: message.envelope.from?.[0]?.address || '', const parsedEmail = await simpleParser(source.toString());
fromName: message.envelope.from?.[0]?.name ||
message.envelope.from?.[0]?.address?.split('@')[0] || '', // Return only the content
to: message.envelope.to?.map((addr: any) => addr.address).join(', ') || '', return NextResponse.json({
subject: message.envelope.subject || '(No subject)', id,
date: message.envelope.date?.toISOString() || new Date().toISOString(), subject: envelope.subject,
content: message.source?.toString() || '', content: parsedEmail.html || parsedEmail.textAsHtml || parsedEmail.text || '',
read: message.flags.has('\\Seen'), contentFetched: true
starred: message.flags.has('\\Flagged'), });
flags: Array.from(message.flags), } catch (error: any) {
hasAttachments: message.bodyStructure?.type === 'multipart' console.error("Error fetching email content:", error);
}; return NextResponse.json(
{ error: "Failed to fetch email content", message: error.message },
// Cache the email content (with a 15-minute expiry) { status: 500 }
emailContentCache.set(cacheKey, emailContent); );
setTimeout(() => emailContentCache.delete(cacheKey), 15 * 60 * 1000);
// Return the email content
return NextResponse.json(emailContent);
} finally { } finally {
try { // Close the mailbox and connection
await client.logout(); if (imapClient) {
} catch (e) { try {
console.error('Error during IMAP logout:', e); await imapClient.mailboxClose();
await imapClient.logout();
} catch (e) {
console.error("Error closing IMAP connection:", e);
}
} }
} }
} catch (error) { } catch (error: any) {
console.error('Error fetching email:', error); console.error("Error in GET:", error);
return NextResponse.json( 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 } { status: 500 }
); );
} }

View File

@ -253,19 +253,34 @@ export async function GET(request: Request) {
bodyStructure: true, bodyStructure: true,
internalDate: true, internalDate: true,
size: 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; 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 // Extract content from the body parts for a preview
let content = ''; let preview = '';
if (source) { if (bodyParts && bodyParts.length > 0) {
const parsedEmail = await simpleParser(source.toString()); const textPart = bodyParts.find((part: any) => part.type === 'text/plain');
// Get HTML or text content const htmlPart = bodyParts.find((part: any) => part.type === 'text/html');
content = parsedEmail.html || parsedEmail.text || ''; // 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 // Convert attachments to our format
@ -335,7 +350,10 @@ export async function GET(request: Request) {
hasAttachments: attachments.length > 0, hasAttachments: attachments.length > 0,
attachments, attachments,
size, 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) { } catch (messageError) {
console.error(`Error fetching message ${id}:`, messageError); console.error(`Error fetching message ${id}:`, messageError);

View File

@ -49,13 +49,16 @@ export interface Email {
to: string; to: string;
subject: string; subject: string;
content: string; content: string;
body?: string; // For backward compatibility preview?: string; // Preview content for list view
body?: string; // For backward compatibility
date: string; date: string;
read: boolean; read: boolean;
starred: boolean; starred: boolean;
attachments?: { name: string; url: string }[]; attachments?: { name: string; url: string }[];
folder: string; folder: string;
cc?: string; cc?: string;
bcc?: string;
contentFetched?: boolean; // Track if full content has been fetched
} }
interface Attachment { interface Attachment {
@ -115,22 +118,42 @@ function EmailContent({ email }: { email: Email }) {
setDebugInfo(null); setDebugInfo(null);
try { try {
console.log('Loading content for email:', email.id); 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) // Check if we need to fetch full content
const emailContent = email.content || email.body || ''; if (!email.content || email.content.length === 0) {
console.log('Fetching full content for email:', email.id);
if (!emailContent) {
console.log('No content available 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) { if (mounted) {
setContent(<div className="text-gray-500">No content available</div>); // Update the email content with the fetched full content
setDebugInfo('No content available for this email'); email.content = fullContent.content;
// Render the content
const sanitizedHtml = DOMPurify.sanitize(fullContent.content);
setContent(
<div
className="email-content prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
);
setDebugInfo('Rendered fetched HTML content');
setError(null);
setIsLoading(false); setIsLoading(false);
} }
return; return;
} }
const formattedEmail = emailContent.trim(); // Use existing content if available
console.log('Using existing content for email');
const formattedEmail = email.content.trim();
if (!formattedEmail) { if (!formattedEmail) {
console.log('Empty content for email:', email.id); console.log('Empty content for email:', email.id);
if (mounted) { if (mounted) {
@ -141,16 +164,22 @@ function EmailContent({ email }: { email: Email }) {
return; return;
} }
console.log('Parsing email content:', formattedEmail.substring(0, 100) + '...'); // Check if content is already HTML
const parsedEmail = await decodeEmail(formattedEmail); if (formattedEmail.startsWith('<') && formattedEmail.endsWith('>')) {
console.log('Parsed email result:', { // Content is likely HTML, sanitize and display directly
hasHtml: !!parsedEmail.html, const sanitizedHtml = DOMPurify.sanitize(formattedEmail);
hasText: !!parsedEmail.text, setContent(
htmlLength: parsedEmail.html?.length || 0, <div
textLength: parsedEmail.text?.length || 0 className="email-content prose prose-sm max-w-none dark:prose-invert"
}); dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
if (mounted) { );
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) { if (parsedEmail.html) {
const sanitizedHtml = DOMPurify.sanitize(parsedEmail.html); const sanitizedHtml = DOMPurify.sanitize(parsedEmail.html);
setContent( setContent(
@ -159,27 +188,30 @@ function EmailContent({ email }: { email: Email }) {
dangerouslySetInnerHTML={{ __html: sanitizedHtml }} dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/> />
); );
setDebugInfo('Rendered HTML content'); setDebugInfo('Rendered HTML content from parser');
} else if (parsedEmail.text) { } else if (parsedEmail.text) {
setContent( setContent(
<div className="email-content whitespace-pre-wrap"> <div className="email-content whitespace-pre-wrap">
{parsedEmail.text} {parsedEmail.text}
</div> </div>
); );
setDebugInfo('Rendered text content'); setDebugInfo('Rendered text content from parser');
} else { } else {
setContent(<div className="text-gray-500">No displayable content available</div>); setContent(<div className="text-gray-500">No displayable content available</div>);
setDebugInfo('No HTML or text content in parsed email'); setDebugInfo('No HTML or text content in parsed email');
} }
setError(null);
setIsLoading(false);
} }
setError(null);
} catch (err) { } catch (err) {
console.error('Error rendering email content:', err); console.error('Error rendering email content:', err);
if (mounted) { if (mounted) {
setError('Error rendering email content. Please try again.'); setError('Error rendering email content. Please try again.');
setDebugInfo(err instanceof Error ? err.message : 'Unknown error'); setDebugInfo(err instanceof Error ? err.message : 'Unknown error');
setContent(null); setContent(null);
}
} finally {
if (mounted) {
setIsLoading(false); setIsLoading(false);
} }
} }
@ -190,12 +222,13 @@ function EmailContent({ email }: { email: Email }) {
return () => { return () => {
mounted = false; mounted = false;
}; };
}, [email?.id, email?.content, email?.body]); }, [email?.id, email?.content, email?.folder]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div> <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
<span className="ml-3 text-gray-500">Loading email content...</span>
</div> </div>
); );
} }
@ -370,23 +403,41 @@ function EmailPreview({ email }: { email: Email }) {
let mounted = true; let mounted = true;
async function loadPreview() { async function loadPreview() {
if (!email?.content) { if (!email) {
if (mounted) setPreview('No content available'); if (mounted) setPreview('No content available');
return; return;
} }
// If email already has a preview, use it directly
if (email.preview) {
if (mounted) setPreview(email.preview);
return;
}
setIsLoading(true); setIsLoading(true);
try { try {
const decoded = await decodeEmail(email.content); // If we have the content already, extract preview from it
if (mounted) { if (email.content) {
if (decoded.text) { const plainText = email.content.replace(/<[^>]*>/g, ' ').trim();
setPreview(decoded.text.substring(0, 150) + '...'); if (mounted) {
} else if (decoded.html) { setPreview(plainText.substring(0, 150) + '...');
const cleanText = decoded.html.replace(/<[^>]*>/g, ' ').trim();
setPreview(cleanText.substring(0, 150) + '...');
} else {
setPreview('No preview available');
} }
} 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); setError(null);
} }
} catch (err) { } catch (err) {
@ -405,7 +456,7 @@ function EmailPreview({ email }: { email: Email }) {
return () => { return () => {
mounted = false; mounted = false;
}; };
}, [email?.content]); }, [email]);
if (isLoading) { if (isLoading) {
return <span className="text-gray-400">Loading preview...</span>; return <span className="text-gray-400">Loading preview...</span>;
@ -795,22 +846,29 @@ export default function CourrierPage() {
// Set selected email from our existing data (which now includes full content) // Set selected email from our existing data (which now includes full content)
setSelectedEmail(selectedEmail); setSelectedEmail(selectedEmail);
// Try to mark as read in the background // Try to mark as read in the background if not already read
try { if (!selectedEmail.read) {
await fetch(`/api/courrier/${emailId}/mark-read`, { try {
method: 'POST', // Use the new API endpoint
}); await fetch(`/api/courrier/${emailId}`, {
method: 'POST',
// Update read status in the list headers: {
setEmails(prevEmails => 'Content-Type': 'application/json',
prevEmails.map(email => },
email.id === emailId body: JSON.stringify({ action: 'mark-read' }),
? { ...email, read: true } });
: email
) // Update read status in the list
); setEmails(prevEmails =>
} catch (error) { prevEmails.map(email =>
console.error('Error marking email as read:', error); email.id === emailId
? { ...email, read: true }
: email
)
);
} catch (error) {
console.error('Error marking email as read:', error);
}
} }
} catch (error) { } catch (error) {
console.error('Error selecting email:', error); console.error('Error selecting email:', error);