import Imap from 'imap'; import { simpleParser } from 'mailparser'; import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption'; import { listFiles, getFileStats } from '~/server/utils/minio'; interface EmailMessage { id: string; from: string; to: string | string[]; subject: string; body: string; html?: string; timestamp: string; direction: 'sent' | 'received'; threadId?: string; } export default defineEventHandler(async (event) => { const xTagHeader = getRequestHeader(event, "x-tag"); if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) { throw createError({ statusCode: 401, statusMessage: "unauthenticated" }); } try { const body = await readBody(event); const { clientEmail, interestId, sessionId, limit = 20 } = body; if (!clientEmail || !sessionId) { throw createError({ statusCode: 400, statusMessage: "Client email and sessionId are required" }); } // Get encrypted credentials from session const encryptedCredentials = getCredentialsFromSession(sessionId); if (!encryptedCredentials) { // Return empty results instead of throwing error return { success: true, emails: [], threads: [] }; } // Decrypt credentials const { email: userEmail, password } = decryptCredentials(encryptedCredentials); // First, get emails from MinIO cache if available const cachedEmails: EmailMessage[] = []; if (interestId) { try { const files = await listFiles(`client-emails/interest-${interestId}/`, true) as any[]; console.log('Found cached email files:', files.length); for (const file of files) { if (file.name.endsWith('.json') && !file.isFolder) { try { // Use the getDownloadUrl function to get a proper presigned URL const { getDownloadUrl } = await import('~/server/utils/minio'); const downloadUrl = await getDownloadUrl(file.name); const response = await fetch(downloadUrl); const emailData = await response.json(); cachedEmails.push(emailData); } catch (err) { console.error('Failed to read cached email:', file.name, err); } } } } catch (err) { console.error('Failed to list cached emails:', err); } } // Configure IMAP const imapConfig = { user: userEmail, password: password, host: process.env.NUXT_EMAIL_IMAP_HOST || 'mail.portnimara.com', port: parseInt(process.env.NUXT_EMAIL_IMAP_PORT || '993'), tls: true, tlsOptions: { rejectUnauthorized: false }, connTimeout: 10000, // 10 seconds connection timeout authTimeout: 5000 // 5 seconds auth timeout }; // Fetch emails from IMAP with timeout let imapEmails: EmailMessage[] = []; const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('IMAP connection timeout')), 15000) ); try { imapEmails = await Promise.race([ fetchImapEmails(imapConfig, userEmail, clientEmail, limit), timeoutPromise ]); } catch (imapError) { console.error('IMAP fetch failed:', imapError); // Continue with cached emails only } // Combine cached and IMAP emails, remove duplicates const allEmails = [...cachedEmails, ...imapEmails]; const uniqueEmails = Array.from( new Map(allEmails.map(email => [email.id, email])).values() ); // Sort by timestamp uniqueEmails.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ); // Group into threads const threads = groupIntoThreads(uniqueEmails); return { success: true, emails: uniqueEmails, threads: threads }; } catch (error) { console.error('Failed to fetch email thread:', error); if (error instanceof Error) { throw createError({ statusCode: 500, statusMessage: `Failed to fetch emails: ${error.message}` }); } else { throw createError({ statusCode: 500, statusMessage: "An unexpected error occurred", }); } } }); // Separate function for IMAP fetching with proper cleanup async function fetchImapEmails( imapConfig: any, userEmail: string, clientEmail: string, limit: number ): Promise { return new Promise((resolve, reject) => { const emails: EmailMessage[] = []; const imap = new Imap(imapConfig); let isResolved = false; const cleanup = () => { if (!isResolved) { isResolved = true; try { imap.end(); } catch (e) { console.error('Error closing IMAP connection:', e); } } }; imap.once('ready', () => { // Search in both INBOX and Sent folders const foldersToSearch = ['INBOX', 'Sent', 'Sent Items', 'Sent Mail']; let currentFolderIndex = 0; const allEmails: EmailMessage[] = []; const searchNextFolder = () => { if (currentFolderIndex >= foldersToSearch.length) { cleanup(); resolve(allEmails); return; } const folderName = foldersToSearch[currentFolderIndex]; currentFolderIndex++; imap.openBox(folderName, true, (err, box) => { if (err) { console.log(`Folder ${folderName} not found, trying next...`); searchNextFolder(); return; } console.log(`Searching in folder: ${folderName}`); // Search for emails both sent and received with this client const searchCriteria = [ 'OR', ['FROM', clientEmail], ['TO', clientEmail], ['CC', clientEmail], ['BCC', clientEmail] ]; imap.search(searchCriteria, (err, results) => { if (err) { console.error(`Search error in ${folderName}:`, err); searchNextFolder(); return; } if (!results || results.length === 0) { console.log(`No emails found in ${folderName}`); searchNextFolder(); return; } console.log(`Found ${results.length} emails in ${folderName}`); const messagesToFetch = results.slice(-limit); let messagesProcessed = 0; const fetch = imap.fetch(messagesToFetch, { bodies: '', struct: true, envelope: true }); fetch.on('message', (msg, seqno) => { msg.on('body', (stream, info) => { simpleParser(stream as any, async (err: any, parsed: any) => { if (err) { console.error('Parse error:', err); messagesProcessed++; if (messagesProcessed === messagesToFetch.length) { searchNextFolder(); } return; } const email: EmailMessage = { id: parsed.messageId || `${Date.now()}-${seqno}`, from: parsed.from?.text || '', to: Array.isArray(parsed.to) ? parsed.to.map((addr: any) => addr.text).join(', ') : parsed.to?.text || '', subject: parsed.subject || '', body: parsed.text || '', html: parsed.html || undefined, timestamp: parsed.date?.toISOString() || new Date().toISOString(), direction: parsed.from?.text.toLowerCase().includes(userEmail.toLowerCase()) ? 'sent' : 'received' }; if (parsed.headers.has('in-reply-to')) { email.threadId = parsed.headers.get('in-reply-to') as string; } allEmails.push(email); messagesProcessed++; if (messagesProcessed === messagesToFetch.length) { searchNextFolder(); } }); }); }); fetch.once('error', (err) => { console.error('Fetch error:', err); searchNextFolder(); }); fetch.once('end', () => { if (messagesProcessed === 0) { searchNextFolder(); } }); }); }); }; searchNextFolder(); }); imap.once('error', (err: any) => { cleanup(); reject(err); }); imap.once('end', () => { if (!isResolved) { isResolved = true; resolve(emails); } }); imap.connect(); }); } // Group emails into threads based on subject and references function groupIntoThreads(emails: EmailMessage[]): any[] { const threads = new Map(); emails.forEach(email => { // Normalize subject by removing Re:, Fwd:, etc. const normalizedSubject = email.subject .replace(/^(Re:|Fwd:|Fw:)\s*/gi, '') .trim(); // Find existing thread or create new one let threadFound = false; for (const [threadId, threadEmails] of threads.entries()) { const threadSubject = threadEmails[0].subject .replace(/^(Re:|Fwd:|Fw:)\s*/gi, '') .trim(); if (threadSubject === normalizedSubject) { threadEmails.push(email); threadFound = true; break; } } if (!threadFound) { threads.set(email.id, [email]); } }); // Convert to array format return Array.from(threads.entries()).map(([threadId, emails]) => ({ id: threadId, subject: emails[0].subject, emailCount: emails.length, latestTimestamp: emails[emails.length - 1].timestamp, emails: emails })); }