'use server'; import 'server-only'; import { ImapFlow } from 'imapflow'; import nodemailer from 'nodemailer'; import { prisma } from '@/lib/prisma'; import { simpleParser } from 'mailparser'; import { cacheEmailCredentials, getCachedEmailCredentials, cacheEmailList, getCachedEmailList, cacheEmailContent, getCachedEmailContent, cacheImapSession, getCachedImapSession, invalidateFolderCache, invalidateEmailContentCache } from '@/lib/redis'; import { EmailCredentials, EmailMessage, EmailAddress, EmailAttachment } from '@/lib/types'; import { PrismaClient } from '@prisma/client'; // Types specific to this service export interface EmailListResult { emails: EmailMessage[]; totalEmails: number; page: number; perPage: number; totalPages: number; folder: string; mailboxes: string[]; } // Connection pool to reuse IMAP clients const connectionPool: Record = {}; const CONNECTION_TIMEOUT = 5 * 60 * 1000; // 5 minutes // Clean up idle connections periodically setInterval(() => { const now = Date.now(); Object.entries(connectionPool).forEach(([key, { client, lastUsed }]) => { if (now - lastUsed > CONNECTION_TIMEOUT) { console.log(`Closing idle IMAP connection for ${key}`); client.logout().catch(err => { console.error(`Error closing connection for ${key}:`, err); }); delete connectionPool[key]; } }); }, 60 * 1000); // Check every minute /** * Get IMAP connection for a user, reusing existing connections when possible */ export async function getImapConnection(userId: string): Promise { console.log(`Getting IMAP connection for user ${userId}`); // First try to get credentials from Redis cache let credentials = await getCachedEmailCredentials(userId); // If not in cache, get from database and cache them if (!credentials) { console.log(`Credentials not found in cache for ${userId}, attempting database lookup`); credentials = await getUserEmailCredentials(userId); if (!credentials) { throw new Error('No email credentials found'); } // Cache credentials for future use await cacheEmailCredentials(userId, [credentials]); } // Validate credentials if (!credentials.password) { console.error(`Missing password in credentials for user ${userId}`); throw new Error('No password configured'); } if (!credentials.email || !credentials.host) { console.error(`Incomplete credentials for user ${userId}`); throw new Error('Invalid email credentials configuration'); } const connectionKey = `${userId}:${credentials.email}`; const existingConnection = connectionPool[connectionKey]; // Try to get session data from Redis const sessionData = await getCachedImapSession(userId); // Return existing connection if available and connected if (existingConnection) { try { if (existingConnection.client.usable) { existingConnection.lastUsed = Date.now(); console.log(`Reusing existing IMAP connection for ${connectionKey}`); // Update session data in Redis if (sessionData) { await cacheImapSession(userId, { ...sessionData, lastActive: Date.now() }); } return existingConnection.client; } } catch (error) { console.warn(`Existing connection for ${connectionKey} is not usable, creating new connection`); // Will create a new connection below } } console.log(`Creating new IMAP connection for ${connectionKey}`); // Create new connection 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 } }); try { await client.connect(); console.log(`Successfully connected to IMAP server for ${connectionKey}`); // Store in connection pool connectionPool[connectionKey] = { client, lastUsed: Date.now() }; return client; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error(`IMAP connection error for ${connectionKey}:`, errorMessage); throw new Error(`Failed to connect to IMAP server: ${errorMessage}`); } } /** * Get user's email credentials from database */ export async function getUserEmailCredentials(userId: string): Promise { const credentials = await prisma.mailCredentials.findFirst({ where: { userId }, select: { id: true, userId: true, email: true, password: true, host: true, port: true, secure: true, smtp_host: true, smtp_port: true, smtp_secure: true, display_name: true, color: true, createdAt: true, updatedAt: true } }); if (!credentials) { return null; } // Cache the credentials await cacheEmailCredentials(userId, credentials); return credentials; } /** * Save or update user's email credentials */ export async function saveUserEmailCredentials( userId: string, credentials: EmailCredentials ): Promise { console.log('Saving credentials for user:', userId); // Extract only the fields that exist in the database schema const dbCredentials = { email: credentials.email, password: credentials.password ?? '', host: credentials.host, port: credentials.port }; // Save to database - only using fields that exist in the schema await prisma.mailCredentials.upsert({ where: { userId }, update: dbCredentials, create: { userId, ...dbCredentials } }); // Cache the full credentials object in Redis (with all fields) await cacheEmailCredentials(userId, [credentials]); } // Helper type for IMAP fetch options interface FetchOptions { envelope: boolean; flags: boolean; bodyStructure: boolean; internalDate: boolean; size: boolean; bodyParts: { part: string; query: any; limit?: number }[]; } /** * Get list of emails for a user */ export async function getEmails( userId: string, folder: string = 'INBOX', page: number = 1, perPage: number = 20, searchQuery: string = '', accountId?: string ): Promise { // Try to get from cache first if (!searchQuery) { const cacheKey = accountId ? `${userId}:${accountId}:${folder}` : `${userId}:${folder}`; const cachedResult = await getCachedEmailList(userId, folder, page, perPage); if (cachedResult) { console.log(`Using cached email list for ${cacheKey}:${page}:${perPage}`); return cachedResult; } } console.log(`Cache miss for emails ${userId}:${folder}:${page}:${perPage}${accountId ? ` for account ${accountId}` : ''}, fetching from IMAP`); // If accountId is provided, connect to that specific account let client: ImapFlow; if (accountId) { try { // Get account from database const account = await prisma.mailCredentials.findUnique({ where: { id: accountId, userId } }); if (!account) { throw new Error(`Account with ID ${accountId} not found`); } // Connect to IMAP server for this specific account client = new ImapFlow({ host: account.host, port: account.port, secure: true, // Default to secure connection auth: { user: account.email, pass: account.password, }, logger: false, tls: { rejectUnauthorized: false } }); await client.connect(); } catch (error) { console.error(`Error connecting to account ${accountId}:`, error); // Fallback to default connection client = await getImapConnection(userId); } } else { // Use the default connection logic client = await getImapConnection(userId); } let mailboxes: string[] = []; try { console.log(`[DEBUG] Fetching mailboxes for user ${userId}`); // Get list of mailboxes first try { mailboxes = await getMailboxes(client); console.log(`[DEBUG] Found ${mailboxes.length} mailboxes:`, mailboxes); // Save mailboxes in session data const cachedSession = await getCachedImapSession(userId); await cacheImapSession(userId, { ...(cachedSession || { lastActive: Date.now() }), mailboxes }); console.log(`[DEBUG] Updated cached session with mailboxes for user ${userId}`); } catch (mailboxError) { console.error(`[ERROR] Failed to fetch mailboxes:`, mailboxError); } // Open mailbox const mailboxData = await client.mailboxOpen(folder); const totalMessages = mailboxData.exists; // Calculate range based on total messages const endIdx = page * perPage; const startIdx = (page - 1) * perPage + 1; const from = Math.max(totalMessages - endIdx + 1, 1); const to = Math.max(totalMessages - startIdx + 1, 1); // Empty result if no messages if (totalMessages === 0 || from > to) { const result = { emails: [], totalEmails: 0, page, perPage, totalPages: 0, folder, mailboxes }; // Cache even empty results if (!searchQuery) { await cacheEmailList(userId, folder, page, perPage, result); } return result; } // Search if needed let messageIds: any[] = []; if (searchQuery) { messageIds = await client.search({ body: searchQuery }); messageIds = messageIds.filter(id => id >= from && id <= to); } else { messageIds = Array.from({ length: to - from + 1 }, (_, i) => from + i); } // Fetch messages const emails: EmailMessage[] = []; for (const id of messageIds) { try { // Define fetch options with proper typing const fetchOptions: any = { envelope: true, flags: true, bodyStructure: true, internalDate: true, size: true, bodyParts: [{ part: '1', query: { type: "text" }, limit: 5000 }] }; const message = await client.fetchOne(id, fetchOptions); if (!message) continue; const { envelope, flags, bodyStructure, internalDate, size, bodyParts } = message; // Extract preview content let preview = ''; if (bodyParts && typeof bodyParts === 'object') { // Convert to array if it's a Map const partsArray = Array.isArray(bodyParts) ? bodyParts : Array.from(bodyParts.values()); const textPart = partsArray.find((part: any) => part.type === 'text/plain'); const htmlPart = partsArray.find((part: any) => part.type === 'text/html'); 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) + '...'; } } // Process attachments const attachments: EmailAttachment[] = []; const processAttachments = (node: any, path: Array = []) => { if (!node) return; if (node.type === 'attachment') { attachments.push({ contentId: node.contentId, filename: node.filename || 'attachment', contentType: node.contentType, size: node.size, path: [...path, node.part].join('.') }); } if (node.childNodes) { node.childNodes.forEach((child: any, index: number) => { processAttachments(child, [...path, node.part || index + 1]); }); } }; if (bodyStructure) { processAttachments(bodyStructure); } // Convert flags from Set to boolean checks const flagsArray = Array.from(flags as Set); emails.push({ id: id.toString(), messageId: envelope.messageId, subject: envelope.subject || "(No Subject)", from: envelope.from.map((f: any) => ({ name: f.name || f.address, address: f.address, })), to: envelope.to.map((t: any) => ({ name: t.name || t.address, address: t.address, })), cc: (envelope.cc || []).map((c: any) => ({ name: c.name || c.address, address: c.address, })), bcc: (envelope.bcc || []).map((b: any) => ({ name: b.name || b.address, address: b.address, })), date: internalDate || new Date(), flags: { seen: flagsArray.includes("\\Seen"), flagged: flagsArray.includes("\\Flagged"), answered: flagsArray.includes("\\Answered"), deleted: flagsArray.includes("\\Deleted"), draft: flagsArray.includes("\\Draft"), }, hasAttachments: attachments.length > 0, attachments, size, preview, folder, contentFetched: false }); } catch (error) { console.error(`Error fetching message ${id}:`, error); } } // Sort by date, newest first emails.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); const result = { emails, totalEmails: totalMessages, page, perPage, totalPages: Math.ceil(totalMessages / perPage), folder, mailboxes }; // Always cache the result if it's not a search query, even for pagination if (!searchQuery) { console.log(`Caching email list for ${userId}:${folder}:${page}:${perPage}`); await cacheEmailList(userId, folder, page, perPage, result); } return result; } finally { // Don't logout, keep connection in pool if (folder !== 'INBOX') { try { await client.mailboxClose(); } catch (error) { console.error('Error closing mailbox:', error); } } } } /** * Get a single email with full content */ export async function getEmailContent( userId: string, emailId: string, folder: string = 'INBOX' ): Promise { // Try to get from cache first const cachedEmail = await getCachedEmailContent(userId, emailId); if (cachedEmail) { console.log(`Using cached email content for ${userId}:${emailId}`); return cachedEmail; } console.log(`Cache miss for email content ${userId}:${emailId}, fetching from IMAP`); const client = await getImapConnection(userId); try { await client.mailboxOpen(folder); const message = await client.fetchOne(emailId, { source: true, envelope: true, flags: true }); if (!message) { throw new Error('Email not found'); } const { source, envelope, flags } = message; // Parse the email content, ensuring all styles and structure are preserved const parsedEmail = await simpleParser(source.toString(), { skipHtmlToText: true, // Don't convert HTML to plain text keepCidLinks: true // Keep Content-ID references for inline images }); // Convert flags from Set to boolean checks const flagsArray = Array.from(flags as Set); // Preserve the raw HTML exactly as it was in the original email const rawHtml = parsedEmail.html || ''; const email = { id: emailId, messageId: envelope.messageId, subject: envelope.subject || "(No Subject)", from: envelope.from.map((f: any) => ({ name: f.name || f.address, address: f.address, })), to: envelope.to.map((t: any) => ({ name: t.name || t.address, address: t.address, })), cc: (envelope.cc || []).map((c: any) => ({ name: c.name || c.address, address: c.address, })), bcc: (envelope.bcc || []).map((b: any) => ({ name: b.name || b.address, address: b.address, })), date: envelope.date || new Date(), flags: { seen: flagsArray.includes("\\Seen"), flagged: flagsArray.includes("\\Flagged"), answered: flagsArray.includes("\\Answered"), deleted: flagsArray.includes("\\Deleted"), draft: flagsArray.includes("\\Draft"), }, hasAttachments: parsedEmail.attachments?.length > 0, attachments: parsedEmail.attachments?.map(att => ({ filename: att.filename || 'attachment', contentType: att.contentType, size: att.size || 0 })), // Preserve the exact raw HTML to maintain all styling html: rawHtml, text: parsedEmail.text || undefined, // For content field, prioritize using the raw HTML to preserve all styling content: rawHtml || parsedEmail.text || '', folder, contentFetched: true }; // Cache the email content await cacheEmailContent(userId, emailId, email); return email; } finally { try { await client.mailboxClose(); } catch (error) { console.error('Error closing mailbox:', error); } } } /** * Mark an email as read or unread */ export async function markEmailReadStatus( userId: string, emailId: string, isRead: boolean, folder: string = 'INBOX' ): Promise { const client = await getImapConnection(userId); try { await client.mailboxOpen(folder); if (isRead) { await client.messageFlagsAdd(emailId, ['\\Seen']); } else { await client.messageFlagsRemove(emailId, ['\\Seen']); } // Invalidate content cache since the flags changed await invalidateEmailContentCache(userId, emailId); // Also invalidate folder cache because unread counts may have changed await invalidateFolderCache(userId, folder); return true; } catch (error) { console.error(`Error marking email ${emailId} as ${isRead ? 'read' : 'unread'}:`, error); return false; } finally { try { await client.mailboxClose(); } catch (error) { console.error('Error closing mailbox:', error); } } } /** * Send an email */ export async function sendEmail( userId: string, emailData: { to: string; cc?: string; bcc?: string; subject: string; body: string; attachments?: Array<{ name: string; content: string; type: string; }>; } ): Promise<{ success: boolean; messageId?: string; error?: string }> { const credentials = await getUserEmailCredentials(userId); if (!credentials) { return { success: false, error: 'No email credentials found' }; } // Create SMTP transporter with user's SMTP settings if available const transporter = nodemailer.createTransport({ host: credentials.smtp_host || 'smtp.infomaniak.com', // Use custom SMTP or default port: credentials.smtp_port || 587, secure: credentials.smtp_secure || false, auth: { user: credentials.email, pass: credentials.password, }, tls: { rejectUnauthorized: false } }); try { // Verify connection await transporter.verify(); // Prepare email options const mailOptions = { from: credentials.email, to: emailData.to, cc: emailData.cc || undefined, bcc: emailData.bcc || undefined, subject: emailData.subject || '(No subject)', html: emailData.body, attachments: emailData.attachments?.map(file => ({ filename: file.name, content: file.content, contentType: file.type })) || [] }; // Send email const info = await transporter.sendMail(mailOptions); return { success: true, messageId: info.messageId }; } catch (error) { console.error('Error sending email:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Get list of mailboxes (folders) */ export async function getMailboxes(client: ImapFlow): Promise { try { const list = await client.list(); return list.map(mailbox => mailbox.path); } catch (error) { console.error('Error listing mailboxes:', error); return []; } } /** * Test email connections with given credentials */ export async function testEmailConnection(credentials: EmailCredentials): Promise<{ imap: boolean; smtp: boolean; error?: string; }> { // Test IMAP connection let imapSuccess = false; let smtpSuccess = false; let errorMessage = ''; // First test IMAP const imapClient = new ImapFlow({ host: credentials.host, port: credentials.port, secure: true, auth: { user: credentials.email, pass: credentials.password, }, logger: false, tls: { rejectUnauthorized: false } }); try { await imapClient.connect(); await imapClient.mailboxOpen('INBOX'); imapSuccess = true; } catch (error) { console.error('IMAP connection test failed:', error); errorMessage = error instanceof Error ? error.message : 'Unknown IMAP error'; return { imap: false, smtp: false, error: `IMAP connection failed: ${errorMessage}` }; } finally { try { await imapClient.logout(); } catch (e) { // Ignore logout errors } } // If IMAP successful and SMTP details provided, test SMTP if (credentials.smtp_host && credentials.smtp_port) { const transporter = nodemailer.createTransport({ host: credentials.smtp_host, port: credentials.smtp_port, secure: true, auth: { user: credentials.email, pass: credentials.password, }, tls: { rejectUnauthorized: false } }); try { await transporter.verify(); smtpSuccess = true; } catch (error) { console.error('SMTP connection test failed:', error); errorMessage = error instanceof Error ? error.message : 'Unknown SMTP error'; return { imap: imapSuccess, smtp: false, error: `SMTP connection failed: ${errorMessage}` }; } } else { // If no SMTP details, just mark as successful smtpSuccess = true; } return { imap: imapSuccess, smtp: smtpSuccess }; } // Original simplified function for backward compatibility export async function testImapConnection(credentials: EmailCredentials): Promise { const result = await testEmailConnection(credentials); return result.imap; } // Email formatting functions have been moved to lib/utils/email-formatter.ts // Use those functions instead of the ones previously defined here /** * Force recaching of user credentials from database * This is a helper function to fix issues with missing credentials in Redis */ export async function forceRecacheUserCredentials(userId: string): Promise { try { console.log(`[CREDENTIAL FIX] Attempting to force recache credentials for user ${userId}`); // Get credentials directly from database const dbCredentials = await prisma.mailCredentials.findUnique({ where: { userId }, select: { email: true, password: true, host: true, port: true, secure: true, smtp_host: true, smtp_port: true, smtp_secure: true, display_name: true, color: true } }); if (!dbCredentials) { console.error(`[CREDENTIAL FIX] No credentials found in database for user ${userId}`); return false; } // Log what we found (without revealing the actual password) console.log(`[CREDENTIAL FIX] Found database credentials for user ${userId}:`, { email: dbCredentials.email, hasPassword: !!dbCredentials.password, passwordLength: dbCredentials.password?.length || 0, host: dbCredentials.host, port: dbCredentials.port }); if (!dbCredentials.password) { console.error(`[CREDENTIAL FIX] Password is empty in database for user ${userId}`); return false; } // Try to directly encrypt the password to see if encryption works try { const { encryptData } = await import('@/lib/redis'); const encryptedPassword = encryptData(dbCredentials.password); console.log(`[CREDENTIAL FIX] Successfully test-encrypted password for user ${userId}`); // If we got here, encryption works } catch (encryptError) { console.error(`[CREDENTIAL FIX] Encryption test failed for user ${userId}:`, encryptError); return false; } // Format credentials for caching const credentials = { email: dbCredentials.email, password: dbCredentials.password, host: dbCredentials.host, port: dbCredentials.port, ...(dbCredentials.secure !== undefined && { secure: dbCredentials.secure }), ...(dbCredentials.smtp_host && { smtp_host: dbCredentials.smtp_host }), ...(dbCredentials.smtp_port && { smtp_port: dbCredentials.smtp_port }), ...(dbCredentials.smtp_secure !== undefined && { smtp_secure: dbCredentials.smtp_secure }), ...(dbCredentials.display_name && { display_name: dbCredentials.display_name }), ...(dbCredentials.color && { color: dbCredentials.color }) }; // Try to cache the credentials try { const { cacheEmailCredentials } = await import('@/lib/redis'); await cacheEmailCredentials(userId, [credentials]); console.log(`[CREDENTIAL FIX] Successfully cached credentials for user ${userId}`); // Now verify the credentials were cached correctly const { getEmailCredentials } = await import('@/lib/redis'); const cachedCreds = await getEmailCredentials(userId); if (!cachedCreds) { console.error(`[CREDENTIAL FIX] Failed to verify cached credentials for user ${userId}`); return false; } if (!cachedCreds.password) { console.error(`[CREDENTIAL FIX] Cached credentials missing password for user ${userId}`); return false; } console.log(`[CREDENTIAL FIX] Verified cached credentials for user ${userId}`); return true; } catch (cacheError) { console.error(`[CREDENTIAL FIX] Failed to cache credentials for user ${userId}:`, cacheError); return false; } } catch (error) { console.error(`[CREDENTIAL FIX] Error in force recache process for user ${userId}:`, error); return false; } }